Files
app/src/admin/pages/AttendanceBalances.tsx
BOHA 39fe84ce99 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>
2026-06-07 10:13:06 +02:00

822 lines
26 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 { useState } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { useQuery } from "@tanstack/react-query";
import {
attendanceBalancesOptions,
attendanceWorkFundOptions,
attendanceProjectReportOptions,
type BalanceEntry,
type FundUserData,
} from "../lib/queries/attendance";
import { useApiMutation } from "../lib/queries/mutations";
import {
Card,
DataTable,
Modal,
ConfirmDialog,
Field,
TextField,
Select,
StatusChip,
PageHeader,
PageEnter,
EmptyState,
LoadingState,
ProgressBar,
type DataColumn,
type ProgressColor,
} from "../ui";
const API_BASE = "/api/admin";
const EditIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
);
const ResetIcon = (
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
</svg>
);
/** Maps the remaining-vacation value to a kit color token (mirrors legacy
* getVacationClass: <=0 danger, <20 warning, otherwise default text color). */
const getVacationColor = (remaining: number): string | undefined => {
if (remaining <= 0) return "error.main";
if (remaining < 20) return "warning.main";
return undefined;
};
const renderFundDiff = (data: { overtime: number; missing: number }) => {
if (data.overtime > 0) {
return (
<Typography
component="span"
sx={{ fontWeight: 600, color: "warning.main", fontSize: "0.8rem" }}
>
+{data.overtime}h
</Typography>
);
}
if (data.missing > 0) {
return (
<Typography
component="span"
sx={{ color: "error.main", fontSize: "0.8rem" }}
>
-{data.missing}h
</Typography>
);
}
return (
<Typography
component="span"
sx={{ color: "success.main", fontSize: "0.8rem" }}
>
0h
</Typography>
);
};
const renderMonthlyStatus = (
us: FundUserData,
isFulfilled: boolean,
isCurrentMonth: boolean,
) => {
if (us.overtime > 0) {
return <StatusChip color="warning" label={`+${us.overtime}h`} />;
}
if (us.missing > 0) {
return <StatusChip color="error" label={`-${us.missing}h`} />;
}
if (isFulfilled && !isCurrentMonth) {
return <StatusChip color="success" label="OK" />;
}
return null;
};
/** Maps a user's monthly fund state to a ProgressBar color token. Mirrors the
* legacy getProgressBackground gradient conditional: overtime→warning,
* fulfilled→success, current month→primary (was the accent gradient),
* otherwise→error (deficit). */
const getProgressColor = (
us: FundUserData,
isFulfilled: boolean,
isCurrentMonth: boolean,
): ProgressColor => {
if (us.overtime > 0) return "warning";
if (isFulfilled) return "success";
if (isCurrentMonth) return "primary";
return "error";
};
export default function AttendanceBalances() {
const alert = useAlert();
const { hasPermission } = useAuth();
const [year, setYear] = useState(new Date().getFullYear());
const { data: balancesData, isPending: balancesPending } = useQuery(
attendanceBalancesOptions(year),
);
const { data: fundData, isPending: fundPending } = useQuery(
attendanceWorkFundOptions(year),
);
const { data: projectData, isPending: projectPending } = useQuery(
attendanceProjectReportOptions(year),
);
const [showEditModal, setShowEditModal] = useState(false);
const [editingUser, setEditingUser] = useState<{
id: string;
name: string;
} | null>(null);
const [editForm, setEditForm] = useState({
vacation_total: 160,
vacation_used: 0,
sick_used: 0,
});
const [resetConfirm, setResetConfirm] = useState<{
show: boolean;
userId: string | null;
userName: string;
}>({ show: false, userId: null, userName: "" });
if (!hasPermission("attendance.balances")) return <Forbidden />;
const editMutation = useApiMutation<
{
user_id: string;
year: number;
action_type: "edit";
vacation_total: number;
vacation_used: number;
sick_used: number;
},
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/attendance?action=balances`,
method: () => "POST",
invalidate: ["attendance", "users"],
});
const resetMutation = useApiMutation<
{ user_id: string; year: number; action_type: "reset" },
{ message?: string; error?: string }
>({
url: () => `${API_BASE}/attendance?action=balances`,
method: () => "POST",
invalidate: ["attendance", "users"],
onSuccess: (data) => {
setResetConfirm({ show: false, userId: null, userName: "" });
alert.success(data?.message || "Operace dokončena");
},
});
const openEditModal = (userId: string, balance: BalanceEntry) => {
setEditingUser({ id: userId, name: balance.name });
setEditForm({
vacation_total: balance.vacation_total,
vacation_used: balance.vacation_used,
sick_used: balance.sick_used,
});
setShowEditModal(true);
};
const handleEditSubmit = async () => {
if (!editingUser) return;
try {
const result = await editMutation.mutateAsync({
user_id: editingUser.id,
year,
action_type: "edit",
...editForm,
});
setShowEditModal(false);
alert.success(result?.message || "Upraveno");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleReset = async () => {
if (!resetConfirm.userId) return;
try {
await resetMutation.mutateAsync({
user_id: resetConfirm.userId,
year,
action_type: "reset",
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const years: number[] = [];
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
for (let y = currentYear - 5; y <= currentYear + 5; y++) {
years.push(y);
}
const getYearFundTotals = (userId: string) => {
if (!fundData?.months || Object.keys(fundData.months).length === 0)
return null;
let totalFund = 0;
let totalWorked = 0;
let totalCovered = 0;
for (const monthData of Object.values(fundData.months)) {
// Use prorated fund (fund_to_date) for current month, full fund for past
totalFund += monthData.fund_to_date ?? monthData.fund;
const us = monthData.users?.[userId];
if (us) {
totalWorked += us.worked;
totalCovered += us.covered;
}
}
const missing = Math.max(
0,
Math.round((totalFund - totalCovered) * 10) / 10,
);
const overtime = Math.max(
0,
Math.round((totalCovered - totalFund) * 10) / 10,
);
return {
fund: totalFund,
worked: Math.round(totalWorked * 10) / 10,
covered: Math.round(totalCovered * 10) / 10,
missing,
overtime,
};
};
const balanceRows = balancesData
? Object.entries(balancesData.balances).map(([userId, balance]) => ({
userId,
balance,
}))
: [];
const columns: DataColumn<{ userId: string; balance: BalanceEntry }>[] = [
{
key: "name",
header: "Zaměstnanec",
width: "20%",
bold: true,
render: ({ balance }) => balance.name,
},
{
key: "vacation_total",
header: "Nárok (h)",
width: "10%",
mono: true,
render: ({ balance }) => balance.vacation_total,
},
{
key: "vacation_used",
header: "Čerpáno (h)",
width: "10%",
mono: true,
render: ({ balance }) => balance.vacation_used.toFixed(1),
},
{
key: "vacation_remaining",
header: "Zbývá (h)",
width: "10%",
mono: true,
render: ({ balance }) => (
<Box
component="span"
sx={{ color: getVacationColor(balance.vacation_remaining) }}
>
{balance.vacation_remaining.toFixed(1)}
</Box>
),
},
{
key: "sick_used",
header: "Nemoc (h)",
width: "9%",
mono: true,
render: ({ balance }) => balance.sick_used.toFixed(1),
},
{
key: "fund",
header: "Fond roku",
width: "9%",
mono: true,
render: ({ userId }) => {
const yf = getYearFundTotals(userId);
return yf ? `${yf.fund}h` : "—";
},
},
{
key: "covered",
header: "Pokryto",
width: "9%",
mono: true,
render: ({ userId }) => {
const yf = getYearFundTotals(userId);
return yf ? `${yf.covered}h` : "—";
},
},
{
key: "diff",
header: "+/",
width: "8%",
mono: true,
render: ({ userId }) => {
const yf = getYearFundTotals(userId);
return yf ? renderFundDiff(yf) : "—";
},
},
{
key: "actions",
header: "Akce",
width: "10%",
align: "right",
render: ({ userId, balance }) => (
<Box sx={{ display: "flex", gap: 0.5, justifyContent: "flex-end" }}>
<IconButton
size="small"
onClick={() => openEditModal(userId, balance)}
aria-label="Upravit"
title="Upravit"
>
{EditIcon}
</IconButton>
<IconButton
size="small"
color="error"
onClick={() =>
setResetConfirm({
show: true,
userId,
userName: balance.name,
})
}
aria-label="Resetovat"
title="Resetovat"
>
{ResetIcon}
</IconButton>
</Box>
),
},
];
return (
<PageEnter>
<PageHeader
title="Správa bilancí"
actions={
<Box sx={{ minWidth: 120 }}>
<Select
value={String(year)}
onChange={(val) => setYear(parseInt(val))}
options={years.map((y) => ({
value: String(y),
label: String(y),
}))}
/>
</Box>
}
/>
<Card>
{balancesPending ? (
<LoadingState />
) : balanceRows.length === 0 ? (
<EmptyState title="Žádní uživatelé k zobrazení." />
) : (
<DataTable<{ userId: string; balance: BalanceEntry }>
columns={columns}
rows={balanceRows}
rowKey={(row) => row.userId}
/>
)}
</Card>
{/* Monthly Fund Overview */}
{!fundPending &&
fundData?.months &&
Object.keys(fundData.months).length > 0 && (
<Box sx={{ mt: 4 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
Měsíční přehled fondu {year}
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" },
gap: 2,
}}
>
{Object.entries(fundData.months).map(([monthKey, monthData]) => {
const isCurrentMonth =
year === currentYear && parseInt(monthKey) === currentMonth;
return (
<Card
key={monthKey}
sx={
isCurrentMonth
? {
borderColor: "primary.main",
boxShadow: (theme) =>
`0 0 0 1px ${theme.palette.primary.main}`,
}
: undefined
}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 1.5,
}}
>
<Typography
component="h3"
sx={{
fontWeight: 600,
fontSize: "1rem",
display: "flex",
alignItems: "center",
gap: 0.75,
}}
>
{monthData.month_name}
{isCurrentMonth && (
<StatusChip color="info" label="aktuální" />
)}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: "12px" }}
>
{monthData.fund_to_date ?? monthData.fund}h (
{Math.round(
(monthData.fund_to_date ?? monthData.fund) / 8,
)}{" "}
dnů)
</Typography>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 1,
}}
>
{fundData?.users &&
fundData.users.map((user) => {
const us = monthData.users?.[String(user.id)];
if (!us) return null;
const effectiveFund =
monthData.fund_to_date ?? monthData.fund;
const pct =
effectiveFund > 0
? Math.min(
100,
(us.covered / effectiveFund) * 100,
)
: 0;
const isFulfilled = us.covered >= effectiveFund;
return (
<Box key={user.id}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "12px",
mb: 0.5,
}}
>
<Typography
variant="caption"
sx={{ color: "text.primary" }}
>
{us.name}
</Typography>
<Box
sx={{
display: "flex",
gap: 1,
alignItems: "center",
}}
>
<Typography
variant="caption"
color="text.secondary"
>
{us.worked}h
</Typography>
{renderMonthlyStatus(
us,
isFulfilled,
isCurrentMonth,
)}
</Box>
</Box>
<ProgressBar
value={pct}
color={getProgressColor(
us,
isFulfilled,
isCurrentMonth,
)}
height={4}
/>
</Box>
);
})}
</Box>
</Card>
);
})}
</Box>
</Box>
)}
{fundPending && <LoadingState />}
{/* Monthly Project Overview */}
{!projectPending &&
projectData?.months &&
Object.keys(projectData.months).length > 0 && (
<Box sx={{ mt: 4 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
Měsíční přehled projektů {year}
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" },
gap: 2,
}}
>
{Object.entries(projectData.months).map(
([monthKey, monthInfo]) => {
const isCurrentMonth =
year === currentYear && parseInt(monthKey) === currentMonth;
const totalHours = monthInfo.projects.reduce(
(sum, p) => sum + p.hours,
0,
);
if (monthInfo.projects.length === 0) return null;
return (
<Card
key={monthKey}
sx={
isCurrentMonth
? {
borderColor: "primary.main",
boxShadow: (theme) =>
`0 0 0 1px ${theme.palette.primary.main}`,
}
: undefined
}
>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 1.5,
}}
>
<Typography
component="h3"
sx={{
fontWeight: 600,
fontSize: "1rem",
display: "flex",
alignItems: "center",
gap: 0.75,
}}
>
{monthInfo.month_name}
{isCurrentMonth && (
<StatusChip color="info" label="aktuální" />
)}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: "12px", fontWeight: 600 }}
>
{totalHours.toFixed(1)}h
</Typography>
</Box>
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 1.5,
}}
>
{monthInfo.projects.map((proj) => (
<Box key={proj.project_id || "no-project"}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 0.5,
}}
>
<Typography
variant="caption"
sx={{
fontWeight: 600,
color: "text.primary",
}}
>
{proj.project_id
? proj.project_number
: "Bez projektu"}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontWeight: 600 }}
>
{proj.hours.toFixed(1)}h
</Typography>
</Box>
{proj.project_id && proj.project_name && (
<Typography
variant="caption"
color="text.disabled"
sx={{
display: "block",
fontSize: "0.7rem",
mb: 0.5,
}}
>
{proj.project_name}
</Typography>
)}
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 0.75,
}}
>
{proj.users.map((u) => {
const pct =
proj.hours > 0
? Math.min(
100,
(u.hours / proj.hours) * 100,
)
: 0;
return (
<Box key={u.user_id}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 0.25,
}}
>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: "11px" }}
>
{u.user_name}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ fontSize: "11px" }}
>
{u.hours.toFixed(1)}h
</Typography>
</Box>
<ProgressBar
value={pct}
color={
proj.project_id ? "primary" : "info"
}
height={4}
/>
</Box>
);
})}
</Box>
</Box>
))}
</Box>
</Card>
);
},
)}
</Box>
</Box>
)}
{projectPending && <LoadingState />}
{/* Edit Modal */}
<Modal
isOpen={showEditModal && !!editingUser}
onClose={() => setShowEditModal(false)}
onSubmit={handleEditSubmit}
title="Upravit dovolenou"
subtitle={editingUser?.name}
loading={editMutation.isPending}
>
<Field label="Nárok na dovolenou (hodiny)">
<TextField
type="number"
value={editForm.vacation_total}
onChange={(e) =>
setEditForm({
...editForm,
vacation_total: parseFloat(e.target.value),
})
}
slotProps={{ htmlInput: { min: 0, max: 500, step: 1 } }}
/>
</Field>
<Field label="Čerpáno dovolené (hodiny)">
<TextField
type="number"
value={editForm.vacation_used}
onChange={(e) =>
setEditForm({
...editForm,
vacation_used: parseFloat(e.target.value),
})
}
slotProps={{ htmlInput: { min: 0, max: 500, step: 0.5 } }}
/>
</Field>
<Field label="Čerpáno nemocenské (hodiny)">
<TextField
type="number"
value={editForm.sick_used}
onChange={(e) =>
setEditForm({
...editForm,
sick_used: parseFloat(e.target.value),
})
}
slotProps={{ htmlInput: { min: 0, max: 500, step: 0.5 } }}
/>
</Field>
</Modal>
{/* Reset Confirmation */}
<ConfirmDialog
isOpen={resetConfirm.show}
onClose={() =>
setResetConfirm({ show: false, userId: null, userName: "" })
}
onConfirm={handleReset}
title="Resetovat bilanci"
message={`Opravdu chcete vynulovat čerpání dovolené a nemocenské pro ${resetConfirm.userName} za rok ${year}?`}
confirmText="Resetovat"
confirmVariant="danger"
loading={resetMutation.isPending}
/>
</PageEnter>
);
}