Files
app/src/admin/pages/AttendanceLocation.tsx
BOHA 2a228ea659 refactor(css): migrate base.css to MUI GlobalStyles and Leaflet to styled()
Phase 2 of the "fully MUI" cleanup — eliminates two more stylesheets.

- base.css -> src/admin/GlobalStyles.tsx: the reset, typography,
  scrollbar/::selection, theme cross-fade timing and all utility
  classes now live in a single theme-aware MUI <GlobalStyles>, rendered
  inside MuiProvider so every rule resolves against theme.vars (verified
  reactive in both schemes). base.css deleted.
- attendance.css -> a styled("div") LocationMap in AttendanceLocation;
  attendance.css deleted.
- App.tsx bootstrap loader is now fully self-contained (inline spinner
  + keyframes, theme-aware background read from the data-theme attr),
  since it renders before MuiProvider/GlobalStyles mount — it no longer
  depends on the removed .admin-spinner / var(--bg-primary).

tsc -b --noEmit and npm run build clean; verified in Chrome (light +
dark): body bg, text, fonts, .text-warning/.link-accent all correct.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 17:29:16 +02:00

361 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 />;
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);
if (isPending) {
return <LoadingState />;
}
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>
);
}