Files
app/src/admin/pages/Attendance.tsx
2026-06-07 01:02:26 +02:00

1279 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 { motion } from "framer-motion";
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,
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",
invalidate: ["attendance"],
});
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",
invalidate: ["attendance", "leave-requests", "users"],
});
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);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (geoAbortRef.current) geoAbortRef.current.abort();
if (punchTimeoutRef.current) clearTimeout(punchTimeoutRef.current);
};
}, []);
// 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);
}
};
const calculateBusinessDays = (from: string, to: string) => {
if (!from || !to) return 0;
const start = new Date(from);
const end = new Date(to);
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 (
<Box>
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
>
<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>
</motion.div>
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", lg: "1fr 340px" },
gap: 3,
alignItems: "start",
}}
>
{/* Left Column - Clock In/Out */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<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,
}}
>
{new Date().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>
)}
</motion.div>
{/* Right Column - Stats & Quick Links */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<Box sx={{ display: "flex", flexDirection: "column", gap: 3 }}>
{/* 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>
</motion.div>
</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: "1fr 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"
/>
</Box>
);
}