Files
app/src/admin/pages/AttendanceLocation.tsx
BOHA 519edce373 fix: 2026-06-09 full-codebase audit hardening
Validation: shared NaN-guarded Zod coercion helpers in schemas/common.ts replace
the raw number|string transform idiom across every schema (the root-cause NaN bug
class); emailOrEmpty + lenient isoDateString/timeString.

Security: roles privilege-escalation closed; refresh-token family revocation on
reuse; TOTP uses config params; read endpoints permission-guarded; received-invoices
gross VAT on all paths; orders-pdf custom-items authz.

Concurrency: $queryRaw SELECT...FOR UPDATE locks in ascending-id order (warehouse
confirm/cancel, attendance lockUserRow); uniqueness checks moved into create
transactions (TOCTOU -> 409); deterministic id tiebreak on second-precision
timestamp ordering (plan resolveCell/resolveGrid, warehouse FIFO).

Frontend: Rules-of-Hooks fixed across ~14 pages + PlanCellModal; UTC-date persisted
fields; dashboard invalidation gaps; stale-closure confirm bugs.

Tooling/tests: ESLint flat config (react-hooks/rules-of-hooks = error) + Prettier;
tsconfig.test.json so tsc -b type-checks the tests; removed 3 dead deps; npm audit
fix (8 -> 3). Suite 195 -> 247 (happy-path auth, FIFO oldest-first, flakiness fixes),
isolated on app_test via .env.test with a hard-throw setup guard.

Gates: tsc 0 | build 0 | vitest 247/247 | eslint 0 errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 06:45:26 +02:00

364 lines
11 KiB
TypeScript

import { useEffect, useRef } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { useNavigate, useParams, Link as RouterLink } from "react-router-dom";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { styled } from "@mui/material/styles";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
// Leaflet map container (was the .attendance-location-map rule). zIndex:0
// creates a stacking context so Leaflet's internal z-indexes stay contained.
const LocationMap = styled("div")(({ theme }) => ({
height: "400px",
borderRadius: "8px",
marginBottom: "1.5rem",
background: theme.vars!.palette.background.paper,
position: "relative",
zIndex: 0,
"@media (max-width:640px)": {
height: "clamp(220px, 45vh, 400px)",
},
}));
import { formatDate, formatTime } from "../utils/attendanceHelpers";
import {
attendanceLocationOptions,
type LocationRecord,
} from "../lib/queries/attendance";
import { Button, Card, LoadingState, PageEnter, PageHeader } from "../ui";
const BackIcon = (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
);
export default function AttendanceLocation() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<unknown>(null);
const locationQuery = useQuery(attendanceLocationOptions(id));
const record = locationQuery.data ?? null;
const isPending = locationQuery.isPending;
// Navigate away on fetch error
useEffect(() => {
if (locationQuery.error) {
alert.error("Nepodařilo se načíst data");
navigate("/attendance/admin");
}
}, [locationQuery.error]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
if (!record || isPending) return;
const hasArrivalLocation = record.arrival_lat && record.arrival_lng;
const hasDepartureLocation = record.departure_lat && record.departure_lng;
const hasAnyLocation = hasArrivalLocation || hasDepartureLocation;
if (!hasAnyLocation || !mapRef.current) return;
const initMap = () => {
if (mapInstanceRef.current) {
(mapInstanceRef.current as { remove: () => void }).remove();
}
const map = L.map(mapRef.current!);
mapInstanceRef.current = map;
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "&copy; OpenStreetMap contributors",
}).addTo(map);
const bounds: [number, number][] = [];
interface LocationPoint {
lat: number;
lng: number;
type: string;
label: string;
time: string;
accuracy: number;
}
const locations: LocationPoint[] = [];
if (hasArrivalLocation) {
locations.push({
lat: parseFloat(String(record.arrival_lat)),
lng: parseFloat(String(record.arrival_lng)),
type: "arrival",
label: "Příchod",
time: formatTime(record.arrival_time),
accuracy: Number(record.arrival_accuracy) || 0,
});
}
if (hasDepartureLocation) {
locations.push({
lat: parseFloat(String(record.departure_lat)),
lng: parseFloat(String(record.departure_lng)),
type: "departure",
label: "Odchod",
time: formatTime(record.departure_time),
accuracy: Number(record.departure_accuracy) || 0,
});
}
locations.forEach((loc) => {
const color = loc.type === "arrival" ? "#22c55e" : "#ef4444";
const marker = L.circleMarker([loc.lat, loc.lng], {
radius: 10,
fillColor: color,
color: "#fff",
weight: 2,
opacity: 1,
fillOpacity: 0.8,
}).addTo(map);
const popupEl = document.createElement("div");
const strong = document.createElement("strong");
strong.textContent = loc.label;
popupEl.appendChild(strong);
popupEl.appendChild(document.createElement("br"));
popupEl.appendChild(document.createTextNode(loc.time));
popupEl.appendChild(document.createElement("br"));
popupEl.appendChild(
document.createTextNode(`Přesnost: ${Math.round(loc.accuracy)}m`),
);
marker.bindPopup(popupEl);
if (loc.accuracy > 0) {
L.circle([loc.lat, loc.lng], {
radius: loc.accuracy,
fillColor: color,
color: color,
weight: 1,
opacity: 0.3,
fillOpacity: 0.1,
}).addTo(map);
}
bounds.push([loc.lat, loc.lng]);
});
if (bounds.length === 1) {
map.setView(bounds[0], 16);
} else if (bounds.length > 1) {
map.fitBounds(bounds, { padding: [50, 50] });
}
};
initMap();
return () => {
if (mapInstanceRef.current) {
(mapInstanceRef.current as { remove: () => void }).remove();
mapInstanceRef.current = null;
}
};
}, [record, isPending]);
const formatDatetimeLocal = (datetime: string | null | undefined): string => {
if (!datetime) return "—";
const d = new Date(datetime);
return `${d.getDate()}.${d.getMonth() + 1}.${d.getFullYear()} ${formatTime(datetime)}`;
};
if (!hasPermission("attendance.manage")) return <Forbidden />;
// Show the spinner while the record is still loading — this must precede the
// `!record` null-return below, otherwise a pending query (record === undefined)
// renders a blank page and this branch is dead.
if (isPending) {
return <LoadingState />;
}
if (!record) {
return null;
}
const hasArrivalLocation = record.arrival_lat && record.arrival_lng;
const hasDepartureLocation = record.departure_lat && record.departure_lng;
const hasAnyLocation = hasArrivalLocation || hasDepartureLocation;
const shiftDateStr = record.shift_date.includes("T")
? record.shift_date.split("T")[0]
: record.shift_date;
const month = shiftDateStr.substring(0, 7);
return (
<PageEnter>
{/* Header */}
<PageHeader
title="Poloha záznamu"
actions={
<Button
component={RouterLink}
to={`/attendance/admin?month=${month}`}
variant="outlined"
color="inherit"
startIcon={BackIcon}
>
Zpět na správu
</Button>
}
/>
{/* Info + map card */}
<Card sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 2, fontWeight: 600 }}>
{record.user_name} {formatDate(record.shift_date)}
</Typography>
{/* Leaflet map */}
{hasAnyLocation && <LocationMap ref={mapRef} />}
{/* Location detail grid */}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr" },
gap: 2,
mt: hasAnyLocation ? 2 : 0,
}}
>
{/* Arrival */}
<Box
sx={{
p: 2,
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
opacity: hasArrivalLocation ? 1 : 0.6,
}}
>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
Příchod
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace", mb: 1 }}
>
{record.arrival_time
? formatDatetimeLocal(record.arrival_time)
: "—"}
</Typography>
{hasArrivalLocation ? (
<>
<Typography variant="body2" sx={{ mb: 0.5 }}>
{record.arrival_address || <em>Adresa nezjištěna</em>}
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontSize: "0.75rem",
color: "text.secondary",
mb: 1,
}}
>
GPS: {record.arrival_lat}, {record.arrival_lng}
{record.arrival_accuracy &&
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
</Typography>
<Button
component="a"
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
target="_blank"
rel="noopener noreferrer"
variant="outlined"
color="inherit"
size="small"
>
Otevřít v Google Maps
</Button>
</>
) : (
<Typography variant="body2" color="text.secondary">
<em>Poloha nebyla zaznamenána</em>
</Typography>
)}
</Box>
{/* Departure */}
{(hasDepartureLocation || record.departure_time) && (
<Box
sx={{
p: 2,
borderRadius: 1,
border: "1px solid",
borderColor: "divider",
opacity: hasDepartureLocation ? 1 : 0.6,
}}
>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
Odchod
</Typography>
<Typography
variant="body2"
sx={{ fontFamily: "'DM Mono', Menlo, monospace", mb: 1 }}
>
{record.departure_time
? formatDatetimeLocal(record.departure_time)
: "—"}
</Typography>
{hasDepartureLocation ? (
<>
<Typography variant="body2" sx={{ mb: 0.5 }}>
{record.departure_address || <em>Adresa nezjištěna</em>}
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "'DM Mono', Menlo, monospace",
fontSize: "0.75rem",
color: "text.secondary",
mb: 1,
}}
>
GPS: {record.departure_lat}, {record.departure_lng}
{record.departure_accuracy &&
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
</Typography>
<Button
component="a"
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
target="_blank"
rel="noopener noreferrer"
variant="outlined"
color="inherit"
size="small"
>
Otevřít v Google Maps
</Button>
</>
) : (
<Typography variant="body2" color="text.secondary">
<em>Poloha nebyla zaznamenána</em>
</Typography>
)}
</Box>
)}
</Box>
</Card>
</PageEnter>
);
}