- Change attendance idx_attendance_user_date from unique to index (allow multiple shifts per day) - Reset migrations to single baseline init migration - Add seed script with admin user (admin/admin) - Update CLAUDE.md with migration workflow documentation - Various frontend fixes (queries, pages, hooks) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
294 lines
10 KiB
TypeScript
294 lines
10 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 } from "react-router-dom";
|
|
import { motion } from "framer-motion";
|
|
|
|
import L from "leaflet";
|
|
import "leaflet/dist/leaflet.css";
|
|
|
|
import { formatDate, formatTime } from "../utils/attendanceHelpers";
|
|
import {
|
|
attendanceLocationOptions,
|
|
type LocationRecord,
|
|
} from "../lib/queries/attendance";
|
|
import { Skeleton } from "boneyard-js/react";
|
|
import AttendanceLocationFixture from "../fixtures/AttendanceLocationFixture";
|
|
|
|
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);
|
|
|
|
return (
|
|
<Skeleton
|
|
name="attendance-location"
|
|
loading={isPending}
|
|
fixture={<AttendanceLocationFixture />}
|
|
>
|
|
<div>
|
|
<motion.div
|
|
className="admin-page-header"
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25 }}
|
|
>
|
|
<div>
|
|
<h1 className="admin-page-title">Poloha záznamu</h1>
|
|
</div>
|
|
<div className="admin-page-actions">
|
|
<Link
|
|
to={`/attendance/admin?month=${month}`}
|
|
className="admin-btn admin-btn-secondary"
|
|
>
|
|
← Zpět na správu
|
|
</Link>
|
|
</div>
|
|
</motion.div>
|
|
|
|
<motion.div
|
|
className="admin-card"
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, delay: 0.06 }}
|
|
>
|
|
<div className="admin-card-header">
|
|
<h2 className="admin-card-title">
|
|
{record.user_name} — {formatDate(record.shift_date)}
|
|
</h2>
|
|
</div>
|
|
<div className="admin-card-body">
|
|
{hasAnyLocation && (
|
|
<div ref={mapRef} className="attendance-location-map" />
|
|
)}
|
|
|
|
<div className="attendance-location-grid">
|
|
{/* Arrival */}
|
|
<div
|
|
className={`attendance-location-card ${!hasArrivalLocation ? "empty" : ""}`}
|
|
>
|
|
<h3 className="attendance-location-title">Příchod</h3>
|
|
<div className="attendance-location-time">
|
|
{record.arrival_time
|
|
? formatDatetimeLocal(record.arrival_time)
|
|
: "—"}
|
|
</div>
|
|
{hasArrivalLocation ? (
|
|
<>
|
|
<div className="attendance-location-address">
|
|
{record.arrival_address || <em>Adresa nezjištěna</em>}
|
|
</div>
|
|
<div className="attendance-location-coords">
|
|
GPS: {record.arrival_lat}, {record.arrival_lng}
|
|
{record.arrival_accuracy &&
|
|
` (přesnost: ${Math.round(Number(record.arrival_accuracy))}m)`}
|
|
</div>
|
|
<a
|
|
href={`https://www.google.com/maps?q=${record.arrival_lat},${record.arrival_lng}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
|
|
>
|
|
Otevřít v Google Maps
|
|
</a>
|
|
</>
|
|
) : (
|
|
<div className="attendance-location-address">
|
|
<em>Poloha nebyla zaznamenána</em>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Departure */}
|
|
{(hasDepartureLocation || record.departure_time) && (
|
|
<div
|
|
className={`attendance-location-card ${!hasDepartureLocation ? "empty" : ""}`}
|
|
>
|
|
<h3 className="attendance-location-title">Odchod</h3>
|
|
<div className="attendance-location-time">
|
|
{record.departure_time
|
|
? formatDatetimeLocal(record.departure_time)
|
|
: "—"}
|
|
</div>
|
|
{hasDepartureLocation ? (
|
|
<>
|
|
<div className="attendance-location-address">
|
|
{record.departure_address || <em>Adresa nezjištěna</em>}
|
|
</div>
|
|
<div className="attendance-location-coords">
|
|
GPS: {record.departure_lat}, {record.departure_lng}
|
|
{record.departure_accuracy &&
|
|
` (přesnost: ${Math.round(Number(record.departure_accuracy))}m)`}
|
|
</div>
|
|
<a
|
|
href={`https://www.google.com/maps?q=${record.departure_lat},${record.departure_lng}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="admin-btn admin-btn-secondary admin-btn-sm mt-2"
|
|
>
|
|
Otevřít v Google Maps
|
|
</a>
|
|
</>
|
|
) : (
|
|
<div className="attendance-location-address">
|
|
<em>Poloha nebyla zaznamenána</em>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
</Skeleton>
|
|
);
|
|
}
|