Files
app/src/admin/pages/AttendanceAdmin.tsx
BOHA f017867d5f fix(ui): unify list filters across all pages (bare controls, standard sizes)
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>
2026-06-07 11:49:28 +02:00

456 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}