Every list-page FilterBar now follows one spec: bare controls (no Field label rows — which broke flex-end alignment), standardized widths (search 1 1 320px grows; small selects 0 0 160px; entity selects 0 0 220px; MonthField 0 0 180px; dates 0 0 150px), consistent 'Všechny/Všichni/Všechna <entity>' defaults, and a placeholder on every search. TripsAdmin's separate month-Select + year-Select collapse into one MonthField (yyyy-MM, split at the query boundary) — matching Attendance/TripsHistory. The 4 labeled attendance/trips pages move off the Field wrapper; Projects & ReceivedInvoices move their inline search into a FilterBar. Chrome-verified all ~18 filter pages in light + the existing theme. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
456 lines
14 KiB
TypeScript
456 lines
14 KiB
TypeScript
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 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 { formatMinutes, formatHoursDecimal } from "../utils/attendanceHelpers";
|
||
import {
|
||
Button,
|
||
Card,
|
||
ConfirmDialog,
|
||
FilterBar,
|
||
LoadingState,
|
||
MonthField,
|
||
PageEnter,
|
||
Select,
|
||
StatCard,
|
||
StatusChip,
|
||
ProgressBar,
|
||
type ProgressColor,
|
||
type StatCardColor,
|
||
} from "../ui";
|
||
|
||
interface UserTotalData {
|
||
name: string;
|
||
minutes: number;
|
||
working: boolean;
|
||
vacation_hours: number;
|
||
sick_hours: number;
|
||
unpaid_hours: number;
|
||
fund: number | null;
|
||
worked_hours: number;
|
||
// Raw worked hours incl. sub-day remainders (set by computeUserTotals in
|
||
// useAttendanceAdmin); used for the Fond "used" calculation below.
|
||
worked_hours_raw?: number;
|
||
covered: number;
|
||
missing: number;
|
||
overtime: number;
|
||
// mzda-style summary fields (set by computeUserTotals in useAttendanceAdmin)
|
||
odpracovano?: number;
|
||
vcetne_svatku?: number;
|
||
prescas?: number;
|
||
svatek?: number;
|
||
worked_holiday_hours?: number;
|
||
}
|
||
|
||
/** 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;
|
||
return (
|
||
realWorked +
|
||
(data.vacation_hours ?? 0) +
|
||
(data.worked_holiday_hours ?? 0) +
|
||
(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();
|
||
|
||
const {
|
||
loading,
|
||
month,
|
||
setMonth,
|
||
filterUserId,
|
||
setFilterUserId,
|
||
data,
|
||
hasData,
|
||
showBulkModal,
|
||
setShowBulkModal,
|
||
bulkSubmitting,
|
||
bulkForm,
|
||
setBulkForm,
|
||
showCreateModal,
|
||
setShowCreateModal,
|
||
createForm,
|
||
setCreateForm,
|
||
showEditModal,
|
||
setShowEditModal,
|
||
editingRecord,
|
||
editForm,
|
||
setEditForm,
|
||
deleteConfirm,
|
||
setDeleteConfirm,
|
||
projectList,
|
||
createProjectLogs,
|
||
setCreateProjectLogs,
|
||
editProjectLogs,
|
||
setEditProjectLogs,
|
||
openCreateModal,
|
||
handleCreateShiftDateChange,
|
||
handleCreateSubmit,
|
||
openBulkModal,
|
||
toggleBulkUser,
|
||
toggleAllBulkUsers,
|
||
handleBulkSubmit,
|
||
openEditModal,
|
||
handleEditSubmit,
|
||
handleDelete,
|
||
handlePrint,
|
||
} = useAttendanceAdmin({ alert });
|
||
|
||
useModalLock(showBulkModal);
|
||
useModalLock(showEditModal);
|
||
useModalLock(showCreateModal);
|
||
|
||
if (!hasPermission("attendance.manage")) return <Forbidden />;
|
||
|
||
// Show spinner only on initial load (no data yet), not on filter changes
|
||
const isInitialLoad =
|
||
loading &&
|
||
data.records.length === 0 &&
|
||
Object.keys(data.user_totals).length === 0;
|
||
|
||
if (isInitialLoad) {
|
||
return <LoadingState />;
|
||
}
|
||
|
||
const hasTotals = Object.keys(data.user_totals).length > 0;
|
||
|
||
return (
|
||
<PageEnter>
|
||
<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"
|
||
>
|
||
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>
|
||
|
||
{/* Filters */}
|
||
<FilterBar>
|
||
<Box sx={{ flex: "0 0 180px" }}>
|
||
<MonthField value={month} onChange={(val) => setMonth(val)} />
|
||
</Box>
|
||
<Box sx={{ flex: "0 0 220px" }}>
|
||
<Select
|
||
value={filterUserId}
|
||
onChange={setFilterUserId}
|
||
options={[
|
||
{ value: "", label: "Všichni zaměstnanci" },
|
||
...data.users.map((user) => ({
|
||
value: String(user.id),
|
||
label: user.name,
|
||
})),
|
||
]}
|
||
/>
|
||
</Box>
|
||
</FilterBar>
|
||
|
||
{/* User Totals (KPI cards) */}
|
||
{hasTotals && (
|
||
<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,
|
||
}}
|
||
>
|
||
{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" }}
|
||
>
|
||
—
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
</Box>
|
||
}
|
||
/>
|
||
);
|
||
})}
|
||
</Box>
|
||
)}
|
||
|
||
{/* Records Table */}
|
||
<Card>
|
||
<AttendanceShiftTable
|
||
records={data.records}
|
||
onEdit={openEditModal}
|
||
onDelete={(record) => setDeleteConfirm({ show: true, record })}
|
||
/>
|
||
</Card>
|
||
|
||
{/* Modals */}
|
||
<BulkAttendanceModal
|
||
isOpen={showBulkModal}
|
||
onClose={() => setShowBulkModal(false)}
|
||
form={bulkForm}
|
||
setForm={setBulkForm}
|
||
users={data.users}
|
||
onSubmit={handleBulkSubmit}
|
||
submitting={bulkSubmitting}
|
||
toggleUser={toggleBulkUser}
|
||
toggleAllUsers={toggleAllBulkUsers}
|
||
/>
|
||
|
||
<ShiftFormModal
|
||
mode="create"
|
||
isOpen={showCreateModal}
|
||
onClose={() => setShowCreateModal(false)}
|
||
onSubmit={handleCreateSubmit}
|
||
form={createForm}
|
||
setForm={setCreateForm}
|
||
projectLogs={createProjectLogs}
|
||
setProjectLogs={setCreateProjectLogs}
|
||
projectList={projectList}
|
||
users={data.users}
|
||
onShiftDateChange={handleCreateShiftDateChange}
|
||
editingRecord={null}
|
||
/>
|
||
|
||
<ShiftFormModal
|
||
mode="edit"
|
||
isOpen={showEditModal && !!editingRecord}
|
||
onClose={() => setShowEditModal(false)}
|
||
onSubmit={handleEditSubmit}
|
||
form={editForm}
|
||
setForm={setEditForm}
|
||
projectLogs={editProjectLogs}
|
||
setProjectLogs={setEditProjectLogs}
|
||
projectList={projectList}
|
||
users={data.users}
|
||
onShiftDateChange={handleCreateShiftDateChange}
|
||
editingRecord={
|
||
editingRecord
|
||
? {
|
||
user_name: editingRecord.user_name ?? "",
|
||
shift_date: editingRecord.shift_date,
|
||
}
|
||
: null
|
||
}
|
||
/>
|
||
|
||
<ConfirmDialog
|
||
isOpen={deleteConfirm.show}
|
||
onClose={() => setDeleteConfirm({ show: false, record: null })}
|
||
onConfirm={handleDelete}
|
||
title="Smazat záznam"
|
||
message="Opravdu chcete smazat tento záznam docházky?"
|
||
confirmText="Smazat"
|
||
confirmVariant="danger"
|
||
/>
|
||
</PageEnter>
|
||
);
|
||
}
|