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:
BOHA
2026-06-07 00:57:26 +02:00
parent 3eb364fba5
commit c690bd9a24
4 changed files with 914 additions and 785 deletions

View File

@@ -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 = (
<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() {
const alert = useAlert();
const { hasPermission } = useAuth();
@@ -111,278 +159,267 @@ export default function AttendanceAdmin() {
Object.keys(data.user_totals).length === 0;
if (isInitialLoad) {
return (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
return <LoadingState />;
}
const hasTotals = Object.keys(data.user_totals).length > 0;
return (
<div>
<Box>
<motion.div
className="admin-page-header"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<div>
<h1 className="admin-page-title">Správa docházky</h1>
</div>
<div className="admin-page-actions">
{hasData && (
<button
onClick={handlePrint}
className="admin-btn admin-btn-secondary"
title="Tisk docházky"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
style={{ marginRight: "0.5rem" }}
<Box
sx={{
display: "flex",
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 && (
<Button
variant="outlined"
color="inherit"
startIcon={PrintIcon}
onClick={handlePrint}
title="Tisk docházky"
>
<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
</button>
)}
<button
onClick={openBulkModal}
className="admin-btn admin-btn-secondary"
>
Vyplnit měsíc
</button>
<button
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
</button>
</div>
Tisk
</Button>
)}
<Button variant="outlined" color="inherit" onClick={openBulkModal}>
Vyplnit měsíc
</Button>
<Button startIcon={AddIcon} onClick={openCreateModal}>
Přidat záznam
</Button>
</Box>
</Box>
</motion.div>
{/* Filters */}
<motion.div
className="admin-card mb-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="admin-card-body">
<div className="admin-form-row">
<FormField label="Měsíc">
<AdminDatePicker
mode="month"
value={month}
onChange={(val: string) => setMonth(val)}
/>
</FormField>
<FormField label="Zaměstnanec">
<select
<FilterBar>
<Box sx={{ flex: "0 0 200px" }}>
<Field label="Měsíc">
<MonthField value={month} onChange={(val) => setMonth(val)} />
</Field>
</Box>
<Box sx={{ flex: "1 1 240px" }}>
<Field label="Zaměstnanec">
<Select
value={filterUserId}
onChange={(e) => setFilterUserId(e.target.value)}
className="admin-form-select"
>
<option value="">Všichni</option>
{data.users.map((user) => (
<option key={user.id} value={user.id}>
{user.name}
</option>
))}
</select>
</FormField>
</div>
</div>
onChange={setFilterUserId}
options={[
{ value: "", label: "Všichni" },
...data.users.map((user) => ({
value: String(user.id),
label: user.name,
})),
]}
/>
</Field>
</Box>
</FilterBar>
</motion.div>
{/* User Totals */}
{Object.keys(data.user_totals).length > 0 && (
{/* User Totals (KPI cards) */}
{hasTotals && (
<motion.div
className="admin-grid admin-grid-3 mb-6"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.09 }}
>
{Object.entries(data.user_totals).map(([uid, userData]) => {
const ut = userData as UserTotalData;
return (
<div key={uid} className="admin-card" style={{ display: "flex" }}>
<div
className="admin-card-body"
style={{
display: "flex",
flexDirection: "column",
flex: 1,
}}
>
<div className="flex-row gap-2 mb-2">
<span style={{ fontWeight: 600 }}>{ut.name}</span>
<span
className={`attendance-working-badge ${ut.working ? "working" : "finished"}`}
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
lg: "repeat(3, 1fr)",
},
gap: 2,
mb: 3,
}}
>
{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 (
<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)}
</div>
<div className="admin-stat-label">odpracováno</div>
<div
style={{
marginTop: "0.5rem",
marginBottom: "0.75rem",
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
minHeight: "2rem",
}}
>
{ut.vacation_hours > 0 && (
<span className="attendance-leave-badge badge-vacation">
Dov: {ut.vacation_hours}h
</span>
)}
{ut.sick_hours > 0 && (
<span className="attendance-leave-badge badge-sick">
Nem: {ut.sick_hours}h
</span>
)}
{ut.svatek !== undefined && ut.svatek > 0 && (
<span className="attendance-leave-badge badge-holiday">
Sv: {Math.round(ut.svatek)}h
</span>
)}
{ut.unpaid_hours > 0 && (
<span className="attendance-leave-badge badge-unpaid">
Nep: {ut.unpaid_hours}h
</span>
)}
</div>
{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 (
<div
className="kpi-card-footer"
style={{
marginTop: "auto",
paddingTop: "0.75rem",
borderTop: "1px solid var(--border-color)",
}}
>
<div
className="text-secondary"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "0.8rem",
}}
{formatMinutes(ut.minutes)}
<StatusChip
color={ut.working ? "success" : "default"}
label={ut.working ? "✓" : "✗"}
/>
</Box>
}
color={statColor}
footer={
<Box>
{/* Leave-type badges */}
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: 0.5,
mb: 1,
minHeight: "1.75rem",
alignItems: "center",
}}
>
{ut.vacation_hours > 0 && (
<StatusChip
color="info"
label={`Dov: ${ut.vacation_hours}h`}
/>
)}
{ut.sick_hours > 0 && (
<StatusChip
color="error"
label={`Nem: ${ut.sick_hours}h`}
/>
)}
{ut.svatek !== undefined && ut.svatek > 0 && (
<StatusChip
color="warning"
label={`Sv: ${Math.round(ut.svatek)}h`}
/>
)}
{ut.unpaid_hours > 0 && (
<StatusChip
color="default"
label={`Nep: ${ut.unpaid_hours}h`}
/>
)}
</Box>
{/* 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 (
<Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 0.5,
}}
>
<Typography
variant="caption"
color="text.secondary"
>
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
{formatHoursDecimal(fundVal * 60)}h
</Typography>
{delta > 0 && (
<Typography
variant="caption"
sx={{
fontWeight: 600,
color: "warning.main",
}}
>
+{formatHoursDecimal(delta * 60)}h
</Typography>
)}
{delta < 0 && (
<Typography
variant="caption"
sx={{
fontWeight: 600,
color: "error.main",
}}
>
-{formatHoursDecimal(Math.abs(delta) * 60)}h
</Typography>
)}
</Box>
<ProgressBar
value={pct}
color={getFundBarColor(ut)}
height={4}
/>
</Box>
);
})()}
{/* Remaining vacation */}
<Box sx={{ mt: 1.5 }}>
{balance ? (
<Typography variant="caption" color="text.secondary">
Zbývá dovolené:{" "}
{balance.vacation_remaining.toFixed(1)}h /{" "}
{balance.vacation_total}h
</Typography>
) : (
<Typography
variant="caption"
sx={{ visibility: "hidden" }}
>
<span>
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
{formatHoursDecimal(fundVal * 60)}h
</span>
{delta > 0 && (
<span className="text-warning fw-600">
+{formatHoursDecimal(delta * 60)}h
</span>
)}
{delta <= 0 && delta < 0 && (
<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
style={{
height: "100%",
width: `${Math.min(
100,
(fondUsed / (ut.fund || 1)) * 100,
)}%`,
background: getFundBarBackground(ut),
borderRadius: "2px",
transition: "width 0.3s ease",
}}
/>
</div>
</div>
);
})()}
<div
className="text-secondary"
style={{ marginTop: "0.75rem", fontSize: "0.8rem" }}
>
{data.leave_balances[uid] ? (
<>
Zbývá dovolené:{" "}
{data.leave_balances[uid].vacation_remaining.toFixed(1)}
h / {data.leave_balances[uid].vacation_total}h
</>
) : (
<span style={{ visibility: "hidden" }}></span>
)}
</div>
</div>
</div>
);
})}
</Typography>
)}
</Box>
</Box>
}
/>
);
})}
</Box>
</motion.div>
)}
{/* Records Table */}
<motion.div
className="admin-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
<Card>
<AttendanceShiftTable
records={data.records}
onEdit={openEditModal}
onDelete={(record) => setDeleteConfirm({ show: true, record })}
/>
</div>
</Card>
</motion.div>
{/* Modals */}
@@ -435,7 +472,7 @@ export default function AttendanceAdmin() {
}
/>
<ConfirmModal
<ConfirmDialog
isOpen={deleteConfirm.show}
onClose={() => setDeleteConfirm({ show: false, record: null })}
onConfirm={handleDelete}
@@ -444,6 +481,6 @@ export default function AttendanceAdmin() {
confirmText="Smazat"
confirmVariant="danger"
/>
</div>
</Box>
);
}