Files
app/src/admin/pages/AttendanceLocation.tsx
BOHA 6b31b2f74b feat: system settings, dynamic logos, template numbering, permission consolidation
- System settings page with tabs: Security, System, Firma
- Configurable attendance rules (break thresholds, rounding) from DB
- Configurable document numbering with template patterns ({YYYY}/{PREFIX}/{NNN})
- Dynamic logo upload (light/dark variants) served from DB instead of static files
- Email settings (SMTP from/name, alert/leave emails) configurable in UI
- Currency and VAT rate lists configurable, used across all modules
- Permissions simplified: offers.settings + settings.roles + settings.security → settings.manage
- Leaflet bundled locally, removed unpkg.com from CSP
- Silent catch blocks fixed with proper logging
- console.log replaced with app.log.info in server.ts
- Schema renamed: company-settings.schema → settings.schema
- App info section: version, Node.js, uptime, memory, DB status, NAS status

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 10:15:47 +01:00

359 lines
12 KiB
TypeScript

import { useState, useEffect, useRef } from "react";
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 apiFetch from "../utils/api";
const API_BASE = "/api/admin";
interface LocationRecord {
user_name: string;
shift_date: string;
arrival_time?: string | null;
departure_time?: string | null;
arrival_lat?: string | number | null;
arrival_lng?: string | number | null;
arrival_accuracy?: number | null;
arrival_address?: string | null;
departure_lat?: string | number | null;
departure_lng?: string | number | null;
departure_accuracy?: number | null;
departure_address?: string | null;
}
export default function AttendanceLocation() {
const alert = useAlert();
const { hasPermission } = useAuth();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>();
const [loading, setLoading] = useState(true);
const [record, setRecord] = useState<LocationRecord | null>(null);
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<unknown>(null);
useEffect(() => {
const fetchData = async () => {
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=location&id=${id}`,
);
const result = await response.json();
if (result.success) {
const raw = result.data.record || result.data;
// Enrich with user_name from nested users relation
const userName = raw.users
? `${raw.users.first_name} ${raw.users.last_name}`.trim()
: raw.user_name || "";
setRecord({ ...raw, user_name: userName });
} else {
alert.error("Záznam nebyl nalezen");
navigate("/attendance/admin");
}
} catch {
alert.error("Nepodařilo se načíst data");
navigate("/attendance/admin");
} finally {
setLoading(false);
}
};
fetchData();
}, [id, alert, navigate]);
useEffect(() => {
if (!record || loading) 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);
marker.bindPopup(
`<strong>${loc.label}</strong><br>${loc.time}<br>Přesnost: ${Math.round(loc.accuracy)}m`,
);
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, loading]);
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.admin")) return <Forbidden />;
if (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div
style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}
>
<div
className="admin-skeleton-line"
style={{ width: "32px", height: "32px", borderRadius: "8px" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px" }}
/>
</div>
</div>
<div className="admin-card">
<div
className="admin-skeleton-line"
style={{ width: "100%", height: "300px", borderRadius: "8px" }}
/>
</div>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1.25rem",
}}
>
{[0, 1].map((i) => (
<div key={i} className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line h-8"
style={{ width: "50%" }}
/>
<div className="admin-skeleton-line w-full" />
<div className="admin-skeleton-line w-3/4" />
</div>
</div>
))}
</div>
</div>
);
}
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 (
<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"
>
&larr; 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>
);
}