Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace the raw number|string transform idiom across every schema (the root-cause NaN bug class); emailOrEmpty + lenient isoDateString/timeString. Security: roles privilege-escalation closed; refresh-token family revocation on reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices gross VAT on all paths; orders-pdf custom-items authz. Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse confirm/cancel, attendance lockUserRow); uniqueness checks moved into create transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO). Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted fields; dashboard invalidation gaps; stale-closure confirm bugs. Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier; tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes), isolated on app_test via .env.test with a hard-throw setup guard. Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
822 lines
26 KiB
TypeScript
822 lines
26 KiB
TypeScript
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: "" });
|
||
|
||
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");
|
||
},
|
||
});
|
||
|
||
if (!hasPermission("attendance.balances")) return <Forbidden />;
|
||
|
||
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) || 0,
|
||
})
|
||
}
|
||
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) || 0,
|
||
})
|
||
}
|
||
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) || 0,
|
||
})
|
||
}
|
||
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>
|
||
);
|
||
}
|