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>
361 lines
11 KiB
TypeScript
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: "© 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>
|
|
);
|
|
}
|