feat(mui): consistent staggered page entrance across all 43 route pages

Adopt the PageEnter wrapper as every page's outermost render element and remove the ad-hoc per-page entrance motion.div wrappers. Every page now enters the same way — all top-level sections rise+fade in, staggered — so nothing appears instantly and the motion is identical app-wide. Presentation-only: no data/logic/hooks/invalidate/permissions touched. Embedded sub-tabs (CompanySettings, ReceivedInvoices), Login (auth shell) and the dev UiKit are intentionally excluded. Gates: tsc -b --noEmit=0, build=0, vitest 152/152.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-07 10:13:06 +02:00
parent b273614128
commit 39fe84ce99
43 changed files with 4956 additions and 5709 deletions

View File

@@ -1,4 +1,3 @@
import { motion } from "framer-motion";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useAlert } from "../context/AlertContext";
@@ -18,6 +17,7 @@ import {
FilterBar,
LoadingState,
MonthField,
PageEnter,
Select,
StatCard,
StatusChip,
@@ -165,262 +165,236 @@ export default function AttendanceAdmin() {
const hasTotals = Object.keys(data.user_totals).length > 0;
return (
<Box>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
<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 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={setFilterUserId}
options={[
{ value: "", label: "Všichni" },
...data.users.map((user) => ({
value: String(user.id),
label: user.name,
})),
]}
/>
</Field>
</Box>
</FilterBar>
{/* User Totals (KPI cards) */}
{hasTotals && (
<Box
sx={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
flexWrap: "wrap",
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
lg: "repeat(3, 1fr)",
},
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>
</motion.div>
{/* Filters */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<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={setFilterUserId}
options={[
{ value: "", label: "Všichni" },
...data.users.map((user) => ({
value: String(user.id),
label: user.name,
})),
]}
/>
</Field>
</Box>
</FilterBar>
</motion.div>
{/* User Totals (KPI cards) */}
{hasTotals && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.09 }}
>
<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={
{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
component="span"
sx={{
display: "inline-flex",
display: "flex",
flexWrap: "wrap",
gap: 0.5,
mb: 1,
minHeight: "1.75rem",
alignItems: "center",
gap: 1,
}}
>
{formatMinutes(ut.minutes)}
<StatusChip
color={ut.working ? "success" : "default"}
label={ut.working ? "✓" : "✗"}
/>
{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>
}
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,
}}
{/* 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"
color="text.secondary"
sx={{
fontWeight: 600,
color: "warning.main",
}}
>
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
{formatHoursDecimal(fundVal * 60)}h
+{formatHoursDecimal(delta * 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}
/>
)}
{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>
{/* 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>
</motion.div>
</Box>
}
/>
);
})}
</Box>
)}
{/* Records Table */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.12 }}
>
<Card>
<AttendanceShiftTable
records={data.records}
onEdit={openEditModal}
onDelete={(record) => setDeleteConfirm({ show: true, record })}
/>
</Card>
</motion.div>
<Card>
<AttendanceShiftTable
records={data.records}
onEdit={openEditModal}
onDelete={(record) => setDeleteConfirm({ show: true, record })}
/>
</Card>
{/* Modals */}
<BulkAttendanceModal
@@ -481,6 +455,6 @@ export default function AttendanceAdmin() {
confirmText="Smazat"
confirmVariant="danger"
/>
</Box>
</PageEnter>
);
}