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 = (
);
const VacationIcon = (
);
const SickIcon = (
);
const RequestsIcon = (
);
const HistoryIcon = (
);
const ManageIcon = (
);
const BalancesIcon = (
);
const ChevronIcon = (
);
/** 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 (
);
}
export default function Attendance() {
const alert = useAlert();
const { hasPermission } = useAuth();
const statusQuery = useQuery({
queryKey: ["attendance", "status"],
queryFn: () => jsonQuery("/api/admin/attendance/status"),
});
const projectsQuery = useQuery({
queryKey: ["attendance", "projects"],
queryFn: () =>
jsonQuery("/api/admin/attendance?action=projects"),
});
const punchMutation = useApiMutation<
Record,
{ 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(null);
const punchTimeoutRef = useRef | null>(null);
const mountedRef = useRef(true);
const latestActionRef = useRef(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 ;
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 = {},
) => {
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 ;
}
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[] = [
{
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 (
{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 (
{log.project_name || `#${log.project_id}`} ({h}:
{String(mm).padStart(2, "0")}h)
);
})}
);
},
} as DataColumn,
]
: []),
];
return (
Docházka
{new Date().toLocaleDateString("cs-CZ", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})}
{/* Left Column - Clock In/Out */}
`0 0 0 4px ${theme.palette.success.main}33`
: "none",
}}
/>
{isOngoingShift ? "Pracuji" : "Nepracuji"}
{now.toLocaleTimeString("cs-CZ", {
hour: "2-digit",
minute: "2-digit",
})}
{isOngoingShift ? (
<>
Příchod
{formatTime(ongoingShift.arrival_time)}
Pauza
{ongoingShift.break_start
? `${formatTime(ongoingShift.break_start)} - ${formatTime(ongoingShift.break_end)}`
: "—"}
Odchod
—
{projects.length > 0 && (
Projekt
{activeProjectId ? (
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,
},
}}
/>
) : (
Žádný
)}
)}
{!ongoingShift.break_start && (
)}
setNotes(e.target.value)}
placeholder="Co jste dělali během směny..."
/>
>
) : (
)}
{/* Completed Today */}
{completedToday.length > 0 && (
Dnešní dokončené směny
columns={completedColumns}
rows={completedToday}
rowKey={(shift) => shift.id}
empty={}
/>
)}
{/* Right Column - Stats & Quick Links */}
{/* Leave Balance Card */}
{vacationDaysRemaining}{" "}
{czechPlural(vacationDaysRemaining, "den", "dny", "dnů")}
{vacationHoursRemaining > 0 && ` ${vacationHoursRemaining}h`}
>
}
footer={
Celkem: {leaveBalance.vacation_total}h
Čerpáno: {leaveBalance.vacation_used}h
0
? (leaveBalance.vacation_remaining /
leaveBalance.vacation_total) *
100
: 0
}
color="success"
height={6}
/>
}
/>
{/* Monthly Fund Card */}
{data.monthly_fund && (
Odpracováno: {data.monthly_fund.worked}h
{data.monthly_fund.overtime > 0 ? (
Přesčas: +{data.monthly_fund.overtime}h
) : (
Zbývá: {data.monthly_fund.remaining}h
)}
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 && (
{"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`}
)
)}
}
/>
)}
{/* Sick Leave Card */}
{/* Quick Links */}
Rychlé odkazy
{hasPermission("attendance.manage") && (
)}
{hasPermission("attendance.balances") && (
)}
{/* Leave Modal */}
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}
>
{
setLeaveForm((prev) => ({
...prev,
date_from: val,
date_to: prev.date_to < val ? val : prev.date_to,
}));
}}
/>
setLeaveForm({ ...leaveForm, date_to: val })}
/>
{leaveForm.date_from && leaveForm.date_to && (
{businessDays}{" "}
{czechPlural(
businessDays,
"pracovní den",
"pracovní dny",
"pracovních dnů",
)}
{businessDays * 8} hodin
)}
setLeaveForm({ ...leaveForm, notes: e.target.value })
}
placeholder="Volitelná poznámka..."
/>
{
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"
/>
);
}