feat(mui): migrate Attendance History (Moje historie) 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:43:26 +02:00
parent 19eda2ffa3
commit abbff0bfbf

View File

@@ -1,9 +1,10 @@
import { useState, useMemo, useRef } from "react"; import { useState, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
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 AdminDatePicker from "../components/AdminDatePicker";
import { companySettingsOptions } from "../lib/queries/settings"; import { companySettingsOptions } from "../lib/queries/settings";
import { import {
attendanceHistoryOptions, attendanceHistoryOptions,
@@ -25,7 +26,20 @@ import {
normalizeDateStr, normalizeDateStr,
formatHoursDecimal, formatHoursDecimal,
} from "../utils/attendanceHelpers"; } from "../utils/attendanceHelpers";
import FormField from "../components/FormField"; import {
Button,
Card,
DataTable,
Field,
FilterBar,
MonthField,
ProgressBar,
StatusChip,
PageHeader,
EmptyState,
LoadingState,
type DataColumn,
} from "../ui";
const MONTH_NAMES = [ const MONTH_NAMES = [
"Leden", "Leden",
@@ -42,6 +56,21 @@ const MONTH_NAMES = [
"Prosinec", "Prosinec",
]; ];
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 formatBreakRange = (record: AttendanceRecord): string => { const formatBreakRange = (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)}`;
@@ -122,9 +151,7 @@ function getBusinessDaysInMonth(year: number, month: number): number {
const renderProjectCell = (record: AttendanceRecord) => { const renderProjectCell = (record: AttendanceRecord) => {
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,
@@ -145,31 +172,28 @@ const renderProjectCell = (record: AttendanceRecord) => {
m = mins % 60; m = mins % 60;
} }
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}`} (${h}:${String(m).padStart(2, "0")}h${isActive ? " ▸" : ""})`}
fontSize: "0.7rem", sx={{ fontSize: "0.7rem" }}
display: "inline-block", />
background: isActive ? "var(--accent-light)" : undefined,
}}
>
{log.project_name || `#${log.project_id}`} ({h}:
{String(m).padStart(2, "0")}h{isActive ? " ▸" : ""})
</span>
); );
})} })}
</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 "—";
@@ -373,324 +397,292 @@ export default function AttendanceHistory() {
}; };
}; };
const fund = computed.monthlyFund;
const progressValue =
fund.fund > 0 ? Math.min(100, (fund.worked / fund.fund) * 100) : 0;
const progressColor =
computed.delta > 0
? "warning"
: computed.delta >= 0
? "success"
: "primary";
const deltaColor =
computed.delta > 0
? "warning.main"
: computed.delta < 0
? "error.main"
: "success.main";
const columns: DataColumn<AttendanceRecord>[] = [
{
key: "date",
header: "Datum",
width: "10%",
mono: true,
render: (record) => formatDate(record.shift_date),
},
{
key: "type",
header: "Typ",
width: "11%",
render: (record) => {
const leaveType = record.leave_type || "work";
return (
<StatusChip
color={leaveTypeChipColor(leaveType)}
label={getLeaveTypeName(leaveType)}
/>
);
},
},
{
key: "arrival",
header: "Příchod",
width: "10%",
mono: true,
render: (record) => {
const isLeave = (record.leave_type || "work") !== "work";
return isLeave ? "—" : formatDatetime(record.arrival_time);
},
},
{
key: "break",
header: "Pauza",
width: "11%",
mono: true,
render: (record) => {
const isLeave = (record.leave_type || "work") !== "work";
return isLeave ? "—" : formatBreakRange(record);
},
},
{
key: "departure",
header: "Odchod",
width: "10%",
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
? (Number(record.leave_hours) || 8) * 60
: calculateWorkMinutes(record);
return workMinutes > 0 ? formatMinutes(workMinutes, true) : "—";
},
},
{
key: "projects",
header: "Projekty",
width: "18%",
render: (record) => renderProjectCell(record),
},
{
key: "notes",
header: "Poznámka",
width: "11%",
render: (record) => record.notes || "",
},
];
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> <PageHeader
<h1 className="admin-page-title">Historie docházky</h1> title="Historie docházky"
<p className="admin-page-subtitle">{computed.monthName}</p> subtitle={computed.monthName}
</div> actions={
<div className="admin-page-actions"> records.length > 0 ? (
{records.length > 0 && ( <Button
<button variant="outlined"
onClick={handlePrint} color="inherit"
className="admin-btn admin-btn-secondary" startIcon={PrintIcon}
title="Tisk docházky" onClick={handlePrint}
> 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" /> Tisk
<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" /> </Button>
<rect x="6" y="14" width="12" height="8" /> ) : undefined
</svg> }
Tisk />
</button>
)}
</div>
</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: "1 1 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)} </FilterBar>
/>
</FormField>
</div>
</div>
</motion.div> </motion.div>
{/* Monthly Fund Card */} {/* Monthly Fund Card */}
<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.08 }} transition={{ duration: 0.25, delay: 0.08 }}
> >
<div className="admin-card-body"> <Card sx={{ mt: 3 }}>
{isPending ? ( {isPending ? (
<div className="admin-loading"> <LoadingState />
<div className="admin-spinner" />
</div>
) : ( ) : (
<> <Box
{computed.monthlyFund && ( sx={{
<div display: "flex",
style={{ alignItems: "center",
gap: 2,
flexWrap: "wrap",
}}
>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 44,
height: 44,
borderRadius: 2,
bgcolor: "info.light",
color: "info.main",
flexShrink: 0,
}}
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</Box>
<Box sx={{ flex: 1, minWidth: "200px" }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
mb: 0.75,
}}
>
<Typography sx={{ fontWeight: 600, fontSize: "1rem" }}>
Fond: {fund.worked}h / {fund.fund}h
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ fontSize: "0.8125rem" }}
>
{fund.business_days} prac. dnů
{fund.holiday_count > 0 &&
` + ${fund.holiday_count} svátky`}
</Typography>
</Box>
<ProgressBar value={progressValue} color={progressColor} />
<Box
sx={{
display: "flex", display: "flex",
alignItems: "center",
gap: "1rem",
flexWrap: "wrap", flexWrap: "wrap",
gap: 0.5,
mt: 1,
minHeight: "1.5rem",
alignItems: "center",
justifyContent: "space-between",
}} }}
> >
<div className="admin-stat-icon info"> <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}>
<svg {computed.totalMinutes > 0 && (
width="24" <StatusChip
height="24" color="info"
viewBox="0 0 24 24" label={`Práce: ${formatHoursDecimal(computed.totalMinutes)}h`}
fill="none" title="Odpracováno (reálně)"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<div style={{ flex: 1, minWidth: "200px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "baseline",
marginBottom: "0.375rem",
}}
>
<span
style={{
fontWeight: 600,
fontSize: "1rem",
color: "var(--text-primary)",
}}
>
Fond: {computed.monthlyFund.worked}h /{" "}
{computed.monthlyFund.fund}h
</span>
<span
className="text-secondary"
style={{ fontSize: "0.8125rem" }}
>
{computed.monthlyFund.business_days} prac. dnů
{computed.monthlyFund.holiday_count > 0 &&
` + ${computed.monthlyFund.holiday_count} svátky`}
</span>
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.worked / computed.monthlyFund.fund) * 100 : 0)}%`,
background:
computed.delta > 0
? "linear-gradient(135deg, var(--warning), #d97706)"
: computed.delta >= 0
? "linear-gradient(135deg, var(--success), #059669)"
: "var(--gradient)",
}}
/> />
</div> )}
<div {computed.vacationHours > 0 && (
style={{ <StatusChip
display: "flex", color="success"
flexWrap: "wrap", label={`Dov: ${computed.vacationHours}h`}
gap: "0.4rem", />
marginTop: "0.5rem", )}
minHeight: "1.5rem", {computed.sickHours > 0 && (
alignItems: "center", <StatusChip
justifyContent: "space-between", color="error"
}} label={`Nem: ${computed.sickHours}h`}
> />
<div )}
style={{ {computed.freeHolidayHours > 0 && (
display: "flex", <StatusChip
flexWrap: "wrap", color="warning"
gap: "0.4rem", label={`Sv: ${Math.round(computed.freeHolidayHours)}h`}
}} />
> )}
{computed.totalMinutes > 0 && ( {computed.unpaidHours > 0 && (
<span <StatusChip
className="attendance-leave-badge" color="default"
title="Odpracováno (reálně)" label={`Nep: ${computed.unpaidHours}h`}
> />
Práce: {formatHoursDecimal(computed.totalMinutes)}h )}
</span> </Box>
)} <Typography
{computed.vacationHours > 0 && ( variant="caption"
<span className="attendance-leave-badge badge-vacation"> sx={{ fontWeight: 600, color: deltaColor }}
Dov: {computed.vacationHours}h >
</span> {computed.delta > 0
)} ? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
{computed.sickHours > 0 && ( : computed.delta < 0
<span className="attendance-leave-badge badge-sick"> ? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
Nem: {computed.sickHours}h : "Fond splněn"}
</span> </Typography>
)} </Box>
{computed.freeHolidayHours > 0 && ( </Box>
<span className="attendance-leave-badge badge-holiday"> </Box>
Sv: {Math.round(computed.freeHolidayHours)}h
</span>
)}
{computed.unpaidHours > 0 && (
<span className="attendance-leave-badge badge-unpaid">
Nep: {computed.unpaidHours}h
</span>
)}
</div>
<span
style={{
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
className={
computed.delta > 0
? "text-warning fw-600"
: computed.delta < 0
? "text-danger fw-600"
: "text-success fw-600"
}
>
{computed.delta > 0
? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
: computed.delta < 0
? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
: "Fond splněn"}
</span>
</div>
</div>
</div>
)}
{!computed.monthlyFund && (
<div
className="text-muted"
style={{
fontSize: "0.875rem",
textAlign: "center",
padding: "0.5rem 0",
}}
>
Fond měsíce není k dispozici
</div>
)}
</>
)} )}
</div> </Card>
</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 sx={{ mt: 3 }}>
{isPending ? ( {isPending ? (
<div className="admin-loading"> <LoadingState />
<div className="admin-spinner" />
</div>
) : ( ) : (
<> <DataTable<AttendanceRecord>
{records.length === 0 && ( columns={columns}
<div className="admin-empty-state"> rows={records}
<p>Za tento měsíc nejsou žádné záznamy.</p> rowKey={(record) => record.id}
</div> empty={
)} <EmptyState title="Za tento měsíc nejsou žádné záznamy." />
{records.length > 0 && ( }
<div className="admin-table-responsive"> />
<table className="admin-table">
<thead>
<tr>
<th>Datum</th>
<th>Typ</th>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody>
{records.map((record) => {
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = isLeave
? (Number(record.leave_hours) || 8) * 60
: calculateWorkMinutes(record);
return (
<tr key={record.id}>
<td className="admin-mono">
{formatDate(record.shift_date)}
</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 ? "—" : formatBreakRange(record)}
</td>
<td className="admin-mono">
{isLeave
? "—"
: formatDatetime(record.departure_time)}
</td>
<td className="admin-mono">
{workMinutes > 0
? formatMinutes(workMinutes, true)
: "—"}
</td>
<td>{renderProjectCell(record)}</td>
<td
style={{
maxWidth: "150px",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{record.notes || ""}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</>
)} )}
</div> </Card>
</motion.div> </motion.div>
{/* Hidden Print Content */} {/* Hidden Print Content */}
@@ -896,6 +888,24 @@ export default function AttendanceHistory() {
</table> </table>
</div> </div>
)} )}
</div> </Box>
); );
} }
/** 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";
}
}