Files
app/src/admin/pages/Attendance.tsx
BOHA c2746d78c9 fix(dashboard): stale punch button/KPIs after attendance changes on other pages
Verified flow: clock in on dashboard -> delete the record in /attendance/admin
-> navigate back: 'Zaznamenat odchod', the Pritomni KPI and the presence card
still showed the pre-delete state until F5. Root cause: every attendance
mutation outside the dashboard invalidated only ["attendance"], never
["dashboard"], so within the dashboard query's 60s staleTime the remount was
served from cache.

- useAttendanceAdmin create/bulk/edit/delete, /attendance punch +
  leave-request, AttendanceCreate, LeaveApproval approve/reject now also
  invalidate ["dashboard"] (CLAUDE.md rule: mutations invalidate every domain
  that embeds their data)
- systemic backstop: dashboardOptions uses refetchOnMount "always" - the
  dashboard aggregates attendance/offers/invoices/orders/projects/leave, and
  domain pages can't all be expected to invalidate it; returning to the
  dashboard must never show pre-mutation data

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 11:40:06 +02:00

1288 lines
40 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, useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link as RouterLink } from "react-router-dom";
import { parseISO, isValid } from "date-fns";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import {
formatTime,
calculateWorkMinutes,
formatMinutes,
} from "../utils/attendanceHelpers";
import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import { jsonQuery } from "../lib/apiAdapter";
import { useApiMutation } from "../lib/queries/mutations";
import { czechPlural, todayLocalStr } from "../utils/formatters";
import {
Button,
Card,
DataTable,
DateField,
Field,
Modal,
ConfirmDialog,
Select,
StatCard,
StatusChip,
TextField,
ProgressBar,
PageEnter,
EmptyState,
LoadingState,
type DataColumn,
type ProgressColor,
} from "../ui";
const API_BASE = "/api/admin";
interface ShiftRecord {
id: number;
user_id: number;
shift_date: string;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
notes?: string | null;
project_id?: number | null;
project_logs?: ProjectLog[];
}
interface ProjectLog {
id?: number;
project_id?: number;
project_name?: string;
started_at?: string;
ended_at?: string | null;
}
interface Project {
id: number;
name: string;
project_number: string;
}
interface LeaveBalance {
vacation_total: number;
vacation_used: number;
vacation_remaining: number;
sick_used: number;
}
interface MonthlyFund {
month_name: string;
fund: number;
worked: number;
covered: number;
remaining: number;
overtime: number;
leave_hours: number;
vacation_hours: number;
sick_hours: number;
unpaid_hours: number;
}
interface AttendanceData {
ongoing_shift: ShiftRecord | null;
today_shifts: ShiftRecord[];
date: string;
leave_balance: LeaveBalance;
monthly_fund: MonthlyFund | null;
project_logs: ProjectLog[];
active_project_id: number | null;
}
/** Maps the monthly-fund state to a ProgressBar color token. Mirrors the legacy
* getFundBarBackground gradient conditional: overtime→warning, covered≥fund→
* success, otherwise→primary (was the accent gradient). */
function getFundBarColor(fund: MonthlyFund): ProgressColor {
if (fund.overtime > 0) return "warning";
if (fund.covered >= fund.fund) return "success";
return "primary";
}
const CalendarIcon = (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
);
const VacationIcon = (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
);
const SickIcon = (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
);
const RequestsIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
);
const HistoryIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M3 3v18h18" />
<path d="M18.7 8l-5.1 5.2-2.8-2.7L7 14.3" />
</svg>
);
const ManageIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
);
const BalancesIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
);
const ChevronIcon = (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 18l6-6-6-6" />
</svg>
);
/** Sidebar navigation row: icon + label on the left, chevron on the right. */
function QuickLink({
to,
icon,
label,
}: {
to: string;
icon: React.ReactNode;
label: string;
}) {
return (
<Button
component={RouterLink}
to={to}
variant="text"
color="inherit"
sx={{
justifyContent: "flex-start",
width: "100%",
gap: 1.25,
px: 1.5,
py: 1.25,
borderRadius: 2,
color: "text.primary",
fontWeight: 500,
"& .nav-chevron": { ml: "auto", color: "text.disabled" },
}}
>
<Box sx={{ display: "flex", color: "text.secondary" }}>{icon}</Box>
<span>{label}</span>
<Box component="span" className="nav-chevron" sx={{ display: "flex" }}>
{ChevronIcon}
</Box>
</Button>
);
}
export default function Attendance() {
const alert = useAlert();
const { hasPermission } = useAuth();
const statusQuery = useQuery({
queryKey: ["attendance", "status"],
queryFn: () => jsonQuery<AttendanceData>("/api/admin/attendance/status"),
});
const projectsQuery = useQuery({
queryKey: ["attendance", "projects"],
queryFn: () =>
jsonQuery<Project[]>("/api/admin/attendance?action=projects"),
});
const punchMutation = useApiMutation<
Record<string, unknown>,
{ message?: string }
>({
url: () => `${API_BASE}/attendance`,
method: () => "POST",
// dashboard included: the punch-button state + presence cards live there.
invalidate: ["attendance", "dashboard"],
});
const notesMutation = useApiMutation<{ notes: string }, { message?: string }>(
{
url: () => `${API_BASE}/attendance/notes`,
method: () => "POST",
invalidate: ["attendance"],
},
);
const switchProjectMutation = useApiMutation<
{ project_id: number | null },
{ message?: string }
>({
url: () => `${API_BASE}/attendance/switch-project`,
method: () => "POST",
invalidate: ["attendance"],
});
const leaveRequestMutation = useApiMutation<
{ leave_type: string; date_from: string; date_to: string; notes: string },
{ message?: string }
>({
url: () => `${API_BASE}/leave-requests`,
method: () => "POST",
// dashboard included: approvers see the pending-requests KPI there.
invalidate: ["attendance", "leave-requests", "leave", "users", "dashboard"],
});
const [submitting, setSubmitting] = useState(false);
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [leaveForm, setLeaveForm] = useState({
leave_type: "vacation",
date_from: todayLocalStr(),
date_to: todayLocalStr(),
notes: "",
});
const [requestSubmitting, setRequestSubmitting] = useState(false);
const [notes, setNotes] = useState("");
const [switchingProject, setSwitchingProject] = useState(false);
const [gpsConfirm, setGpsConfirm] = useState<{
isOpen: boolean;
action: string | null;
}>({ isOpen: false, action: null });
const geoAbortRef = useRef<AbortController | null>(null);
const punchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const latestActionRef = useRef<string | null>(null);
// Live wall-clock display (HH:MM) — re-render every 30s so the shown time
// actually advances instead of freezing at first paint.
const [now, setNow] = useState(() => new Date());
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (geoAbortRef.current) geoAbortRef.current.abort();
if (punchTimeoutRef.current) clearTimeout(punchTimeoutRef.current);
};
}, []);
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 30_000);
return () => clearInterval(id);
}, []);
// Sync notes from query data when the shift changes
useEffect(() => {
if (statusQuery.data) {
setNotes(statusQuery.data.ongoing_shift?.notes || "");
}
}, [statusQuery.data]);
if (!hasPermission("attendance.record")) return <Forbidden />;
const handlePunch = (action: string) => {
setSubmitting(true);
latestActionRef.current = action;
// Some browsers silently hang on getCurrentPosition (especially with
// enableHighAccuracy:true on desktops without GPS). Use a short safety
// timeout and proceed without GPS rather than leaving the user stuck.
const safetyTimeout = setTimeout(() => {
if (mountedRef.current) {
submitPunch(action, {});
}
}, 6000);
if (!navigator.geolocation) {
clearTimeout(safetyTimeout);
alert.warning("GPS není dostupná");
submitPunch(action, {});
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
clearTimeout(safetyTimeout);
if (!mountedRef.current) return;
try {
const { latitude, longitude, accuracy } = position.coords;
submitPunch(action, { latitude, longitude, accuracy, address: "" });
} catch {
submitPunch(action, {});
}
// Fire-and-forget reverse geocoding to update the address later
if (geoAbortRef.current) geoAbortRef.current.abort();
const controller = new AbortController();
geoAbortRef.current = controller;
fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${position.coords.latitude}&lon=${position.coords.longitude}&zoom=18&addressdetails=1`,
{
headers: { "Accept-Language": "cs" },
signal: controller.signal,
},
)
.then((r) => r.json())
.then((geoData) => {
if (!mountedRef.current) return;
if (latestActionRef.current !== action) return;
if (geoData.display_name) {
apiFetch(`${API_BASE}/attendance/update-address`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
address: geoData.display_name,
punch_action: action,
}),
}).catch(() => {});
}
})
.catch(() => {});
},
(geoError) => {
clearTimeout(safetyTimeout);
if (!mountedRef.current) return;
let errorMsg = "Nepodařilo se získat polohu";
if (geoError.code === geoError.PERMISSION_DENIED) {
errorMsg = "Přístup k poloze byl zamítnut";
} else if (geoError.code === geoError.TIMEOUT) {
errorMsg = "Vypršel časový limit";
}
alert.error(errorMsg);
setSubmitting(false);
setGpsConfirm({ isOpen: true, action });
},
{ enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 },
);
};
const submitPunch = async (
action: string,
gpsData: Record<string, unknown> = {},
) => {
try {
const result = await punchMutation.mutateAsync({
punch_action: action,
...gpsData,
});
setSubmitting(false);
punchTimeoutRef.current = setTimeout(() => {
alert.success(result?.message || "Uloženo");
}, 300);
} catch (e) {
setSubmitting(false);
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleBreak = async () => {
setSubmitting(true);
try {
const result = await punchMutation.mutateAsync({
punch_action: "break_start",
});
alert.success(result?.message || "Přestávka zaznamenána");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSubmitting(false);
}
};
const handleSaveNotes = async () => {
try {
await notesMutation.mutateAsync({ notes });
alert.success("Poznámka byla uložena");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
}
};
const handleSwitchProject = async (newProjectId: string | null) => {
setSwitchingProject(true);
try {
const result = await switchProjectMutation.mutateAsync({
project_id: newProjectId ? Number(newProjectId) : null,
});
alert.success(result?.message || "Projekt přepnut");
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setSwitchingProject(false);
}
};
// Parse a YYYY-MM-DD string into a LOCAL-midnight Date. `new Date("YYYY-MM-DD")`
// would parse as UTC midnight; reading it back with local getDay()/getDate()
// can land on the wrong calendar day in the evening Prague window.
const parseLocalDate = (s: string): Date | null => {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
if (!m) return null;
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
};
const calculateBusinessDays = (from: string, to: string) => {
if (!from || !to) return 0;
const start = parseLocalDate(from);
const end = parseLocalDate(to);
if (!start || !end) return 0;
if (end < start) return 0;
let days = 0;
const current = new Date(start);
while (current <= end) {
const day = current.getDay();
if (day !== 0 && day !== 6) days++;
current.setDate(current.getDate() + 1);
}
return days;
};
const handleRequestSubmit = async () => {
setRequestSubmitting(true);
try {
const result = await leaveRequestMutation.mutateAsync(leaveForm);
setIsLeaveModalOpen(false);
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(result?.message || "Žádost odeslána");
setLeaveForm({
leave_type: "vacation",
date_from: todayLocalStr(),
date_to: todayLocalStr(),
notes: "",
});
} catch (e) {
alert.error(e instanceof Error ? e.message : "Chyba připojení");
} finally {
setRequestSubmitting(false);
}
};
if (statusQuery.isPending) {
return <LoadingState />;
}
if (!statusQuery.data) {
return null;
}
const {
ongoing_shift: ongoingShift,
today_shifts: todayShifts,
leave_balance: leaveBalance,
} = statusQuery.data;
const data = statusQuery.data;
const projects = projectsQuery.data ?? [];
const projectLogs = data.project_logs ?? [];
const activeProjectId = data.active_project_id ?? null;
const isOngoingShift = ongoingShift && !ongoingShift.departure_time;
const completedToday = todayShifts.filter((s) => s.departure_time);
const vacationDaysRemaining = Math.floor(leaveBalance.vacation_remaining / 8);
const vacationHoursRemaining = leaveBalance.vacation_remaining % 8;
const businessDays = calculateBusinessDays(
leaveForm.date_from,
leaveForm.date_to,
);
const completedColumns: DataColumn<ShiftRecord>[] = [
{
key: "arrival",
header: "Příchod",
mono: true,
render: (shift) => formatTime(shift.arrival_time),
},
{
key: "break",
header: "Pauza",
mono: true,
render: (shift) =>
shift.break_start && shift.break_end
? `${formatTime(shift.break_start)} - ${formatTime(shift.break_end)}`
: "—",
},
{
key: "departure",
header: "Odchod",
mono: true,
render: (shift) => formatTime(shift.departure_time),
},
{
key: "worked",
header: "Odpracováno",
mono: true,
render: (shift) =>
formatMinutes(
calculateWorkMinutes({
...shift,
notes: shift.notes ?? undefined,
}),
true,
),
},
...(projects.length > 0
? [
{
key: "projects",
header: "Projekty",
render: (shift: ShiftRecord) => {
const shiftLogs = shift.project_logs || [];
if (shiftLogs.length === 0) return "—";
return (
<Box
sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}
>
{shiftLogs.map((log, i) => {
if (!log.started_at) return null;
const mins = log.ended_at
? Math.floor(
(new Date(log.ended_at).getTime() -
new Date(log.started_at).getTime()) /
60000,
)
: 0;
const h = Math.floor(mins / 60);
const mm = mins % 60;
return (
<Typography
key={log.id || i}
variant="caption"
sx={{ fontSize: "12px" }}
>
{log.project_name || `#${log.project_id}`} ({h}:
{String(mm).padStart(2, "0")}h)
</Typography>
);
})}
</Box>
);
},
} as DataColumn<ShiftRecord>,
]
: []),
];
return (
<PageEnter>
<Box sx={{ mb: 3 }}>
<Typography variant="h4">Docházka</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{new Date().toLocaleDateString("cs-CZ", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})}
</Typography>
</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "minmax(0, 1fr)",
lg: "minmax(0, 1fr) 340px",
},
gap: 3,
alignItems: "start",
}}
>
{/* Left Column - Clock In/Out */}
<Box sx={{ minWidth: 0 }}>
<Card>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 2.5,
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box
sx={{
width: 10,
height: 10,
borderRadius: "50%",
bgcolor: isOngoingShift ? "success.main" : "text.disabled",
boxShadow: isOngoingShift
? (theme) => `0 0 0 4px ${theme.palette.success.main}33`
: "none",
}}
/>
<Typography sx={{ fontWeight: 600, color: "text.secondary" }}>
{isOngoingShift ? "Pracuji" : "Nepracuji"}
</Typography>
</Box>
<Typography
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontSize: "2rem",
fontWeight: 700,
lineHeight: 1,
}}
>
{now.toLocaleTimeString("cs-CZ", {
hour: "2-digit",
minute: "2-digit",
})}
</Typography>
</Box>
{isOngoingShift ? (
<>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: 2,
mb: 2.5,
}}
>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{
display: "block",
textTransform: "uppercase",
letterSpacing: ".05em",
fontWeight: 600,
}}
>
Příchod
</Typography>
<Typography
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 600,
color: "success.main",
}}
>
{formatTime(ongoingShift.arrival_time)}
</Typography>
</Box>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{
display: "block",
textTransform: "uppercase",
letterSpacing: ".05em",
fontWeight: 600,
}}
>
Pauza
</Typography>
<Typography
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 600,
color: ongoingShift.break_start
? "success.main"
: "text.primary",
}}
>
{ongoingShift.break_start
? `${formatTime(ongoingShift.break_start)} - ${formatTime(ongoingShift.break_end)}`
: "—"}
</Typography>
</Box>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{
display: "block",
textTransform: "uppercase",
letterSpacing: ".05em",
fontWeight: 600,
}}
>
Odchod
</Typography>
<Typography
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontWeight: 600,
}}
>
</Typography>
</Box>
</Box>
{projects.length > 0 && (
<Box sx={{ mb: 2.5 }}>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
mb: 1,
}}
>
<Typography
variant="caption"
color="text.secondary"
sx={{
textTransform: "uppercase",
letterSpacing: ".05em",
fontWeight: 600,
}}
>
Projekt
</Typography>
{activeProjectId ? (
<StatusChip
color="info"
label={
projects.find(
(p) => String(p.id) === String(activeProjectId),
)
? `${projects.find((p) => String(p.id) === String(activeProjectId))!.project_number} ${projects.find((p) => String(p.id) === String(activeProjectId))!.name}`
: `Projekt #${activeProjectId}`
}
sx={{
height: "auto",
"& .MuiChip-label": {
whiteSpace: "normal",
py: 0.25,
},
}}
/>
) : (
<Typography variant="caption" color="text.disabled">
Žádný
</Typography>
)}
</Box>
<Select
value={activeProjectId ? String(activeProjectId) : ""}
onChange={(val) => handleSwitchProject(val || null)}
disabled={switchingProject}
options={[
{ value: "", label: "— Bez projektu —" },
...projects.map((p) => ({
value: String(p.id),
label: `${p.project_number} ${p.name}`,
})),
]}
/>
{projectLogs.length > 0 && (
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 0.75,
mt: 1.5,
}}
>
{projectLogs.map((log, i) => {
if (!log.started_at) return null;
const start = new Date(log.started_at);
const end = log.ended_at
? new Date(log.ended_at)
: new Date();
const mins = Math.max(
0,
Math.floor(
(end.getTime() - start.getTime()) / 60000,
),
);
const h = Math.floor(mins / 60);
const mm = mins % 60;
return (
<Box
key={log.id || i}
sx={{
display: "flex",
alignItems: "center",
gap: 1,
justifyContent: "space-between",
}}
>
<Typography
variant="body2"
sx={{ fontWeight: 600, flex: 1, minWidth: 0 }}
noWrap
>
{log.project_name ||
`Projekt #${log.project_id}`}
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
}}
>
{formatTime(log.started_at)} {" "}
{log.ended_at
? formatTime(log.ended_at)
: "nyní"}
</Typography>
<StatusChip
color="default"
label={`${h}:${String(mm).padStart(2, "0")} h`}
/>
</Box>
);
})}
</Box>
)}
</Box>
)}
<Box
sx={{
display: "flex",
flexDirection: "column",
gap: 1.25,
}}
>
{!ongoingShift.break_start && (
<Button
onClick={handleBreak}
disabled={submitting}
variant="outlined"
color="inherit"
fullWidth
>
Pauza (30 min)
</Button>
)}
<Button
onClick={() => handlePunch("departure")}
disabled={submitting}
fullWidth
>
{submitting ? "Zpracovávám..." : "Odchod"}
</Button>
<Button
onClick={() => setIsLeaveModalOpen(true)}
variant="outlined"
color="inherit"
fullWidth
>
Žádost o nepřítomnost
</Button>
</Box>
<Box sx={{ mt: 2.5 }}>
<Field label="Poznámka ke směně">
<TextField
multiline
minRows={3}
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Co jste dělali během směny..."
/>
</Field>
<Box>
<Button
onClick={handleSaveNotes}
variant="outlined"
color="inherit"
size="small"
>
Uložit poznámku
</Button>
</Box>
</Box>
</>
) : (
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
<Button
onClick={() => handlePunch("arrival")}
disabled={submitting}
fullWidth
>
{submitting ? "Zpracovávám..." : "Příchod"}
</Button>
<Button
onClick={() => setIsLeaveModalOpen(true)}
variant="outlined"
color="inherit"
fullWidth
>
Žádost o nepřítomnost
</Button>
</Box>
)}
</Card>
{/* Completed Today */}
{completedToday.length > 0 && (
<Card sx={{ mt: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
Dnešní dokončené směny
</Typography>
<DataTable<ShiftRecord>
columns={completedColumns}
rows={completedToday}
rowKey={(shift) => shift.id}
empty={<EmptyState title="Žádné dokončené směny." />}
/>
</Card>
)}
</Box>
{/* Right Column - Stats & Quick Links */}
<Box
sx={{ display: "flex", flexDirection: "column", gap: 3, minWidth: 0 }}
>
{/* Leave Balance Card */}
<StatCard
icon={VacationIcon}
color="success"
label={`Dovolená ${new Date().getFullYear()}`}
value={
<>
{vacationDaysRemaining}{" "}
<Typography
component="span"
variant="body2"
color="text.secondary"
sx={{ fontFamily: "inherit" }}
>
{czechPlural(vacationDaysRemaining, "den", "dny", "dnů")}
{vacationHoursRemaining > 0 && ` ${vacationHoursRemaining}h`}
</Typography>
</>
}
footer={
<Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
mb: 0.75,
}}
>
<span>Celkem: {leaveBalance.vacation_total}h</span>
<span>Čerpáno: {leaveBalance.vacation_used}h</span>
</Box>
<ProgressBar
value={
leaveBalance.vacation_total > 0
? (leaveBalance.vacation_remaining /
leaveBalance.vacation_total) *
100
: 0
}
color="success"
height={6}
/>
</Box>
}
/>
{/* Monthly Fund Card */}
{data.monthly_fund && (
<StatCard
icon={CalendarIcon}
color="info"
label={data.monthly_fund.month_name}
value={`${data.monthly_fund.worked}h / ${data.monthly_fund.fund}h`}
footer={
<Box>
<Box
sx={{
display: "flex",
justifyContent: "space-between",
mb: 0.75,
}}
>
<span>Odpracováno: {data.monthly_fund.worked}h</span>
{data.monthly_fund.overtime > 0 ? (
<Box
component="span"
sx={{ color: "warning.main", fontWeight: 600 }}
>
Přesčas: +{data.monthly_fund.overtime}h
</Box>
) : (
<span>Zbývá: {data.monthly_fund.remaining}h</span>
)}
</Box>
<ProgressBar
value={
data.monthly_fund.fund > 0
? Math.min(
100,
(data.monthly_fund.covered /
data.monthly_fund.fund) *
100,
)
: 0
}
color={getFundBarColor(data.monthly_fund)}
height={6}
/>
{data.monthly_fund.leave_hours > 0 && (
<Box sx={{ mt: 0.5, color: "text.disabled" }}>
{"Pokryto: "}
{data.monthly_fund.covered}h (práce{" "}
{data.monthly_fund.worked}h
{data.monthly_fund.vacation_hours > 0 &&
` + dovolená ${data.monthly_fund.vacation_hours}h`}
{data.monthly_fund.sick_hours > 0 &&
` + nemoc ${data.monthly_fund.sick_hours}h`}
{data.monthly_fund.unpaid_hours > 0 &&
` + neplacené ${data.monthly_fund.unpaid_hours}h`}
)
</Box>
)}
</Box>
}
/>
)}
{/* Sick Leave Card */}
<StatCard
icon={SickIcon}
color="error"
label={`Nemoc ${new Date().getFullYear()}`}
value={`${leaveBalance.sick_used}h čerpáno`}
/>
{/* Quick Links */}
<Card>
<Typography
variant="caption"
color="text.secondary"
sx={{
display: "block",
textTransform: "uppercase",
letterSpacing: ".06em",
fontWeight: 700,
mb: 1,
}}
>
Rychlé odkazy
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
<QuickLink
to="/attendance/requests"
icon={RequestsIcon}
label="Moje žádosti"
/>
<QuickLink
to="/attendance/history"
icon={HistoryIcon}
label="Historie docházky"
/>
{hasPermission("attendance.manage") && (
<QuickLink
to="/attendance/admin"
icon={ManageIcon}
label="Správa docházky"
/>
)}
{hasPermission("attendance.balances") && (
<QuickLink
to="/attendance/balances"
icon={BalancesIcon}
label="Správa bilancí"
/>
)}
</Box>
</Card>
</Box>
</Box>
{/* Leave Modal */}
<Modal
isOpen={isLeaveModalOpen}
onClose={() => setIsLeaveModalOpen(false)}
title="Žádost o nepřítomnost"
onSubmit={() => {
// Preserve original guard: do not submit when there are 0 business days.
if (businessDays === 0) return;
handleRequestSubmit();
}}
submitText="Odeslat žádost"
submitDisabled={businessDays === 0}
loading={requestSubmitting}
>
<Field label="Typ nepřítomnosti">
<Select
value={leaveForm.leave_type}
onChange={(val) => setLeaveForm({ ...leaveForm, leave_type: val })}
options={[
{ value: "vacation", label: "Dovolená" },
{ value: "sick", label: "Nemoc" },
{ value: "unpaid", label: "Neplacené volno" },
]}
/>
</Field>
<Box
sx={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) minmax(0, 1fr)",
gap: 2,
}}
>
<Field label="Od">
<DateField
value={leaveForm.date_from}
onChange={(val) => {
setLeaveForm((prev) => ({
...prev,
date_from: val,
date_to: prev.date_to < val ? val : prev.date_to,
}));
}}
/>
</Field>
<Field label="Do">
<DateField
value={leaveForm.date_to}
minDate={
leaveForm.date_from && isValid(parseISO(leaveForm.date_from))
? parseISO(leaveForm.date_from)
: undefined
}
onChange={(val) => setLeaveForm({ ...leaveForm, date_to: val })}
/>
</Field>
</Box>
{leaveForm.date_from && leaveForm.date_to && (
<Box
sx={{
display: "flex",
gap: 3,
alignItems: "center",
px: 2,
py: 1.5,
mb: 2,
bgcolor: "action.hover",
borderRadius: 2,
fontSize: "0.875rem",
}}
>
<Box component="span">
<strong>{businessDays}</strong>{" "}
{czechPlural(
businessDays,
"pracovní den",
"pracovní dny",
"pracovních dnů",
)}
</Box>
<Box component="span" sx={{ color: "text.secondary" }}>
{businessDays * 8} hodin
</Box>
</Box>
)}
<Field label="Poznámka">
<TextField
multiline
minRows={2}
value={leaveForm.notes}
onChange={(e) =>
setLeaveForm({ ...leaveForm, notes: e.target.value })
}
placeholder="Volitelná poznámka..."
/>
</Field>
</Modal>
<ConfirmDialog
isOpen={gpsConfirm.isOpen}
onClose={() => {
setGpsConfirm({ isOpen: false, action: null });
setSubmitting(false);
}}
onConfirm={() => {
setGpsConfirm({ isOpen: false, action: null });
submitPunch(gpsConfirm.action!, {});
}}
title="GPS nedostupná"
message="Nepodařilo se získat polohu. Chcete pokračovat bez GPS?"
confirmText="Pokračovat"
cancelText="Zrušit"
confirmVariant="primary"
/>
</PageEnter>
);
}