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>
1288 lines
40 KiB
TypeScript
1288 lines
40 KiB
TypeScript
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>
|
||
);
|
||
}
|