From c690bd9a246459d6286d20289dcb1359fbb2ebe3 Mon Sep 17 00:00:00 2001 From: BOHA Date: Sun, 7 Jun 2026 00:57:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(mui):=20migrate=20Attendance=20Admin=20(Sp?= =?UTF-8?q?r=C3=A1va=20doch=C3=A1zky)=20onto=20MUI=20kit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- src/admin/components/AttendanceShiftTable.tsx | 390 ++++++++----- src/admin/components/BulkAttendanceModal.tsx | 224 +++---- src/admin/components/ShiftFormModal.tsx | 552 +++++++++--------- src/admin/pages/AttendanceAdmin.tsx | 533 +++++++++-------- 4 files changed, 914 insertions(+), 785 deletions(-) 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 ( -
- - - - - - - - - - - - - - - - - - {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 ( - - - - - - - - - - - - - - ); - })} - -
DatumZaměstnanecTypPříchodPauzaOdchodHodinyProjektGPSPoznámkaAkce
{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, "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("leave_type", e.target.value)} - className="admin-form-select" - > - - - - - -
- - {!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) => ( - - ))} - -
- )} - -
- -