feat(mui): migrate Attendance Admin (Správa docházky) onto MUI kit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link as RouterLink } from "react-router-dom";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
import {
|
import {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDatetime,
|
formatDatetime,
|
||||||
@@ -6,8 +8,8 @@ import {
|
|||||||
calculateWorkMinutes,
|
calculateWorkMinutes,
|
||||||
formatMinutes,
|
formatMinutes,
|
||||||
getLeaveTypeName,
|
getLeaveTypeName,
|
||||||
getLeaveTypeBadgeClass,
|
|
||||||
} from "../utils/attendanceHelpers";
|
} from "../utils/attendanceHelpers";
|
||||||
|
import { DataTable, StatusChip, EmptyState, type DataColumn } from "../ui";
|
||||||
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
|
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
|
||||||
|
|
||||||
interface AttendanceRecord extends HookAttendanceRecord {
|
interface AttendanceRecord extends HookAttendanceRecord {
|
||||||
@@ -23,6 +25,56 @@ interface AttendanceShiftTableProps {
|
|||||||
onDelete: (record: AttendanceRecord) => void;
|
onDelete: (record: AttendanceRecord) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EditIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const DeleteIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<polyline points="3 6 5 6 21 6" />
|
||||||
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Map an attendance leave_type to a StatusChip semantic color (mirrors the
|
||||||
|
* legacy getLeaveTypeBadgeClass colors): work→info, vacation→info(blue),
|
||||||
|
* sick→error(red), unpaid→default(gray). */
|
||||||
|
function leaveTypeChipColor(
|
||||||
|
type: string,
|
||||||
|
): "default" | "success" | "error" | "warning" | "info" {
|
||||||
|
switch (type) {
|
||||||
|
case "vacation":
|
||||||
|
return "info";
|
||||||
|
case "sick":
|
||||||
|
return "error";
|
||||||
|
case "unpaid":
|
||||||
|
return "default";
|
||||||
|
default:
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function formatBreak(record: AttendanceRecord): string {
|
function formatBreak(record: AttendanceRecord): string {
|
||||||
if (record.break_start && record.break_end) {
|
if (record.break_start && record.break_end) {
|
||||||
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
|
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
|
||||||
@@ -36,9 +88,7 @@ function formatBreak(record: AttendanceRecord): string {
|
|||||||
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
||||||
if (record.project_logs && record.project_logs.length > 0) {
|
if (record.project_logs && record.project_logs.length > 0) {
|
||||||
return (
|
return (
|
||||||
<div
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
|
||||||
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
|
|
||||||
>
|
|
||||||
{record.project_logs.map((log, i) => {
|
{record.project_logs.map((log, i) => {
|
||||||
let h: number,
|
let h: number,
|
||||||
m: number,
|
m: number,
|
||||||
@@ -65,33 +115,32 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<span
|
<StatusChip
|
||||||
key={log.id ?? i}
|
key={log.id ?? i}
|
||||||
className="admin-badge"
|
color={isActive ? "info" : "default"}
|
||||||
style={{
|
label={`${log.project_name || `#${log.project_id}`} ${
|
||||||
fontSize: "0.7rem",
|
durationValid
|
||||||
display: "inline-block",
|
|
||||||
background: isActive ? "var(--accent-light)" : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{log.project_name || `#${log.project_id}`}{" "}
|
|
||||||
{durationValid
|
|
||||||
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
|
? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
|
||||||
: "—"}
|
: "—"
|
||||||
</span>
|
}`}
|
||||||
|
sx={{ fontSize: "0.7rem", maxWidth: "100%" }}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (record.project_name) {
|
if (record.project_name) {
|
||||||
return (
|
return (
|
||||||
<span
|
<StatusChip
|
||||||
className="admin-badge admin-badge-wrap"
|
color="default"
|
||||||
style={{ fontSize: "0.75rem" }}
|
label={record.project_name}
|
||||||
>
|
sx={{
|
||||||
{record.project_name}
|
fontSize: "0.75rem",
|
||||||
</span>
|
height: "auto",
|
||||||
|
"& .MuiChip-label": { whiteSpace: "normal", py: 0.25 },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return "—";
|
return "—";
|
||||||
@@ -102,141 +151,164 @@ export default function AttendanceShiftTable({
|
|||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: AttendanceShiftTableProps) {
|
}: AttendanceShiftTableProps) {
|
||||||
if (records.length === 0) {
|
const columns: DataColumn<AttendanceRecord>[] = [
|
||||||
|
{
|
||||||
|
key: "date",
|
||||||
|
header: "Datum",
|
||||||
|
width: "9%",
|
||||||
|
mono: true,
|
||||||
|
render: (record) => formatDate(record.shift_date),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "user",
|
||||||
|
header: "Zaměstnanec",
|
||||||
|
width: "14%",
|
||||||
|
render: (record) => record.user_name,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "type",
|
||||||
|
header: "Typ",
|
||||||
|
width: "10%",
|
||||||
|
render: (record) => {
|
||||||
|
const leaveType = record.leave_type || "work";
|
||||||
return (
|
return (
|
||||||
<div className="admin-empty-state">
|
<StatusChip
|
||||||
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
color={leaveTypeChipColor(leaveType)}
|
||||||
</div>
|
label={getLeaveTypeName(leaveType)}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
},
|
||||||
return (
|
{
|
||||||
<div className="admin-table-responsive">
|
key: "arrival",
|
||||||
<table className="admin-table">
|
header: "Příchod",
|
||||||
<thead>
|
width: "9%",
|
||||||
<tr>
|
mono: true,
|
||||||
<th>Datum</th>
|
render: (record) => {
|
||||||
<th>Zaměstnanec</th>
|
const isLeave = (record.leave_type || "work") !== "work";
|
||||||
<th>Typ</th>
|
return isLeave ? "—" : formatDatetime(record.arrival_time);
|
||||||
<th>Příchod</th>
|
},
|
||||||
<th>Pauza</th>
|
},
|
||||||
<th>Odchod</th>
|
{
|
||||||
<th>Hodiny</th>
|
key: "break",
|
||||||
<th>Projekt</th>
|
header: "Pauza",
|
||||||
<th>GPS</th>
|
width: "10%",
|
||||||
<th>Poznámka</th>
|
mono: true,
|
||||||
<th>Akce</th>
|
render: (record) => {
|
||||||
</tr>
|
const isLeave = (record.leave_type || "work") !== "work";
|
||||||
</thead>
|
return isLeave ? "—" : formatBreak(record);
|
||||||
<tbody>
|
},
|
||||||
{records.map((record) => {
|
},
|
||||||
|
{
|
||||||
|
key: "departure",
|
||||||
|
header: "Odchod",
|
||||||
|
width: "9%",
|
||||||
|
mono: true,
|
||||||
|
render: (record) => {
|
||||||
|
const isLeave = (record.leave_type || "work") !== "work";
|
||||||
|
return isLeave ? "—" : formatDatetime(record.departure_time);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "hours",
|
||||||
|
header: "Hodiny",
|
||||||
|
width: "9%",
|
||||||
|
mono: true,
|
||||||
|
render: (record) => {
|
||||||
const leaveType = record.leave_type || "work";
|
const leaveType = record.leave_type || "work";
|
||||||
const isLeave = leaveType !== "work";
|
const isLeave = leaveType !== "work";
|
||||||
const workMinutes = isLeave
|
const workMinutes = isLeave
|
||||||
? (record.leave_hours != null ? Number(record.leave_hours) : 8) *
|
? (record.leave_hours != null ? Number(record.leave_hours) : 8) * 60
|
||||||
60
|
|
||||||
: calculateWorkMinutes({
|
: calculateWorkMinutes({
|
||||||
...record,
|
...record,
|
||||||
notes: record.notes ?? undefined,
|
notes: record.notes ?? undefined,
|
||||||
});
|
});
|
||||||
|
return workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "project",
|
||||||
|
header: "Projekt",
|
||||||
|
width: "13%",
|
||||||
|
render: (record) => renderProjectCell(record),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "gps",
|
||||||
|
header: "GPS",
|
||||||
|
width: "5%",
|
||||||
|
render: (record) => {
|
||||||
const hasLocation =
|
const hasLocation =
|
||||||
(record.arrival_lat && record.arrival_lng) ||
|
(record.arrival_lat && record.arrival_lng) ||
|
||||||
(record.departure_lat && record.departure_lng);
|
(record.departure_lat && record.departure_lng);
|
||||||
|
return hasLocation ? (
|
||||||
return (
|
<Box
|
||||||
<tr key={record.id}>
|
component={RouterLink}
|
||||||
<td className="admin-mono">{formatDate(record.shift_date)}</td>
|
|
||||||
<td>{record.user_name}</td>
|
|
||||||
<td>
|
|
||||||
<span
|
|
||||||
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
|
|
||||||
>
|
|
||||||
{getLeaveTypeName(leaveType)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{isLeave ? "—" : formatDatetime(record.arrival_time)}
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{isLeave ? "—" : formatBreak(record)}
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{isLeave ? "—" : formatDatetime(record.departure_time)}
|
|
||||||
</td>
|
|
||||||
<td className="admin-mono">
|
|
||||||
{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—"}
|
|
||||||
</td>
|
|
||||||
<td>{renderProjectCell(record)}</td>
|
|
||||||
<td>
|
|
||||||
{hasLocation ? (
|
|
||||||
<Link
|
|
||||||
to={`/attendance/location/${record.id}`}
|
to={`/attendance/location/${record.id}`}
|
||||||
className="attendance-gps-link"
|
|
||||||
title="Zobrazit polohu"
|
title="Zobrazit polohu"
|
||||||
aria-label="Zobrazit polohu"
|
aria-label="Zobrazit polohu"
|
||||||
|
sx={{ textDecoration: "none" }}
|
||||||
>
|
>
|
||||||
<span aria-hidden="true">{"📍"}</span>
|
<span aria-hidden="true">{"📍"}</span>
|
||||||
</Link>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
"—"
|
"—"
|
||||||
)}
|
);
|
||||||
</td>
|
},
|
||||||
<td
|
},
|
||||||
style={{
|
{
|
||||||
maxWidth: "100px",
|
key: "notes",
|
||||||
|
header: "Poznámka",
|
||||||
|
width: "9%",
|
||||||
|
render: (record) => (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
title={record.notes || ""}
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
title={record.notes || ""}
|
|
||||||
>
|
>
|
||||||
{record.notes || ""}
|
{record.notes || ""}
|
||||||
</td>
|
</Box>
|
||||||
<td>
|
),
|
||||||
<div className="admin-table-actions">
|
},
|
||||||
<button
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "Akce",
|
||||||
|
width: "8%",
|
||||||
|
align: "right",
|
||||||
|
render: (record) => (
|
||||||
|
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
onClick={() => onEdit(record)}
|
onClick={() => onEdit(record)}
|
||||||
className="admin-btn-icon"
|
|
||||||
title="Upravit"
|
|
||||||
aria-label="Upravit"
|
aria-label="Upravit"
|
||||||
|
title="Upravit"
|
||||||
>
|
>
|
||||||
<svg
|
{EditIcon}
|
||||||
width="18"
|
</IconButton>
|
||||||
height="18"
|
<IconButton
|
||||||
viewBox="0 0 24 24"
|
size="small"
|
||||||
fill="none"
|
color="error"
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
||||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => onDelete(record)}
|
onClick={() => onDelete(record)}
|
||||||
className="admin-btn-icon danger"
|
|
||||||
title="Smazat"
|
|
||||||
aria-label="Smazat"
|
aria-label="Smazat"
|
||||||
|
title="Smazat"
|
||||||
>
|
>
|
||||||
<svg
|
{DeleteIcon}
|
||||||
width="18"
|
</IconButton>
|
||||||
height="18"
|
</Box>
|
||||||
viewBox="0 0 24 24"
|
),
|
||||||
fill="none"
|
},
|
||||||
stroke="currentColor"
|
];
|
||||||
strokeWidth="2"
|
|
||||||
>
|
return (
|
||||||
<polyline points="3 6 5 6 21 6" />
|
<DataTable<AttendanceRecord>
|
||||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
columns={columns}
|
||||||
</svg>
|
rows={records}
|
||||||
</button>
|
rowKey={(record) => record.id}
|
||||||
</div>
|
empty={<EmptyState title="Za tento měsíc nejsou žádné záznamy." />}
|
||||||
</td>
|
/>
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import FormModal from "./FormModal";
|
import Box from "@mui/material/Box";
|
||||||
import AdminDatePicker from "./AdminDatePicker";
|
import Typography from "@mui/material/Typography";
|
||||||
|
import { Modal, MonthField, TimeField, Field, CheckboxField } from "../ui";
|
||||||
|
|
||||||
interface BulkAttendanceForm {
|
interface BulkAttendanceForm {
|
||||||
month: string;
|
month: string;
|
||||||
@@ -39,127 +40,136 @@ export default function BulkAttendanceModal({
|
|||||||
toggleAllUsers,
|
toggleAllUsers,
|
||||||
}: BulkAttendanceModalProps) {
|
}: BulkAttendanceModalProps) {
|
||||||
return (
|
return (
|
||||||
<FormModal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="Vyplnit docházku za měsíc"
|
title="Vyplnit docházku za měsíc"
|
||||||
size="lg"
|
maxWidth="lg"
|
||||||
loading={submitting}
|
loading={submitting}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
submitLabel="Vyplnit měsíc"
|
submitText="Vyplnit měsíc"
|
||||||
submitDisabled={form.user_ids.length === 0}
|
submitDisabled={form.user_ids.length === 0}
|
||||||
>
|
>
|
||||||
<p
|
<Typography
|
||||||
style={{
|
variant="body2"
|
||||||
color: "var(--text-secondary)",
|
color="text.secondary"
|
||||||
marginTop: "0.25rem",
|
sx={{ mt: 0.25, mb: 2 }}
|
||||||
marginBottom: "1rem",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí.
|
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí.
|
||||||
Existující záznamy se přeskočí.
|
Existující záznamy se přeskočí.
|
||||||
</p>
|
</Typography>
|
||||||
|
|
||||||
<div className="admin-form">
|
<Field label="Měsíc">
|
||||||
<div className="admin-form-group">
|
<MonthField
|
||||||
<label className="admin-form-label">Měsíc</label>
|
|
||||||
<AdminDatePicker
|
|
||||||
mode="month"
|
|
||||||
value={form.month}
|
value={form.month}
|
||||||
onChange={(val) => setForm({ ...form, month: val })}
|
onChange={(val) => setForm({ ...form, month: val })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
<div className="admin-form-group">
|
<Box sx={{ mb: 2 }}>
|
||||||
<label className="admin-form-label">
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1.5,
|
||||||
|
mb: 0.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography
|
||||||
|
component="span"
|
||||||
|
variant="body2"
|
||||||
|
sx={{ fontWeight: 600, color: "text.secondary" }}
|
||||||
|
>
|
||||||
Zaměstnanci
|
Zaměstnanci
|
||||||
<button
|
</Typography>
|
||||||
|
<Typography
|
||||||
|
component="button"
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleAllUsers}
|
onClick={toggleAllUsers}
|
||||||
style={{
|
variant="caption"
|
||||||
marginLeft: "0.75rem",
|
sx={{
|
||||||
background: "none",
|
background: "none",
|
||||||
border: "none",
|
border: "none",
|
||||||
color: "var(--accent-color)",
|
p: 0,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "0.8125rem",
|
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
padding: 0,
|
color: "primary.main",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{form.user_ids.length === users.length
|
{form.user_ids.length === users.length
|
||||||
? "Odznačit vše"
|
? "Odznačit vše"
|
||||||
: "Vybrat vše"}
|
: "Vybrat vše"}
|
||||||
</button>
|
</Typography>
|
||||||
</label>
|
</Box>
|
||||||
<div
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "0.375rem",
|
gap: 0.25,
|
||||||
maxHeight: "200px",
|
maxHeight: 200,
|
||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
padding: "0.75rem",
|
p: 1.5,
|
||||||
background: "var(--bg-tertiary)",
|
bgcolor: "action.hover",
|
||||||
borderRadius: "var(--border-radius-sm)",
|
borderRadius: 2,
|
||||||
border: "1px solid var(--border-color)",
|
border: 1,
|
||||||
|
borderColor: "divider",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<label key={user.id} className="admin-form-checkbox">
|
<CheckboxField
|
||||||
<input
|
key={user.id}
|
||||||
type="checkbox"
|
label={user.name}
|
||||||
checked={form.user_ids.includes(String(user.id))}
|
checked={form.user_ids.includes(String(user.id))}
|
||||||
onChange={() => toggleUser(user.id)}
|
onChange={() => toggleUser(user.id)}
|
||||||
/>
|
/>
|
||||||
<span>{user.name}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</Box>
|
||||||
<small className="admin-form-hint">
|
<Typography variant="caption" color="text.secondary">
|
||||||
Vybráno: {form.user_ids.length} z {users.length}
|
Vybráno: {form.user_ids.length} z {users.length}
|
||||||
</small>
|
</Typography>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
<Box
|
||||||
<div className="admin-form-group">
|
sx={{
|
||||||
<label className="admin-form-label">Příchod</label>
|
display: "grid",
|
||||||
<AdminDatePicker
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
mode="time"
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Příchod">
|
||||||
|
<TimeField
|
||||||
value={form.arrival_time}
|
value={form.arrival_time}
|
||||||
onChange={(val) => setForm({ ...form, arrival_time: val })}
|
onChange={(val) => setForm({ ...form, arrival_time: val })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="admin-form-group">
|
<Field label="Odchod">
|
||||||
<label className="admin-form-label">Odchod</label>
|
<TimeField
|
||||||
<AdminDatePicker
|
|
||||||
mode="time"
|
|
||||||
value={form.departure_time}
|
value={form.departure_time}
|
||||||
onChange={(val) => setForm({ ...form, departure_time: val })}
|
onChange={(val) => setForm({ ...form, departure_time: val })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
<Box
|
||||||
<div className="admin-form-group">
|
sx={{
|
||||||
<label className="admin-form-label">Začátek pauzy</label>
|
display: "grid",
|
||||||
<AdminDatePicker
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
mode="time"
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Začátek pauzy">
|
||||||
|
<TimeField
|
||||||
value={form.break_start_time}
|
value={form.break_start_time}
|
||||||
onChange={(val) => setForm({ ...form, break_start_time: val })}
|
onChange={(val) => setForm({ ...form, break_start_time: val })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="admin-form-group">
|
<Field label="Konec pauzy">
|
||||||
<label className="admin-form-label">Konec pauzy</label>
|
<TimeField
|
||||||
<AdminDatePicker
|
|
||||||
mode="time"
|
|
||||||
value={form.break_end_time}
|
value={form.break_end_time}
|
||||||
onChange={(val) => setForm({ ...form, break_end_time: val })}
|
onChange={(val) => setForm({ ...form, break_end_time: val })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Modal>
|
||||||
</FormModal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
import AdminDatePicker from "./AdminDatePicker";
|
import Box from "@mui/material/Box";
|
||||||
import FormModal from "./FormModal";
|
import Typography from "@mui/material/Typography";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Button,
|
||||||
|
Select,
|
||||||
|
TextField,
|
||||||
|
DateField,
|
||||||
|
TimeField,
|
||||||
|
Field,
|
||||||
|
Alert,
|
||||||
|
} from "../ui";
|
||||||
import {
|
import {
|
||||||
calcFormWorkMinutes,
|
calcFormWorkMinutes,
|
||||||
calcProjectMinutesTotal,
|
calcProjectMinutesTotal,
|
||||||
@@ -80,6 +91,19 @@ export interface ShiftFormModalProps {
|
|||||||
editingRecord: EditingRecord | null;
|
editingRecord: EditingRecord | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RemoveIcon = (
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
// ---------- ProjectTimeStatus ----------
|
// ---------- ProjectTimeStatus ----------
|
||||||
|
|
||||||
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
|
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
|
||||||
@@ -100,30 +124,14 @@ function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
|
|||||||
|
|
||||||
const isMatch = remaining === 0;
|
const isMatch = remaining === 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<Box sx={{ mb: 1 }}>
|
||||||
style={{
|
<Alert severity={isMatch ? "success" : "error"}>
|
||||||
padding: "0.5rem 0.75rem",
|
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m |
|
||||||
marginBottom: "0.5rem",
|
Přiřazeno: {Math.floor(totalProject / 60)}h {totalProject % 60}m |
|
||||||
borderRadius: "6px",
|
Zbývá: {Math.floor(Math.abs(remaining) / 60)}h{" "}
|
||||||
fontSize: "0.8rem",
|
{Math.abs(remaining) % 60}m {remaining < 0 ? "(překročeno)" : ""}
|
||||||
background: isMatch
|
</Alert>
|
||||||
? "var(--success-bg, rgba(34,197,94,0.1))"
|
</Box>
|
||||||
: "var(--danger-bg, rgba(239,68,68,0.1))",
|
|
||||||
color: isMatch
|
|
||||||
? "var(--success-color, #16a34a)"
|
|
||||||
: "var(--danger-color, #dc2626)",
|
|
||||||
border: `1px solid ${
|
|
||||||
isMatch
|
|
||||||
? "var(--success-border, rgba(34,197,94,0.3))"
|
|
||||||
: "var(--danger-border, rgba(239,68,68,0.3))"
|
|
||||||
}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Odpracováno: {Math.floor(totalWork / 60)}h {totalWork % 60}m | Přiřazeno:{" "}
|
|
||||||
{Math.floor(totalProject / 60)}h {totalProject % 60}m | Zbývá:{" "}
|
|
||||||
{Math.floor(Math.abs(remaining) / 60)}h {Math.abs(remaining) % 60}m{" "}
|
|
||||||
{remaining < 0 ? "(překročeno)" : ""}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,65 +145,65 @@ function ProjectLogRow({
|
|||||||
onRemove,
|
onRemove,
|
||||||
}: ProjectLogRowProps) {
|
}: ProjectLogRowProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-row gap-2 mb-2">
|
<Box
|
||||||
<select
|
sx={{
|
||||||
value={log.project_id}
|
display: "flex",
|
||||||
onChange={(e) => onUpdate(index, "project_id", e.target.value)}
|
alignItems: "center",
|
||||||
className="admin-form-select"
|
gap: 1,
|
||||||
style={{ flex: 3, marginBottom: 0 }}
|
mb: 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<option value="">— Projekt —</option>
|
<Box sx={{ flex: 3, minWidth: 0 }}>
|
||||||
{projectList.map((p) => (
|
<Select
|
||||||
<option key={p.id} value={p.id}>
|
value={String(log.project_id)}
|
||||||
{p.project_number} – {p.name}
|
onChange={(val) => onUpdate(index, "project_id", val)}
|
||||||
</option>
|
options={[
|
||||||
))}
|
{ value: "", label: "— Projekt —" },
|
||||||
</select>
|
...projectList.map((p) => ({
|
||||||
<input
|
value: String(p.id),
|
||||||
|
label: `${p.project_number} – ${p.name}`,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ width: 70, flexShrink: 0 }}>
|
||||||
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
|
||||||
max="24"
|
|
||||||
value={log.hours}
|
value={log.hours}
|
||||||
onChange={(e) => onUpdate(index, "hours", e.target.value)}
|
onChange={(e) => onUpdate(index, "hours", e.target.value)}
|
||||||
className="admin-form-input"
|
|
||||||
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
|
|
||||||
placeholder="h"
|
placeholder="h"
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: { min: 0, max: 24, style: { textAlign: "center" } },
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
h
|
h
|
||||||
</span>
|
</Typography>
|
||||||
<input
|
<Box sx={{ width: 70, flexShrink: 0 }}>
|
||||||
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
|
||||||
max="59"
|
|
||||||
value={log.minutes}
|
value={log.minutes}
|
||||||
onChange={(e) => onUpdate(index, "minutes", e.target.value)}
|
onChange={(e) => onUpdate(index, "minutes", e.target.value)}
|
||||||
className="admin-form-input"
|
|
||||||
style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
|
|
||||||
placeholder="m"
|
placeholder="m"
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: { min: 0, max: 59, style: { textAlign: "center" } },
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<span style={{ fontSize: "0.85rem", color: "var(--text-secondary)" }}>
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
m
|
m
|
||||||
</span>
|
</Typography>
|
||||||
<button
|
<IconButton
|
||||||
type="button"
|
size="small"
|
||||||
onClick={() => onRemove(index)}
|
onClick={() => onRemove(index)}
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
aria-label="Odebrat"
|
||||||
style={{ padding: "0.375rem", flexShrink: 0 }}
|
|
||||||
title="Odebrat"
|
title="Odebrat"
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
<svg
|
{RemoveIcon}
|
||||||
width="16"
|
</IconButton>
|
||||||
height="16"
|
</Box>
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<path d="M18 6L6 18M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +253,7 @@ export default function ShiftFormModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormModal
|
<Modal
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
|
title={isCreate ? "Přidat záznam docházky" : "Upravit docházku"}
|
||||||
@@ -254,169 +262,172 @@ export default function ShiftFormModal({
|
|||||||
? `${editingRecord.user_name} — ${formatDate(editingRecord.shift_date)}`
|
? `${editingRecord.user_name} — ${formatDate(editingRecord.shift_date)}`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
size="lg"
|
maxWidth="lg"
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
submitLabel="Uložit"
|
submitText="Uložit"
|
||||||
cancelLabel="Zrušit"
|
cancelText="Zrušit"
|
||||||
>
|
>
|
||||||
<div className="admin-form">
|
|
||||||
{isCreate ? (
|
{isCreate ? (
|
||||||
<div className="admin-form-row">
|
<Box
|
||||||
<div className="admin-form-group">
|
sx={{
|
||||||
<label className="admin-form-label required">Zaměstnanec</label>
|
display: "grid",
|
||||||
<select
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
value={form.user_id}
|
gap: 2,
|
||||||
onChange={(e) => updateField("user_id", e.target.value)}
|
}}
|
||||||
className="admin-form-select"
|
|
||||||
>
|
>
|
||||||
<option value="">Vyberte zaměstnance</option>
|
<Field label="Zaměstnanec" required>
|
||||||
{users.map((user) => (
|
<Select
|
||||||
<option key={user.id} value={user.id}>
|
value={form.user_id}
|
||||||
{user.name}
|
onChange={(val) => updateField("user_id", val)}
|
||||||
</option>
|
options={[
|
||||||
))}
|
{ value: "", label: "Vyberte zaměstnance" },
|
||||||
</select>
|
...users.map((user) => ({
|
||||||
</div>
|
value: String(user.id),
|
||||||
<div className="admin-form-group">
|
label: user.name,
|
||||||
<label className="admin-form-label required">Datum směny</label>
|
})),
|
||||||
<AdminDatePicker
|
]}
|
||||||
mode="date"
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Datum směny" required>
|
||||||
|
<DateField
|
||||||
value={form.shift_date}
|
value={form.shift_date}
|
||||||
onChange={(val) => onShiftDateChange(val)}
|
onChange={(val) => onShiftDateChange(val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-form-group">
|
<Field label="Datum směny">
|
||||||
<label className="admin-form-label">Datum směny</label>
|
<DateField
|
||||||
<AdminDatePicker
|
|
||||||
mode="date"
|
|
||||||
value={form.shift_date}
|
value={form.shift_date}
|
||||||
onChange={(val) => updateField("shift_date", val)}
|
onChange={(val) => updateField("shift_date", val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="admin-form-group">
|
<Field label="Typ záznamu">
|
||||||
<label className="admin-form-label">Typ záznamu</label>
|
<Select
|
||||||
<select
|
|
||||||
value={form.leave_type}
|
value={form.leave_type}
|
||||||
onChange={(e) => updateField("leave_type", e.target.value)}
|
onChange={(val) => updateField("leave_type", val)}
|
||||||
className="admin-form-select"
|
options={[
|
||||||
>
|
{ value: "work", label: "Práce" },
|
||||||
<option value="work">Práce</option>
|
{ value: "vacation", label: "Dovolená" },
|
||||||
<option value="vacation">Dovolená</option>
|
{ value: "sick", label: "Nemoc" },
|
||||||
<option value="sick">Nemoc</option>
|
{ value: "unpaid", label: "Neplacené volno" },
|
||||||
<option value="unpaid">Neplacené volno</option>
|
]}
|
||||||
</select>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
|
|
||||||
{!isWorkType && (
|
{!isWorkType && (
|
||||||
<div className="admin-form-group">
|
<Field
|
||||||
<label className="admin-form-label">Počet hodin</label>
|
label="Počet hodin"
|
||||||
<input
|
hint={isCreate ? "8 hodin = celý den" : undefined}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
value={form.leave_hours}
|
value={form.leave_hours}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateField("leave_hours", parseFloat(e.target.value))
|
updateField("leave_hours", parseFloat(e.target.value))
|
||||||
}
|
}
|
||||||
min="0.5"
|
slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
|
||||||
max="24"
|
|
||||||
step="0.5"
|
|
||||||
className="admin-form-input"
|
|
||||||
/>
|
/>
|
||||||
{isCreate && (
|
</Field>
|
||||||
<small className="admin-form-hint">8 hodin = celý den</small>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isWorkType && (
|
{isWorkType && (
|
||||||
<>
|
<>
|
||||||
<div className="admin-form-row">
|
{/* Arrival */}
|
||||||
<div className="admin-form-group">
|
<Box
|
||||||
<label className="admin-form-label">Příchod - datum</label>
|
sx={{
|
||||||
<AdminDatePicker
|
display: "grid",
|
||||||
mode="date"
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Příchod - datum">
|
||||||
|
<DateField
|
||||||
value={form.arrival_date}
|
value={form.arrival_date}
|
||||||
onChange={(val) => updateField("arrival_date", val)}
|
onChange={(val) => updateField("arrival_date", val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="admin-form-group">
|
<Field label="Příchod - čas">
|
||||||
<label className="admin-form-label">Příchod - čas</label>
|
<TimeField
|
||||||
<AdminDatePicker
|
|
||||||
mode="time"
|
|
||||||
value={form.arrival_time}
|
value={form.arrival_time}
|
||||||
onChange={(val) => updateField("arrival_time", val)}
|
onChange={(val) => updateField("arrival_time", val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
{/* Break start */}
|
||||||
<div className="admin-form-group">
|
<Box
|
||||||
<label className="admin-form-label">
|
sx={{
|
||||||
Začátek pauzy - datum
|
display: "grid",
|
||||||
</label>
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
<AdminDatePicker
|
gap: 2,
|
||||||
mode="date"
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Začátek pauzy - datum">
|
||||||
|
<DateField
|
||||||
value={form.break_start_date}
|
value={form.break_start_date}
|
||||||
onChange={(val) => updateField("break_start_date", val)}
|
onChange={(val) => updateField("break_start_date", val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="admin-form-group">
|
<Field label="Začátek pauzy - čas">
|
||||||
<label className="admin-form-label">Začátek pauzy - čas</label>
|
<TimeField
|
||||||
<AdminDatePicker
|
|
||||||
mode="time"
|
|
||||||
value={form.break_start_time}
|
value={form.break_start_time}
|
||||||
onChange={(val) => updateField("break_start_time", val)}
|
onChange={(val) => updateField("break_start_time", val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
{/* Break end */}
|
||||||
<div className="admin-form-group">
|
<Box
|
||||||
<label className="admin-form-label">Konec pauzy - datum</label>
|
sx={{
|
||||||
<AdminDatePicker
|
display: "grid",
|
||||||
mode="date"
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Konec pauzy - datum">
|
||||||
|
<DateField
|
||||||
value={form.break_end_date}
|
value={form.break_end_date}
|
||||||
onChange={(val) => updateField("break_end_date", val)}
|
onChange={(val) => updateField("break_end_date", val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="admin-form-group">
|
<Field label="Konec pauzy - čas">
|
||||||
<label className="admin-form-label">Konec pauzy - čas</label>
|
<TimeField
|
||||||
<AdminDatePicker
|
|
||||||
mode="time"
|
|
||||||
value={form.break_end_time}
|
value={form.break_end_time}
|
||||||
onChange={(val) => updateField("break_end_time", val)}
|
onChange={(val) => updateField("break_end_time", val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
<div className="admin-form-row">
|
{/* Departure */}
|
||||||
<div className="admin-form-group">
|
<Box
|
||||||
<label className="admin-form-label">Odchod - datum</label>
|
sx={{
|
||||||
<AdminDatePicker
|
display: "grid",
|
||||||
mode="date"
|
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Field label="Odchod - datum">
|
||||||
|
<DateField
|
||||||
value={form.departure_date}
|
value={form.departure_date}
|
||||||
onChange={(val) => updateField("departure_date", val)}
|
onChange={(val) => updateField("departure_date", val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
<div className="admin-form-group">
|
<Field label="Odchod - čas">
|
||||||
<label className="admin-form-label">Odchod - čas</label>
|
<TimeField
|
||||||
<AdminDatePicker
|
|
||||||
mode="time"
|
|
||||||
value={form.departure_time}
|
value={form.departure_time}
|
||||||
onChange={(val) => updateField("departure_time", val)}
|
onChange={(val) => updateField("departure_time", val)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isWorkType && projectList.length > 0 && (
|
{isWorkType && projectList.length > 0 && (
|
||||||
<div className="admin-form-group">
|
<Field label="Projekty">
|
||||||
<label className="admin-form-label">Projekty</label>
|
|
||||||
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
|
<ProjectTimeStatus form={form} projectLogs={projectLogs} />
|
||||||
{projectLogs.map((log, i) => (
|
{projectLogs.map((log, i) => (
|
||||||
<ProjectLogRow
|
<ProjectLogRow
|
||||||
@@ -428,26 +439,25 @@ export default function ShiftFormModal({
|
|||||||
onRemove={removeProjectLog}
|
onRemove={removeProjectLog}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
size="small"
|
||||||
onClick={addProjectLog}
|
onClick={addProjectLog}
|
||||||
className="admin-btn admin-btn-secondary admin-btn-sm"
|
|
||||||
>
|
>
|
||||||
+ Přidat projekt
|
+ Přidat projekt
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="admin-form-group">
|
<Field label="Poznámka">
|
||||||
<label className="admin-form-label">Poznámka</label>
|
<TextField
|
||||||
<textarea
|
multiline
|
||||||
|
minRows={3}
|
||||||
value={form.notes}
|
value={form.notes}
|
||||||
onChange={(e) => updateField("notes", e.target.value)}
|
onChange={(e) => updateField("notes", e.target.value)}
|
||||||
className="admin-form-textarea"
|
|
||||||
rows={3}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</Field>
|
||||||
</div>
|
</Modal>
|
||||||
</FormModal>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,30 @@
|
|||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
import { useAlert } from "../context/AlertContext";
|
import { useAlert } from "../context/AlertContext";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import Forbidden from "../components/Forbidden";
|
import Forbidden from "../components/Forbidden";
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import ConfirmModal from "../components/ConfirmModal";
|
|
||||||
import AdminDatePicker from "../components/AdminDatePicker";
|
|
||||||
import BulkAttendanceModal from "../components/BulkAttendanceModal";
|
import BulkAttendanceModal from "../components/BulkAttendanceModal";
|
||||||
import ShiftFormModal from "../components/ShiftFormModal";
|
import ShiftFormModal from "../components/ShiftFormModal";
|
||||||
import AttendanceShiftTable from "../components/AttendanceShiftTable";
|
import AttendanceShiftTable from "../components/AttendanceShiftTable";
|
||||||
import useModalLock from "../hooks/useModalLock";
|
import useModalLock from "../hooks/useModalLock";
|
||||||
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
|
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
|
||||||
import FormField from "../components/FormField";
|
|
||||||
import { formatMinutes, formatHoursDecimal } from "../utils/attendanceHelpers";
|
import { formatMinutes, formatHoursDecimal } from "../utils/attendanceHelpers";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
ConfirmDialog,
|
||||||
|
Field,
|
||||||
|
FilterBar,
|
||||||
|
LoadingState,
|
||||||
|
MonthField,
|
||||||
|
Select,
|
||||||
|
StatCard,
|
||||||
|
StatusChip,
|
||||||
|
ProgressBar,
|
||||||
|
type ProgressColor,
|
||||||
|
type StatCardColor,
|
||||||
|
} from "../ui";
|
||||||
|
|
||||||
interface UserTotalData {
|
interface UserTotalData {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -35,23 +49,57 @@ interface UserTotalData {
|
|||||||
worked_holiday_hours?: number;
|
worked_holiday_hours?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFundBarBackground(data: UserTotalData) {
|
/** Fond "used" total mirrors the Fond footer line below:
|
||||||
// fondUsed mirrors the Fond "used" line in the IIFE below:
|
* real_worked + vacation + worked_holiday + free_holiday. */
|
||||||
// real_worked + vacation + worked_holiday + free_holiday
|
function getFondUsed(data: UserTotalData): number {
|
||||||
// (delta is fondUsed - fund; 0 means exactly at the fund).
|
|
||||||
const realWorked = data.worked_hours_raw ?? data.worked_hours;
|
const realWorked = data.worked_hours_raw ?? data.worked_hours;
|
||||||
const fondUsed =
|
return (
|
||||||
realWorked +
|
realWorked +
|
||||||
(data.vacation_hours ?? 0) +
|
(data.vacation_hours ?? 0) +
|
||||||
(data.worked_holiday_hours ?? 0) +
|
(data.worked_holiday_hours ?? 0) +
|
||||||
(data.svatek ?? 0);
|
(data.svatek ?? 0)
|
||||||
const fundVal = data.fund ?? 0;
|
);
|
||||||
const delta = fondUsed - fundVal;
|
|
||||||
if (delta > 0) return "linear-gradient(135deg, var(--warning), #d97706)";
|
|
||||||
if (delta >= 0) return "linear-gradient(135deg, var(--success), #059669)";
|
|
||||||
return "var(--gradient)";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Maps the Fond delta (used − fund) to a ProgressBar color token, mirroring
|
||||||
|
* the legacy getFundBarBackground conditional: over fund → warning, exactly
|
||||||
|
* at fund → success, under fund → primary (was the accent gradient). */
|
||||||
|
function getFundBarColor(data: UserTotalData): ProgressColor {
|
||||||
|
const delta = getFondUsed(data) - (data.fund ?? 0);
|
||||||
|
if (delta > 0) return "warning";
|
||||||
|
if (delta >= 0) return "success";
|
||||||
|
return "primary";
|
||||||
|
}
|
||||||
|
|
||||||
|
const PrintIcon = (
|
||||||
|
<svg
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<polyline points="6 9 6 2 18 2 18 9" />
|
||||||
|
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
||||||
|
<rect x="6" y="14" width="12" height="8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const AddIcon = (
|
||||||
|
<svg
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
>
|
||||||
|
<line x1="12" y1="5" x2="12" y2="19" />
|
||||||
|
<line x1="5" y1="12" x2="19" y2="12" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export default function AttendanceAdmin() {
|
export default function AttendanceAdmin() {
|
||||||
const alert = useAlert();
|
const alert = useAlert();
|
||||||
const { hasPermission } = useAuth();
|
const { hasPermission } = useAuth();
|
||||||
@@ -111,278 +159,267 @@ export default function AttendanceAdmin() {
|
|||||||
Object.keys(data.user_totals).length === 0;
|
Object.keys(data.user_totals).length === 0;
|
||||||
|
|
||||||
if (isInitialLoad) {
|
if (isInitialLoad) {
|
||||||
return (
|
return <LoadingState />;
|
||||||
<div className="admin-loading">
|
|
||||||
<div className="admin-spinner" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasTotals = Object.keys(data.user_totals).length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Box>
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-page-header"
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25 }}
|
transition={{ duration: 0.25 }}
|
||||||
>
|
>
|
||||||
<div>
|
<Box
|
||||||
<h1 className="admin-page-title">Správa docházky</h1>
|
sx={{
|
||||||
</div>
|
display: "flex",
|
||||||
<div className="admin-page-actions">
|
alignItems: "flex-start",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
flexWrap: "wrap",
|
||||||
|
gap: 2,
|
||||||
|
mb: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography variant="h4">Správa docházky</Typography>
|
||||||
|
<Box sx={{ display: "flex", gap: 1.5, flexWrap: "wrap" }}>
|
||||||
{hasData && (
|
{hasData && (
|
||||||
<button
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="inherit"
|
||||||
|
startIcon={PrintIcon}
|
||||||
onClick={handlePrint}
|
onClick={handlePrint}
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
title="Tisk docházky"
|
title="Tisk docházky"
|
||||||
>
|
>
|
||||||
<svg
|
|
||||||
width="18"
|
|
||||||
height="18"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
style={{ marginRight: "0.5rem" }}
|
|
||||||
>
|
|
||||||
<polyline points="6 9 6 2 18 2 18 9" />
|
|
||||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
|
||||||
<rect x="6" y="14" width="12" height="8" />
|
|
||||||
</svg>
|
|
||||||
Tisk
|
Tisk
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<button
|
<Button variant="outlined" color="inherit" onClick={openBulkModal}>
|
||||||
onClick={openBulkModal}
|
|
||||||
className="admin-btn admin-btn-secondary"
|
|
||||||
>
|
|
||||||
Vyplnit měsíc
|
Vyplnit měsíc
|
||||||
</button>
|
</Button>
|
||||||
<button
|
<Button startIcon={AddIcon} onClick={openCreateModal}>
|
||||||
onClick={openCreateModal}
|
|
||||||
className="admin-btn admin-btn-primary"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
width="20"
|
|
||||||
height="20"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
>
|
|
||||||
<line x1="12" y1="5" x2="12" y2="19" />
|
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
|
||||||
</svg>
|
|
||||||
Přidat záznam
|
Přidat záznam
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Box>
|
||||||
|
</Box>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card mb-6"
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.06 }}
|
transition={{ duration: 0.25, delay: 0.06 }}
|
||||||
>
|
>
|
||||||
<div className="admin-card-body">
|
<FilterBar>
|
||||||
<div className="admin-form-row">
|
<Box sx={{ flex: "0 0 200px" }}>
|
||||||
<FormField label="Měsíc">
|
<Field label="Měsíc">
|
||||||
<AdminDatePicker
|
<MonthField value={month} onChange={(val) => setMonth(val)} />
|
||||||
mode="month"
|
</Field>
|
||||||
value={month}
|
</Box>
|
||||||
onChange={(val: string) => setMonth(val)}
|
<Box sx={{ flex: "1 1 240px" }}>
|
||||||
/>
|
<Field label="Zaměstnanec">
|
||||||
</FormField>
|
<Select
|
||||||
<FormField label="Zaměstnanec">
|
|
||||||
<select
|
|
||||||
value={filterUserId}
|
value={filterUserId}
|
||||||
onChange={(e) => setFilterUserId(e.target.value)}
|
onChange={setFilterUserId}
|
||||||
className="admin-form-select"
|
options={[
|
||||||
>
|
{ value: "", label: "Všichni" },
|
||||||
<option value="">Všichni</option>
|
...data.users.map((user) => ({
|
||||||
{data.users.map((user) => (
|
value: String(user.id),
|
||||||
<option key={user.id} value={user.id}>
|
label: user.name,
|
||||||
{user.name}
|
})),
|
||||||
</option>
|
]}
|
||||||
))}
|
/>
|
||||||
</select>
|
</Field>
|
||||||
</FormField>
|
</Box>
|
||||||
</div>
|
</FilterBar>
|
||||||
</div>
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* User Totals */}
|
{/* User Totals (KPI cards) */}
|
||||||
{Object.keys(data.user_totals).length > 0 && (
|
{hasTotals && (
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-grid admin-grid-3 mb-6"
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.09 }}
|
transition={{ duration: 0.25, delay: 0.09 }}
|
||||||
>
|
>
|
||||||
{Object.entries(data.user_totals).map(([uid, userData]) => {
|
<Box
|
||||||
const ut = userData as UserTotalData;
|
sx={{
|
||||||
return (
|
display: "grid",
|
||||||
<div key={uid} className="admin-card" style={{ display: "flex" }}>
|
gridTemplateColumns: {
|
||||||
<div
|
xs: "1fr",
|
||||||
className="admin-card-body"
|
sm: "repeat(2, 1fr)",
|
||||||
style={{
|
lg: "repeat(3, 1fr)",
|
||||||
display: "flex",
|
},
|
||||||
flexDirection: "column",
|
gap: 2,
|
||||||
flex: 1,
|
mb: 3,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex-row gap-2 mb-2">
|
{Object.entries(data.user_totals).map(([uid, userData]) => {
|
||||||
<span style={{ fontWeight: 600 }}>{ut.name}</span>
|
const ut = userData as UserTotalData;
|
||||||
<span
|
const balance = data.leave_balances[uid];
|
||||||
className={`attendance-working-badge ${ut.working ? "working" : "finished"}`}
|
const statColor: StatCardColor = ut.working
|
||||||
|
? "success"
|
||||||
|
: "default";
|
||||||
|
return (
|
||||||
|
<StatCard
|
||||||
|
key={uid}
|
||||||
|
label={ut.name}
|
||||||
|
value={
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 1,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{ut.working ? "\u2713" : "\u2717"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="admin-stat-value">
|
|
||||||
{formatMinutes(ut.minutes)}
|
{formatMinutes(ut.minutes)}
|
||||||
</div>
|
<StatusChip
|
||||||
<div className="admin-stat-label">odpracováno</div>
|
color={ut.working ? "success" : "default"}
|
||||||
<div
|
label={ut.working ? "✓" : "✗"}
|
||||||
style={{
|
/>
|
||||||
marginTop: "0.5rem",
|
</Box>
|
||||||
marginBottom: "0.75rem",
|
}
|
||||||
|
color={statColor}
|
||||||
|
footer={
|
||||||
|
<Box>
|
||||||
|
{/* Leave-type badges */}
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexWrap: "wrap",
|
flexWrap: "wrap",
|
||||||
gap: "0.4rem",
|
gap: 0.5,
|
||||||
minHeight: "2rem",
|
mb: 1,
|
||||||
|
minHeight: "1.75rem",
|
||||||
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ut.vacation_hours > 0 && (
|
{ut.vacation_hours > 0 && (
|
||||||
<span className="attendance-leave-badge badge-vacation">
|
<StatusChip
|
||||||
Dov: {ut.vacation_hours}h
|
color="info"
|
||||||
</span>
|
label={`Dov: ${ut.vacation_hours}h`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{ut.sick_hours > 0 && (
|
{ut.sick_hours > 0 && (
|
||||||
<span className="attendance-leave-badge badge-sick">
|
<StatusChip
|
||||||
Nem: {ut.sick_hours}h
|
color="error"
|
||||||
</span>
|
label={`Nem: ${ut.sick_hours}h`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{ut.svatek !== undefined && ut.svatek > 0 && (
|
{ut.svatek !== undefined && ut.svatek > 0 && (
|
||||||
<span className="attendance-leave-badge badge-holiday">
|
<StatusChip
|
||||||
Sv: {Math.round(ut.svatek)}h
|
color="warning"
|
||||||
</span>
|
label={`Sv: ${Math.round(ut.svatek)}h`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{ut.unpaid_hours > 0 && (
|
{ut.unpaid_hours > 0 && (
|
||||||
<span className="attendance-leave-badge badge-unpaid">
|
<StatusChip
|
||||||
Nep: {ut.unpaid_hours}h
|
color="default"
|
||||||
</span>
|
label={`Nep: ${ut.unpaid_hours}h`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
|
{/* Fond usage */}
|
||||||
{ut.fund !== null &&
|
{ut.fund !== null &&
|
||||||
(() => {
|
(() => {
|
||||||
// Fond "used" = real_worked + vacation + worked_holiday + free_holiday
|
// Fond "used" = real_worked + vacation +
|
||||||
// (everything the user actually got paid for this month:
|
// worked_holiday + free_holiday (everything the user
|
||||||
// raw worked time with sub-day remainders, plus
|
// actually got paid for this month).
|
||||||
// vacation days, plus hours worked on holidays, plus
|
const fondUsed = getFondUsed(ut);
|
||||||
// the 8h per unworked holiday from the fund).
|
|
||||||
const realWorked = ut.worked_hours_raw ?? ut.worked_hours;
|
|
||||||
const fondUsed =
|
|
||||||
realWorked +
|
|
||||||
(ut.vacation_hours ?? 0) +
|
|
||||||
(ut.worked_holiday_hours ?? 0) +
|
|
||||||
(ut.svatek ?? 0);
|
|
||||||
const fundVal = ut.fund ?? 0;
|
const fundVal = ut.fund ?? 0;
|
||||||
const delta = fondUsed - fundVal;
|
const delta = fondUsed - fundVal;
|
||||||
|
const pct = Math.min(
|
||||||
|
100,
|
||||||
|
(fondUsed / (ut.fund || 1)) * 100,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<Box>
|
||||||
className="kpi-card-footer"
|
<Box
|
||||||
style={{
|
sx={{
|
||||||
marginTop: "auto",
|
|
||||||
paddingTop: "0.75rem",
|
|
||||||
borderTop: "1px solid var(--border-color)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="text-secondary"
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
fontSize: "0.8rem",
|
mb: 0.5,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
>
|
||||||
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
|
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
|
||||||
{formatHoursDecimal(fundVal * 60)}h
|
{formatHoursDecimal(fundVal * 60)}h
|
||||||
</span>
|
</Typography>
|
||||||
{delta > 0 && (
|
{delta > 0 && (
|
||||||
<span className="text-warning fw-600">
|
<Typography
|
||||||
+{formatHoursDecimal(delta * 60)}h
|
variant="caption"
|
||||||
</span>
|
sx={{
|
||||||
)}
|
fontWeight: 600,
|
||||||
{delta <= 0 && delta < 0 && (
|
color: "warning.main",
|
||||||
<span className="text-danger fw-600">
|
|
||||||
-{formatHoursDecimal(Math.abs(delta) * 60)}h
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginTop: "0.375rem",
|
|
||||||
height: "4px",
|
|
||||||
background: "var(--bg-tertiary)",
|
|
||||||
borderRadius: "2px",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
+{formatHoursDecimal(delta * 60)}h
|
||||||
style={{
|
</Typography>
|
||||||
height: "100%",
|
)}
|
||||||
width: `${Math.min(
|
{delta < 0 && (
|
||||||
100,
|
<Typography
|
||||||
(fondUsed / (ut.fund || 1)) * 100,
|
variant="caption"
|
||||||
)}%`,
|
sx={{
|
||||||
background: getFundBarBackground(ut),
|
fontWeight: 600,
|
||||||
borderRadius: "2px",
|
color: "error.main",
|
||||||
transition: "width 0.3s ease",
|
|
||||||
}}
|
}}
|
||||||
|
>
|
||||||
|
-{formatHoursDecimal(Math.abs(delta) * 60)}h
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<ProgressBar
|
||||||
|
value={pct}
|
||||||
|
color={getFundBarColor(ut)}
|
||||||
|
height={4}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
<div
|
|
||||||
className="text-secondary"
|
{/* Remaining vacation */}
|
||||||
style={{ marginTop: "0.75rem", fontSize: "0.8rem" }}
|
<Box sx={{ mt: 1.5 }}>
|
||||||
>
|
{balance ? (
|
||||||
{data.leave_balances[uid] ? (
|
<Typography variant="caption" color="text.secondary">
|
||||||
<>
|
|
||||||
Zbývá dovolené:{" "}
|
Zbývá dovolené:{" "}
|
||||||
{data.leave_balances[uid].vacation_remaining.toFixed(1)}
|
{balance.vacation_remaining.toFixed(1)}h /{" "}
|
||||||
h / {data.leave_balances[uid].vacation_total}h
|
{balance.vacation_total}h
|
||||||
</>
|
</Typography>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ visibility: "hidden" }}>—</span>
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
sx={{ visibility: "hidden" }}
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
</Box>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</Box>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Records Table */}
|
{/* Records Table */}
|
||||||
<motion.div
|
<motion.div
|
||||||
className="admin-card"
|
|
||||||
initial={{ opacity: 0, y: 12 }}
|
initial={{ opacity: 0, y: 12 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.25, delay: 0.12 }}
|
transition={{ duration: 0.25, delay: 0.12 }}
|
||||||
>
|
>
|
||||||
<div className="admin-card-body">
|
<Card>
|
||||||
<AttendanceShiftTable
|
<AttendanceShiftTable
|
||||||
records={data.records}
|
records={data.records}
|
||||||
onEdit={openEditModal}
|
onEdit={openEditModal}
|
||||||
onDelete={(record) => setDeleteConfirm({ show: true, record })}
|
onDelete={(record) => setDeleteConfirm({ show: true, record })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
@@ -435,7 +472,7 @@ export default function AttendanceAdmin() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmDialog
|
||||||
isOpen={deleteConfirm.show}
|
isOpen={deleteConfirm.show}
|
||||||
onClose={() => setDeleteConfirm({ show: false, record: null })}
|
onClose={() => setDeleteConfirm({ show: false, record: null })}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
@@ -444,6 +481,6 @@ export default function AttendanceAdmin() {
|
|||||||
confirmText="Smazat"
|
confirmText="Smazat"
|
||||||
confirmVariant="danger"
|
confirmVariant="danger"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user