Files
app/src/admin/pages/Attendance.tsx
BOHA ba95723b61 v1.5.6: boneyard-js skeleton migration, TanStack Query refactor, rate-limit config
- Replace hand-coded skeleton CSS/JSX with boneyard-js auto-generated bones
- Remove skeleton.css and @keyframes shimmer from base.css
- Add <Skeleton> wrappers with fixtures to all 25+ page components
- Generate 20 bone captures via boneyard CLI (CDP auth-gated capture)
- Refactor data fetching from useEffect+useState to TanStack Query
- Extract query hooks into src/admin/lib/queries/ and apiAdapter
- Add usePaginatedQuery hook replacing useApiCall/useListData
- Fix parseFloat || 0 anti-pattern in OfferDetail and OffersTemplates inputs
- Fix customer_id mandatory validation on offer creation
- Fix leave-requests comma-separated status filter (Prisma enum in: [])
- Add cross-entity cache invalidation for orders/offers/invoices/projects
- Make rate limits configurable via env vars (RATE_LIMIT_MAX, RATE_LIMIT_REFRESH, etc.)
- Add boneyard.config.json with routes and breakpoints

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 22:35:43 +02:00

1148 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAlert } from "../context/AlertContext";
import { useAuth } from "../context/AuthContext";
import { Link } from "react-router-dom";
import { motion, AnimatePresence } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import ConfirmModal from "../components/ConfirmModal";
import useModalLock from "../hooks/useModalLock";
import {
formatTime,
calculateWorkMinutes,
formatMinutes,
} from "../utils/attendanceHelpers";
import FormField from "../components/FormField";
import Forbidden from "../components/Forbidden";
import apiFetch from "../utils/api";
import { jsonQuery } from "../lib/apiAdapter";
import { Skeleton } from "boneyard-js/react";
import AttendanceFixture from "../fixtures/AttendanceFixture";
const API_BASE = "/api/admin";
interface ShiftRecord {
id: number;
user_id: number;
shift_date: string;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
notes?: string | null;
project_id?: number | null;
project_logs?: ProjectLog[];
}
interface ProjectLog {
id?: number;
project_id?: number;
project_name?: string;
started_at?: string;
ended_at?: string | null;
}
interface Project {
id: number;
name: string;
project_number: string;
}
interface LeaveBalance {
vacation_total: number;
vacation_used: number;
vacation_remaining: number;
sick_used: number;
}
interface MonthlyFund {
month_name: string;
fund: number;
worked: number;
covered: number;
remaining: number;
overtime: number;
leave_hours: number;
vacation_hours: number;
sick_hours: number;
holiday_hours: number;
unpaid_hours: number;
}
interface AttendanceData {
ongoing_shift: ShiftRecord | null;
today_shifts: ShiftRecord[];
date: string;
leave_balance: LeaveBalance;
monthly_fund: MonthlyFund | null;
project_logs: ProjectLog[];
active_project_id: number | null;
}
function pluralizeDays(n: number) {
if (n === 1) return "den";
if (n >= 2 && n <= 4) return "dny";
return "dnů";
}
function getFundBarBackground(fund: MonthlyFund) {
if (fund.overtime > 0)
return "linear-gradient(135deg, var(--warning), #d97706)";
if (fund.covered >= fund.fund)
return "linear-gradient(135deg, var(--success), #059669)";
return "var(--gradient)";
}
export default function Attendance() {
const alert = useAlert();
const { hasPermission } = useAuth();
const queryClient = useQueryClient();
const statusQuery = useQuery({
queryKey: ["attendance", "status"],
queryFn: () => jsonQuery<AttendanceData>("/api/admin/attendance/status"),
});
const projectsQuery = useQuery({
queryKey: ["attendance", "projects"],
queryFn: () =>
jsonQuery<Project[]>("/api/admin/attendance?action=projects"),
});
const [submitting, setSubmitting] = useState(false);
const [isLeaveModalOpen, setIsLeaveModalOpen] = useState(false);
const [leaveForm, setLeaveForm] = useState({
leave_type: "vacation",
date_from: new Date().toISOString().split("T")[0],
date_to: new Date().toISOString().split("T")[0],
notes: "",
});
const [requestSubmitting, setRequestSubmitting] = useState(false);
const [notes, setNotes] = useState("");
const [switchingProject, setSwitchingProject] = useState(false);
const [gpsConfirm, setGpsConfirm] = useState<{
isOpen: boolean;
action: string | null;
}>({ isOpen: false, action: null });
const geoAbortRef = useRef<AbortController | null>(null);
const punchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
const latestActionRef = useRef<string | null>(null);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (geoAbortRef.current) geoAbortRef.current.abort();
if (punchTimeoutRef.current) clearTimeout(punchTimeoutRef.current);
};
}, []);
// Sync notes from query data when the shift changes
useEffect(() => {
if (statusQuery.data) {
setNotes(statusQuery.data.ongoing_shift?.notes || "");
}
}, [statusQuery.data]);
useModalLock(isLeaveModalOpen);
if (!hasPermission("attendance.record")) return <Forbidden />;
const handlePunch = (action: string) => {
setSubmitting(true);
latestActionRef.current = action;
// Some browsers silently hang on getCurrentPosition (especially with
// enableHighAccuracy:true on desktops without GPS). Use a short safety
// timeout and proceed without GPS rather than leaving the user stuck.
const safetyTimeout = setTimeout(() => {
if (mountedRef.current) {
submitPunch(action, {});
}
}, 6000);
if (!navigator.geolocation) {
clearTimeout(safetyTimeout);
alert.warning("GPS není dostupná");
submitPunch(action, {});
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
clearTimeout(safetyTimeout);
if (!mountedRef.current) return;
try {
const { latitude, longitude, accuracy } = position.coords;
submitPunch(action, { latitude, longitude, accuracy, address: "" });
} catch {
submitPunch(action, {});
}
// Fire-and-forget reverse geocoding to update the address later
if (geoAbortRef.current) geoAbortRef.current.abort();
const controller = new AbortController();
geoAbortRef.current = controller;
fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${position.coords.latitude}&lon=${position.coords.longitude}&zoom=18&addressdetails=1`,
{
headers: { "Accept-Language": "cs" },
signal: controller.signal,
},
)
.then((r) => r.json())
.then((geoData) => {
if (!mountedRef.current) return;
if (latestActionRef.current !== action) return;
if (geoData.display_name) {
apiFetch(`${API_BASE}/attendance/update-address`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
address: geoData.display_name,
punch_action: action,
}),
}).catch(() => {});
}
})
.catch(() => {});
},
(geoError) => {
clearTimeout(safetyTimeout);
if (!mountedRef.current) return;
let errorMsg = "Nepodařilo se získat polohu";
if (geoError.code === geoError.PERMISSION_DENIED) {
errorMsg = "Přístup k poloze byl zamítnut";
} else if (geoError.code === geoError.TIMEOUT) {
errorMsg = "Vypršel časový limit";
}
alert.error(errorMsg);
setSubmitting(false);
setGpsConfirm({ isOpen: true, action });
},
{ enableHighAccuracy: false, timeout: 5000, maximumAge: 60000 },
);
};
const submitPunch = async (
action: string,
gpsData: Record<string, unknown> = {},
) => {
try {
const response = await apiFetch(`${API_BASE}/attendance`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ punch_action: action, ...gpsData }),
});
if (response.status === 401) return;
const result = await response.json();
setSubmitting(false);
if (result.success) {
await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
});
punchTimeoutRef.current = setTimeout(() => {
alert.success(result.data?.message || result.message || "Uloženo");
}, 300);
} else {
alert.error(result.error);
}
} catch {
setSubmitting(false);
alert.error("Chyba připojení");
}
};
const handleBreak = async () => {
setSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/attendance`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ punch_action: "break_start" }),
});
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
});
alert.success(
result.data?.message || result.message || "Přestávka zaznamenána",
);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setSubmitting(false);
}
};
const handleSaveNotes = async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance/notes`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notes }),
});
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
alert.success("Poznámka byla uložena");
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
}
};
const handleSwitchProject = async (newProjectId: string | null) => {
setSwitchingProject(true);
try {
const response = await apiFetch(`${API_BASE}/attendance/switch-project`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_id: newProjectId || null }),
});
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
});
alert.success(
result.data?.message || result.message || "Projekt přepnut",
);
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setSwitchingProject(false);
}
};
const calculateBusinessDays = (from: string, to: string) => {
if (!from || !to) return 0;
const start = new Date(from);
const end = new Date(to);
if (end < start) return 0;
let days = 0;
const current = new Date(start);
while (current <= end) {
const day = current.getDay();
if (day !== 0 && day !== 6) days++;
current.setDate(current.getDate() + 1);
}
return days;
};
const handleRequestSubmit = async () => {
setRequestSubmitting(true);
try {
const response = await apiFetch(`${API_BASE}/leave-requests`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(leaveForm),
});
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setIsLeaveModalOpen(false);
await queryClient.invalidateQueries({
queryKey: ["attendance", "status"],
});
await new Promise((resolve) => setTimeout(resolve, 300));
alert.success(
result.data?.message || result.message || "Žádost odeslána",
);
setLeaveForm({
leave_type: "vacation",
date_from: new Date().toISOString().split("T")[0],
date_to: new Date().toISOString().split("T")[0],
notes: "",
});
} else {
alert.error(result.error);
}
} catch {
alert.error("Chyba připojení");
} finally {
setRequestSubmitting(false);
}
};
if (!statusQuery.data) {
return (
<Skeleton
name="attendance"
loading={statusQuery.isPending}
fixture={<AttendanceFixture />}
>
<div />
</Skeleton>
);
}
const {
ongoing_shift: ongoingShift,
today_shifts: todayShifts,
leave_balance: leaveBalance,
} = statusQuery.data;
const data = statusQuery.data;
const projects = projectsQuery.data ?? [];
const projectLogs = data.project_logs ?? [];
const activeProjectId = data.active_project_id ?? null;
const isOngoingShift = ongoingShift && !ongoingShift.departure_time;
const completedToday = todayShifts.filter((s) => s.departure_time);
const vacationDaysRemaining = Math.floor(leaveBalance.vacation_remaining / 8);
const vacationHoursRemaining = leaveBalance.vacation_remaining % 8;
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">Docházka</h1>
<p className="admin-page-subtitle">
{new Date().toLocaleDateString("cs-CZ", {
weekday: "long",
day: "numeric",
month: "long",
year: "numeric",
})}
</p>
</div>
</motion.div>
<div className="attendance-layout">
{/* Left Column - Clock In/Out */}
<motion.div
className="attendance-main"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
<div className="attendance-clock-card">
<div className="attendance-clock-header">
<div className="attendance-clock-status">
{isOngoingShift ? (
<>
<span className="attendance-status-dot active" />
<span>Pracuji</span>
</>
) : (
<>
<span className="attendance-status-dot" />
<span>Nepracuji</span>
</>
)}
</div>
<div className="attendance-clock-time">
{new Date().toLocaleTimeString("cs-CZ", {
hour: "2-digit",
minute: "2-digit",
})}
</div>
</div>
{isOngoingShift ? (
<>
<div className="attendance-shift-info">
<div className="attendance-shift-row">
<div className="attendance-shift-item">
<span className="attendance-shift-label">Příchod</span>
<span className="attendance-shift-value success">
{formatTime(ongoingShift.arrival_time)}
</span>
</div>
<div className="attendance-shift-item">
<span className="attendance-shift-label">Pauza</span>
<span
className={`attendance-shift-value ${ongoingShift.break_start ? "success" : ""}`}
>
{ongoingShift.break_start
? `${formatTime(ongoingShift.break_start)} - ${formatTime(ongoingShift.break_end)}`
: "—"}
</span>
</div>
<div className="attendance-shift-item">
<span className="attendance-shift-label">Odchod</span>
<span className="attendance-shift-value"></span>
</div>
</div>
</div>
{projects.length > 0 && (
<div className="attendance-project-section">
<div className="attendance-project-header">
<span className="attendance-shift-label">Projekt</span>
{activeProjectId ? (
<span className="admin-badge admin-badge-wrap text-sm">
{projects.find(
(p) => String(p.id) === String(activeProjectId),
)
? `${projects.find((p) => String(p.id) === String(activeProjectId))!.project_number} ${projects.find((p) => String(p.id) === String(activeProjectId))!.name}`
: `Projekt #${activeProjectId}`}
</span>
) : (
<span className="text-muted text-sm">Žádný</span>
)}
</div>
<select
value={activeProjectId || ""}
onChange={(e) =>
handleSwitchProject(e.target.value || null)
}
disabled={switchingProject}
className="admin-form-select text-md"
>
<option value=""> Bez projektu </option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.project_number} {p.name}
</option>
))}
</select>
{projectLogs.length > 0 && (
<div className="attendance-project-logs">
{projectLogs.map((log, i) => {
if (!log.started_at) return null;
const start = new Date(log.started_at);
const end = log.ended_at
? new Date(log.ended_at)
: new Date();
const mins = Math.max(
0,
Math.floor(
(end.getTime() - start.getTime()) / 60000,
),
);
const h = Math.floor(mins / 60);
const mm = mins % 60;
return (
<div
key={log.id || i}
className="attendance-project-log-item"
>
<span className="attendance-project-log-name">
{log.project_name ||
`Projekt #${log.project_id}`}
</span>
<span className="attendance-project-log-time">
{formatTime(log.started_at)} {" "}
{log.ended_at
? formatTime(log.ended_at)
: "nyní"}
</span>
<span className="attendance-project-log-duration">
{h}:{String(mm).padStart(2, "0")} h
</span>
</div>
);
})}
</div>
)}
</div>
)}
<div className="attendance-clock-actions">
{!ongoingShift.break_start && (
<button
onClick={handleBreak}
disabled={submitting}
className="admin-btn admin-btn-secondary w-full"
>
Pauza (30 min)
</button>
)}
<button
onClick={() => handlePunch("departure")}
disabled={submitting}
className="admin-btn admin-btn-primary w-full"
>
{submitting ? "Zpracovávám..." : "Odchod"}
</button>
<button
onClick={() => setIsLeaveModalOpen(true)}
className="admin-btn admin-btn-secondary w-full"
>
Žádost o nepřítomnost
</button>
</div>
<div className="attendance-notes">
<label className="attendance-notes-label">
Poznámka ke směně
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Co jste dělali během směny..."
className="admin-form-textarea"
rows={3}
/>
<div className="mt-2">
<button
onClick={handleSaveNotes}
className="admin-btn admin-btn-secondary admin-btn-sm"
>
Uložit poznámku
</button>
</div>
</div>
</>
) : (
<div className="attendance-clock-actions">
<button
onClick={() => handlePunch("arrival")}
disabled={submitting}
className="admin-btn admin-btn-primary w-full"
>
{submitting ? "Zpracovávám..." : "Příchod"}
</button>
<button
onClick={() => setIsLeaveModalOpen(true)}
className="admin-btn admin-btn-secondary w-full"
>
Žádost o nepřítomnost
</button>
</div>
)}
</div>
{/* Completed Today */}
{completedToday.length > 0 && (
<div className="admin-card mt-6">
<div className="admin-card-header">
<h2 className="admin-card-title">Dnešní dokončené směny</h2>
</div>
<div className="admin-card-body">
<div className="admin-table-responsive">
<table className="admin-table">
<thead>
<tr>
<th>Příchod</th>
<th>Pauza</th>
<th>Odchod</th>
<th>Odpracováno</th>
{projects.length > 0 && <th>Projekty</th>}
</tr>
</thead>
<tbody>
{completedToday.map((shift) => {
const shiftLogs = shift.project_logs || [];
return (
<tr key={shift.id}>
<td className="admin-mono">
{formatTime(shift.arrival_time)}
</td>
<td className="admin-mono">
{shift.break_start && shift.break_end
? `${formatTime(shift.break_start)} - ${formatTime(shift.break_end)}`
: "—"}
</td>
<td className="admin-mono">
{formatTime(shift.departure_time)}
</td>
<td className="admin-mono">
{formatMinutes(calculateWorkMinutes(shift), true)}
</td>
{projects.length > 0 && (
<td>
{shiftLogs.length > 0 ? (
<div
style={{
display: "flex",
flexDirection: "column",
gap: "0.25rem",
}}
>
{shiftLogs.map((log, i) => {
if (!log.started_at) return null;
const mins = log.ended_at
? Math.floor(
(new Date(log.ended_at).getTime() -
new Date(
log.started_at,
).getTime()) /
60000,
)
: 0;
const h = Math.floor(mins / 60);
const mm = mins % 60;
return (
<span
key={log.id || i}
style={{ fontSize: "12px" }}
>
{log.project_name ||
`#${log.project_id}`}{" "}
({h}:{String(mm).padStart(2, "0")}h)
</span>
);
})}
</div>
) : (
"—"
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
)}
</motion.div>
{/* Right Column - Stats & Quick Links */}
<motion.div
className="attendance-sidebar"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25, delay: 0.06 }}
>
{/* Leave Balance Card */}
<div className="attendance-balance-card">
<h3 className="attendance-balance-title">
Dovolená {new Date().getFullYear()}
</h3>
<div className="attendance-balance-value">
<span className="attendance-balance-number">
{vacationDaysRemaining}
</span>
<span className="attendance-balance-unit">
{pluralizeDays(vacationDaysRemaining)}
{vacationHoursRemaining > 0 && ` ${vacationHoursRemaining}h`}
</span>
</div>
<div className="attendance-balance-detail">
<span>Celkem: {leaveBalance.vacation_total}h</span>
<span>Čerpáno: {leaveBalance.vacation_used}h</span>
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{
width: `${(leaveBalance.vacation_remaining / leaveBalance.vacation_total) * 100}%`,
}}
/>
</div>
</div>
{/* Monthly Fund Card */}
{data.monthly_fund && (
<div
className="admin-stat-card"
style={{ flexDirection: "column", alignItems: "stretch" }}
>
<div
style={{ display: "flex", alignItems: "center", gap: "1rem" }}
>
<div className="admin-stat-icon info">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-label">
{data.monthly_fund.month_name}
</span>
<span className="admin-stat-value">
{data.monthly_fund.worked}h / {data.monthly_fund.fund}h
</span>
</div>
</div>
<div style={{ marginTop: "0.75rem" }}>
<div
className="text-secondary text-sm"
style={{
display: "flex",
justifyContent: "space-between",
marginBottom: "0.5rem",
}}
>
<span>Odpracováno: {data.monthly_fund.worked}h</span>
{data.monthly_fund.overtime > 0 ? (
<span className="text-warning fw-600">
Přesčas: +{data.monthly_fund.overtime}h
</span>
) : (
<span>Zbývá: {data.monthly_fund.remaining}h</span>
)}
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{
width: `${Math.min(100, (data.monthly_fund.covered / data.monthly_fund.fund) * 100)}%`,
background: getFundBarBackground(data.monthly_fund),
}}
/>
</div>
{data.monthly_fund.leave_hours > 0 && (
<div
className="text-muted text-xs"
style={{ marginTop: "0.375rem" }}
>
{"Pokryto: "}
{data.monthly_fund.covered}h (práce{" "}
{data.monthly_fund.worked}h
{data.monthly_fund.vacation_hours > 0 &&
` + dovolená ${data.monthly_fund.vacation_hours}h`}
{data.monthly_fund.sick_hours > 0 &&
` + nemoc ${data.monthly_fund.sick_hours}h`}
{data.monthly_fund.holiday_hours > 0 &&
` + svátek ${data.monthly_fund.holiday_hours}h`}
{data.monthly_fund.unpaid_hours > 0 &&
` + neplacené ${data.monthly_fund.unpaid_hours}h`}
)
</div>
)}
</div>
</div>
)}
{/* Sick Leave Card */}
<div className="admin-stat-card">
<div className="admin-stat-icon danger">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
</div>
<div className="admin-stat-content">
<span className="admin-stat-label">
Nemoc {new Date().getFullYear()}
</span>
<span className="admin-stat-value">
{leaveBalance.sick_used}h čerpáno
</span>
</div>
</div>
{/* Quick Links */}
<div className="attendance-quick-links">
<h4 className="attendance-quick-title">Rychlé odkazy</h4>
<Link to="/attendance/requests" className="attendance-quick-link">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
<span>Moje žádosti</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
<Link to="/attendance/history" className="attendance-quick-link">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M3 3v18h18" />
<path d="M18.7 8l-5.1 5.2-2.8-2.7L7 14.3" />
</svg>
<span>Historie docházky</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
{hasPermission("attendance.admin") && (
<Link to="/attendance/admin" className="attendance-quick-link">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
<span>Správa docházky</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
)}
{hasPermission("attendance.balances") && (
<Link to="/attendance/balances" className="attendance-quick-link">
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
</svg>
<span>Správa bilancí</span>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M9 18l6-6-6-6" />
</svg>
</Link>
)}
</div>
</motion.div>
</div>
{/* Leave Modal */}
<AnimatePresence>
{isLeaveModalOpen && (
<motion.div
className="admin-modal-overlay"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
<div
className="admin-modal-backdrop"
onClick={() => setIsLeaveModalOpen(false)}
/>
<motion.div
className="admin-modal"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.2 }}
>
<div className="admin-modal-header">
<h2 className="admin-modal-title">Žádost o nepřítomnost</h2>
</div>
<div className="admin-modal-body">
<div className="admin-form">
<FormField label="Typ nepřítomnosti">
<select
value={leaveForm.leave_type}
onChange={(e) =>
setLeaveForm({
...leaveForm,
leave_type: e.target.value,
})
}
className="admin-form-select"
>
<option value="vacation">Dovolená</option>
<option value="sick">Nemoc</option>
<option value="unpaid">Neplacené volno</option>
</select>
</FormField>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1rem",
}}
>
<FormField label="Od">
<AdminDatePicker
mode="date"
value={leaveForm.date_from}
onChange={(val: string) => {
setLeaveForm((prev) => ({
...prev,
date_from: val,
date_to: prev.date_to < val ? val : prev.date_to,
}));
}}
/>
</FormField>
<FormField label="Do">
<AdminDatePicker
mode="date"
value={leaveForm.date_to}
minDate={leaveForm.date_from}
onChange={(val: string) =>
setLeaveForm({ ...leaveForm, date_to: val })
}
/>
</FormField>
</div>
{leaveForm.date_from && leaveForm.date_to && (
<div className="admin-form-group">
<div
style={{
display: "flex",
gap: "1.5rem",
padding: "0.75rem 1rem",
background: "var(--bg-tertiary)",
borderRadius: "var(--border-radius)",
fontSize: "0.875rem",
}}
>
<span>
<strong>
{calculateBusinessDays(
leaveForm.date_from,
leaveForm.date_to,
)}
</strong>{" "}
{(() => {
const d = calculateBusinessDays(
leaveForm.date_from,
leaveForm.date_to,
);
if (d === 1) return "pracovní den";
if (d >= 2 && d <= 4) return "pracovní dny";
return "pracovních dnů";
})()}
</span>
<span className="text-muted">
{calculateBusinessDays(
leaveForm.date_from,
leaveForm.date_to,
) * 8}{" "}
hodin
</span>
</div>
</div>
)}
<FormField label="Poznámka">
<textarea
value={leaveForm.notes}
onChange={(e) =>
setLeaveForm({ ...leaveForm, notes: e.target.value })
}
placeholder="Volitelná poznámka..."
className="admin-form-textarea"
rows={2}
/>
</FormField>
</div>
</div>
<div className="admin-modal-footer">
<button
type="button"
onClick={() => setIsLeaveModalOpen(false)}
className="admin-btn admin-btn-secondary"
disabled={requestSubmitting}
>
Zrušit
</button>
<button
type="button"
onClick={handleRequestSubmit}
disabled={
requestSubmitting ||
calculateBusinessDays(
leaveForm.date_from,
leaveForm.date_to,
) === 0
}
className="admin-btn admin-btn-primary"
>
{requestSubmitting ? "Odesílám..." : "Odeslat žádost"}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
<ConfirmModal
isOpen={gpsConfirm.isOpen}
onClose={() => {
setGpsConfirm({ isOpen: false, action: null });
setSubmitting(false);
}}
onConfirm={() => {
setGpsConfirm({ isOpen: false, action: null });
submitPunch(gpsConfirm.action!, {});
}}
title="GPS nedostupná"
message="Nepodařilo se získat polohu. Chcete pokračovat bez GPS?"
confirmText="Pokračovat"
cancelText="Zrušit"
type="warning"
/>
</div>
);
}