diff --git a/src/admin/components/AttendanceShiftTable.tsx b/src/admin/components/AttendanceShiftTable.tsx
index 5100cc7..12a4dcc 100644
--- a/src/admin/components/AttendanceShiftTable.tsx
+++ b/src/admin/components/AttendanceShiftTable.tsx
@@ -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 {
formatDate,
formatDatetime,
@@ -6,8 +8,8 @@ import {
calculateWorkMinutes,
formatMinutes,
getLeaveTypeName,
- getLeaveTypeBadgeClass,
} from "../utils/attendanceHelpers";
+import { DataTable, StatusChip, EmptyState, type DataColumn } from "../ui";
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
interface AttendanceRecord extends HookAttendanceRecord {
@@ -23,6 +25,56 @@ interface AttendanceShiftTableProps {
onDelete: (record: AttendanceRecord) => void;
}
+const EditIcon = (
+
+);
+
+const DeleteIcon = (
+
+);
+
+/** 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 {
if (record.break_start && 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 {
if (record.project_logs && record.project_logs.length > 0) {
return (
-
+
{record.project_logs.map((log, i) => {
let h: number,
m: number,
@@ -65,33 +115,32 @@ function renderProjectCell(record: AttendanceRecord): React.ReactNode {
}
}
return (
-
- {log.project_name || `#${log.project_id}`}{" "}
- {durationValid
- ? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
- : "—"}
-
+ color={isActive ? "info" : "default"}
+ label={`${log.project_name || `#${log.project_id}`} ${
+ durationValid
+ ? `(${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`
+ : "—"
+ }`}
+ sx={{ fontSize: "0.7rem", maxWidth: "100%" }}
+ />
);
})}
-
+
);
}
if (record.project_name) {
return (
-
- {record.project_name}
-
+
);
}
return "—";
@@ -102,141 +151,164 @@ export default function AttendanceShiftTable({
onEdit,
onDelete,
}: AttendanceShiftTableProps) {
- if (records.length === 0) {
- return (
-
-
Za tento měsíc nejsou žádné záznamy.
-
- );
- }
+ const columns: DataColumn[] = [
+ {
+ 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 (
+
+ );
+ },
+ },
+ {
+ key: "arrival",
+ header: "Příchod",
+ width: "9%",
+ mono: true,
+ render: (record) => {
+ const isLeave = (record.leave_type || "work") !== "work";
+ return isLeave ? "—" : formatDatetime(record.arrival_time);
+ },
+ },
+ {
+ key: "break",
+ header: "Pauza",
+ width: "10%",
+ mono: true,
+ render: (record) => {
+ const isLeave = (record.leave_type || "work") !== "work";
+ return isLeave ? "—" : formatBreak(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 isLeave = leaveType !== "work";
+ const workMinutes = isLeave
+ ? (record.leave_hours != null ? Number(record.leave_hours) : 8) * 60
+ : calculateWorkMinutes({
+ ...record,
+ 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 =
+ (record.arrival_lat && record.arrival_lng) ||
+ (record.departure_lat && record.departure_lng);
+ return hasLocation ? (
+
+ {"📍"}
+
+ ) : (
+ "—"
+ );
+ },
+ },
+ {
+ key: "notes",
+ header: "Poznámka",
+ width: "9%",
+ render: (record) => (
+
+ {record.notes || ""}
+
+ ),
+ },
+ {
+ key: "actions",
+ header: "Akce",
+ width: "8%",
+ align: "right",
+ render: (record) => (
+
+ onEdit(record)}
+ aria-label="Upravit"
+ title="Upravit"
+ >
+ {EditIcon}
+
+ onDelete(record)}
+ aria-label="Smazat"
+ title="Smazat"
+ >
+ {DeleteIcon}
+
+
+ ),
+ },
+ ];
return (
-
-
-
-
- | Datum |
- Zaměstnanec |
- Typ |
- Příchod |
- Pauza |
- Odchod |
- Hodiny |
- Projekt |
- GPS |
- Poznámka |
- Akce |
-
-
-
- {records.map((record) => {
- const leaveType = record.leave_type || "work";
- const isLeave = leaveType !== "work";
- const workMinutes = isLeave
- ? (record.leave_hours != null ? Number(record.leave_hours) : 8) *
- 60
- : calculateWorkMinutes({
- ...record,
- notes: record.notes ?? undefined,
- });
- const hasLocation =
- (record.arrival_lat && record.arrival_lng) ||
- (record.departure_lat && record.departure_lng);
-
- return (
-
- | {formatDate(record.shift_date)} |
- {record.user_name} |
-
-
- {getLeaveTypeName(leaveType)}
-
- |
-
- {isLeave ? "—" : formatDatetime(record.arrival_time)}
- |
-
- {isLeave ? "—" : formatBreak(record)}
- |
-
- {isLeave ? "—" : formatDatetime(record.departure_time)}
- |
-
- {workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—"}
- |
- {renderProjectCell(record)} |
-
- {hasLocation ? (
-
- {"📍"}
-
- ) : (
- "—"
- )}
- |
-
- {record.notes || ""}
- |
-
-
-
-
-
- |
-
- );
- })}
-
-
-
+
+ columns={columns}
+ rows={records}
+ rowKey={(record) => record.id}
+ empty={}
+ />
);
}
diff --git a/src/admin/components/BulkAttendanceModal.tsx b/src/admin/components/BulkAttendanceModal.tsx
index 1cd0212..74f3f77 100644
--- a/src/admin/components/BulkAttendanceModal.tsx
+++ b/src/admin/components/BulkAttendanceModal.tsx
@@ -1,5 +1,6 @@
-import FormModal from "./FormModal";
-import AdminDatePicker from "./AdminDatePicker";
+import Box from "@mui/material/Box";
+import Typography from "@mui/material/Typography";
+import { Modal, MonthField, TimeField, Field, CheckboxField } from "../ui";
interface BulkAttendanceForm {
month: string;
@@ -39,127 +40,136 @@ export default function BulkAttendanceModal({
toggleAllUsers,
}: BulkAttendanceModalProps) {
return (
-
-
Vytvoří záznamy pro všechny pracovní dny. Svátky se automaticky označí.
Existující záznamy se přeskočí.
-
+
-
-
-
-
setForm({ ...form, month: val })}
- />
-
+
+ setForm({ ...form, month: val })}
+ />
+
-
-
-
+
- {users.map((user) => (
-
- ))}
-
-
- Vybráno: {form.user_ids.length} z {users.length}
-
-
+ {form.user_ids.length === users.length
+ ? "Odznačit vše"
+ : "Vybrat vše"}
+
+
+
+ {users.map((user) => (
+ toggleUser(user.id)}
+ />
+ ))}
+
+
+ Vybráno: {form.user_ids.length} z {users.length}
+
+
-
-
-
-
setForm({ ...form, arrival_time: val })}
- />
-
-
-
-
setForm({ ...form, departure_time: val })}
- />
-
-
+
+
+ setForm({ ...form, arrival_time: val })}
+ />
+
+
+ setForm({ ...form, departure_time: val })}
+ />
+
+
-
-
-
-
setForm({ ...form, break_start_time: val })}
- />
-
-
-
-
setForm({ ...form, break_end_time: val })}
- />
-
-
-
-
+
+
+ setForm({ ...form, break_start_time: val })}
+ />
+
+
+ setForm({ ...form, break_end_time: val })}
+ />
+
+
+
);
}
diff --git a/src/admin/components/ShiftFormModal.tsx b/src/admin/components/ShiftFormModal.tsx
index 6de76db..094cf7c 100644
--- a/src/admin/components/ShiftFormModal.tsx
+++ b/src/admin/components/ShiftFormModal.tsx
@@ -1,5 +1,16 @@
-import AdminDatePicker from "./AdminDatePicker";
-import FormModal from "./FormModal";
+import Box from "@mui/material/Box";
+import Typography from "@mui/material/Typography";
+import IconButton from "@mui/material/IconButton";
+import {
+ Modal,
+ Button,
+ Select,
+ TextField,
+ DateField,
+ TimeField,
+ Field,
+ Alert,
+} from "../ui";
import {
calcFormWorkMinutes,
calcProjectMinutesTotal,
@@ -80,6 +91,19 @@ export interface ShiftFormModalProps {
editingRecord: EditingRecord | null;
}
+const RemoveIcon = (
+
+);
+
// ---------- ProjectTimeStatus ----------
function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
@@ -100,30 +124,14 @@ function ProjectTimeStatus({ form, projectLogs }: ProjectTimeStatusProps) {
const isMatch = remaining === 0;
return (
-
- 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)" : ""}
-
+
+
+ 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)" : ""}
+
+
);
}
@@ -137,65 +145,65 @@ function ProjectLogRow({
onRemove,
}: ProjectLogRowProps) {
return (
-
-
-
onUpdate(index, "hours", e.target.value)}
- className="admin-form-input"
- style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
- placeholder="h"
- />
-
+
+
+
+
+ onUpdate(index, "hours", e.target.value)}
+ placeholder="h"
+ slotProps={{
+ htmlInput: { min: 0, max: 24, style: { textAlign: "center" } },
+ }}
+ />
+
+
h
-
-
onUpdate(index, "minutes", e.target.value)}
- className="admin-form-input"
- style={{ width: "60px", marginBottom: 0, textAlign: "center" }}
- placeholder="m"
- />
-
+
+
+ onUpdate(index, "minutes", e.target.value)}
+ placeholder="m"
+ slotProps={{
+ htmlInput: { min: 0, max: 59, style: { textAlign: "center" } },
+ }}
+ />
+
+
m
-
-
-
+ {RemoveIcon}
+
+
);
}
@@ -245,7 +253,7 @@ export default function ShiftFormModal({
};
return (
-
-
- {isCreate ? (
-
-
-
-
-
-
-
-
onShiftDateChange(val)}
- />
-
-
- ) : (
-
-
-
+
+
+
+ updateField("shift_date", val)}
+ onChange={(val) => onShiftDateChange(val)}
/>
-
- )}
-
-
-
-
-
-
- {!isWorkType && (
-
-
-
- updateField("leave_hours", parseFloat(e.target.value))
- }
- min="0.5"
- max="24"
- step="0.5"
- className="admin-form-input"
- />
- {isCreate && (
- 8 hodin = celý den
- )}
-
- )}
-
- {isWorkType && (
- <>
-
-
-
-
updateField("arrival_date", val)}
- />
-
-
-
-
updateField("arrival_time", val)}
- />
-
-
-
-
-
-
-
updateField("break_start_date", val)}
- />
-
-
-
-
updateField("break_start_time", val)}
- />
-
-
-
-
-
-
-
updateField("break_end_date", val)}
- />
-
-
-
-
updateField("break_end_time", val)}
- />
-
-
-
-
-
-
-
updateField("departure_date", val)}
- />
-
-
-
-
updateField("departure_time", val)}
- />
-
-
- >
- )}
-
- {isWorkType && projectList.length > 0 && (
-
-
-
- {projectLogs.map((log, i) => (
-
- ))}
-
-
- )}
-
-
-
-
-
-
+
+ )}
+
+
+
+
+ {!isWorkType && (
+
+
+ updateField("leave_hours", parseFloat(e.target.value))
+ }
+ slotProps={{ htmlInput: { min: 0.5, max: 24, step: 0.5 } }}
+ />
+
+ )}
+
+ {isWorkType && (
+ <>
+ {/* Arrival */}
+
+
+ updateField("arrival_date", val)}
+ />
+
+
+ updateField("arrival_time", val)}
+ />
+
+
+
+ {/* Break start */}
+
+
+ updateField("break_start_date", val)}
+ />
+
+
+ updateField("break_start_time", val)}
+ />
+
+
+
+ {/* Break end */}
+
+
+ updateField("break_end_date", val)}
+ />
+
+
+ updateField("break_end_time", val)}
+ />
+
+
+
+ {/* Departure */}
+
+
+ updateField("departure_date", val)}
+ />
+
+
+ updateField("departure_time", val)}
+ />
+
+
+ >
+ )}
+
+ {isWorkType && projectList.length > 0 && (
+
+
+ {projectLogs.map((log, i) => (
+
+ ))}
+
+
+ )}
+
+
+ updateField("notes", e.target.value)}
+ />
+
+
);
}
diff --git a/src/admin/pages/AttendanceAdmin.tsx b/src/admin/pages/AttendanceAdmin.tsx
index e2fd7d0..a14ba23 100644
--- a/src/admin/pages/AttendanceAdmin.tsx
+++ b/src/admin/pages/AttendanceAdmin.tsx
@@ -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 { useAuth } from "../context/AuthContext";
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 ShiftFormModal from "../components/ShiftFormModal";
import AttendanceShiftTable from "../components/AttendanceShiftTable";
import useModalLock from "../hooks/useModalLock";
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
-import FormField from "../components/FormField";
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 {
name: string;
@@ -35,23 +49,57 @@ interface UserTotalData {
worked_holiday_hours?: number;
}
-function getFundBarBackground(data: UserTotalData) {
- // fondUsed mirrors the Fond "used" line in the IIFE below:
- // real_worked + vacation + worked_holiday + free_holiday
- // (delta is fondUsed - fund; 0 means exactly at the fund).
+/** Fond "used" total mirrors the Fond footer line below:
+ * real_worked + vacation + worked_holiday + free_holiday. */
+function getFondUsed(data: UserTotalData): number {
const realWorked = data.worked_hours_raw ?? data.worked_hours;
- const fondUsed =
+ return (
realWorked +
(data.vacation_hours ?? 0) +
(data.worked_holiday_hours ?? 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)";
+ (data.svatek ?? 0)
+ );
}
+/** 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 = (
+
+);
+
+const AddIcon = (
+
+);
+
export default function AttendanceAdmin() {
const alert = useAlert();
const { hasPermission } = useAuth();
@@ -111,278 +159,267 @@ export default function AttendanceAdmin() {
Object.keys(data.user_totals).length === 0;
if (isInitialLoad) {
- return (
-
- );
+ return ;
}
+ const hasTotals = Object.keys(data.user_totals).length > 0;
+
return (
-
+
-
-
Správa docházky
-
-
- {hasData && (
-
- )}
-
-
-
+ Tisk
+
+ )}
+
+
+
+
{/* Filters */}
-
-
-
- setMonth(val)}
- />
-
-
-
-
-
+ onChange={setFilterUserId}
+ options={[
+ { value: "", label: "Všichni" },
+ ...data.users.map((user) => ({
+ value: String(user.id),
+ label: user.name,
+ })),
+ ]}
+ />
+
+
+
- {/* User Totals */}
- {Object.keys(data.user_totals).length > 0 && (
+ {/* User Totals (KPI cards) */}
+ {hasTotals && (
- {Object.entries(data.user_totals).map(([uid, userData]) => {
- const ut = userData as UserTotalData;
- return (
-
-
-
- {ut.name}
-
+ {Object.entries(data.user_totals).map(([uid, userData]) => {
+ const ut = userData as UserTotalData;
+ const balance = data.leave_balances[uid];
+ const statColor: StatCardColor = ut.working
+ ? "success"
+ : "default";
+ return (
+
- {ut.working ? "\u2713" : "\u2717"}
-
-
-
- {formatMinutes(ut.minutes)}
-
-
odpracováno
-
- {ut.vacation_hours > 0 && (
-
- Dov: {ut.vacation_hours}h
-
- )}
- {ut.sick_hours > 0 && (
-
- Nem: {ut.sick_hours}h
-
- )}
- {ut.svatek !== undefined && ut.svatek > 0 && (
-
- Sv: {Math.round(ut.svatek)}h
-
- )}
- {ut.unpaid_hours > 0 && (
-
- Nep: {ut.unpaid_hours}h
-
- )}
-
- {ut.fund !== null &&
- (() => {
- // Fond "used" = real_worked + vacation + worked_holiday + free_holiday
- // (everything the user actually got paid for this month:
- // raw worked time with sub-day remainders, plus
- // vacation days, plus hours worked on holidays, plus
- // 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 delta = fondUsed - fundVal;
- return (
-
-
+
+ }
+ color={statColor}
+ footer={
+
+ {/* Leave-type badges */}
+
+ {ut.vacation_hours > 0 && (
+
+ )}
+ {ut.sick_hours > 0 && (
+
+ )}
+ {ut.svatek !== undefined && ut.svatek > 0 && (
+
+ )}
+ {ut.unpaid_hours > 0 && (
+
+ )}
+
+
+ {/* Fond usage */}
+ {ut.fund !== null &&
+ (() => {
+ // Fond "used" = real_worked + vacation +
+ // worked_holiday + free_holiday (everything the user
+ // actually got paid for this month).
+ const fondUsed = getFondUsed(ut);
+ const fundVal = ut.fund ?? 0;
+ const delta = fondUsed - fundVal;
+ const pct = Math.min(
+ 100,
+ (fondUsed / (ut.fund || 1)) * 100,
+ );
+ return (
+
+
+
+ Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
+ {formatHoursDecimal(fundVal * 60)}h
+
+ {delta > 0 && (
+
+ +{formatHoursDecimal(delta * 60)}h
+
+ )}
+ {delta < 0 && (
+
+ -{formatHoursDecimal(Math.abs(delta) * 60)}h
+
+ )}
+
+
+
+ );
+ })()}
+
+ {/* Remaining vacation */}
+
+ {balance ? (
+
+ Zbývá dovolené:{" "}
+ {balance.vacation_remaining.toFixed(1)}h /{" "}
+ {balance.vacation_total}h
+
+ ) : (
+
-
- Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
- {formatHoursDecimal(fundVal * 60)}h
-
- {delta > 0 && (
-
- +{formatHoursDecimal(delta * 60)}h
-
- )}
- {delta <= 0 && delta < 0 && (
-
- -{formatHoursDecimal(Math.abs(delta) * 60)}h
-
- )}
-
-
-
- );
- })()}
-
- {data.leave_balances[uid] ? (
- <>
- Zbývá dovolené:{" "}
- {data.leave_balances[uid].vacation_remaining.toFixed(1)}
- h / {data.leave_balances[uid].vacation_total}h
- >
- ) : (
- —
- )}
-
-
-
- );
- })}
+ —
+
+ )}
+
+
+ }
+ />
+ );
+ })}
+
)}
{/* Records Table */}
-
+
setDeleteConfirm({ show: true, record })}
/>
-
+
{/* Modals */}
@@ -435,7 +472,7 @@ export default function AttendanceAdmin() {
}
/>
- setDeleteConfirm({ show: false, record: null })}
onConfirm={handleDelete}
@@ -444,6 +481,6 @@ export default function AttendanceAdmin() {
confirmText="Smazat"
confirmVariant="danger"
/>
-
+
);
}