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:
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user