Files
app/src/admin/pages/Attendance.tsx
BOHA 8cdf057ab3 feat: CNB exchange rates, multi-currency KPI stats, invoice PDF VAT in CZK
- ČNB exchange rate service with date-specific rates and caching
- Invoice/received invoice stats convert foreign currencies to CZK
- Dashboard revenue converts all currencies to CZK
- Invoice PDF: VAT recap table always in CZK with CNB rate footer
- Inline styles replaced with utility classes (step 4 cleanup)
- Spinner animation exempt from prefers-reduced-motion

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

1224 lines
43 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, useCallback, useRef } from "react";
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";
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 [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [data, setData] = useState<AttendanceData>({
ongoing_shift: null,
today_shifts: [],
date: "",
leave_balance: {
vacation_total: 160,
vacation_used: 0,
vacation_remaining: 160,
sick_used: 0,
},
monthly_fund: null,
project_logs: [],
active_project_id: null,
});
const [showLeaveModal, setShowLeaveModal] = 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 [projects, setProjects] = useState<Project[]>([]);
const [switchingProject, setSwitchingProject] = useState(false);
const [projectLogs, setProjectLogs] = useState<ProjectLog[]>([]);
const [activeProjectId, setActiveProjectId] = useState<number | null>(null);
const [gpsConfirm, setGpsConfirm] = useState<{
show: boolean;
action: string | null;
}>({ show: false, action: null });
const geoAbortRef = useRef<AbortController | null>(null);
useEffect(() => {
return () => {
if (geoAbortRef.current) geoAbortRef.current.abort();
};
}, []);
const fetchData = useCallback(async () => {
try {
const response = await apiFetch(`${API_BASE}/attendance/status`);
if (response.status === 401) return;
const result = await response.json();
if (result.success) {
setData(result.data);
setNotes(result.data.ongoing_shift?.notes || "");
setProjectLogs(result.data.project_logs || []);
setActiveProjectId(result.data.active_project_id || null);
}
} catch {
alert.error("Nepodařilo se načíst data");
} finally {
setLoading(false);
}
}, [alert]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
const loadProjects = async () => {
try {
const response = await apiFetch(
`${API_BASE}/attendance?action=projects`,
);
const result = await response.json();
if (result.success) {
const items = Array.isArray(result.data) ? result.data : [];
setProjects(items);
}
} catch {
// silent - projects are supplementary
}
};
loadProjects();
}, []);
useModalLock(showLeaveModal);
if (!hasPermission("attendance.record")) return <Forbidden />;
const handlePunch = (action: string) => {
setSubmitting(true);
if (!navigator.geolocation) {
alert.warning("GPS není dostupná");
submitPunch(action, {});
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude, accuracy } = position.coords;
submitPunch(action, { latitude, longitude, accuracy, address: "" });
if (geoAbortRef.current) geoAbortRef.current.abort();
const controller = new AbortController();
geoAbortRef.current = controller;
fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`,
{
headers: { "Accept-Language": "cs" },
signal: controller.signal,
},
)
.then((r) => r.json())
.then((geoData) => {
if (geoData.display_name) {
apiFetch(`${API_BASE}/attendance/update-address`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
latitude,
longitude,
address: geoData.display_name,
punch_action: action,
}),
}).catch(() => {});
}
})
.catch(() => {});
},
(geoError) => {
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);
setGpsConfirm({ show: true, action });
},
{ enableHighAccuracy: true, timeout: 10000, 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 fetchData();
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 fetchData();
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 fetchData();
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) {
setShowLeaveModal(false);
await fetchData();
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 (loading) {
return (
<div className="admin-skeleton" style={{ padding: 0, gap: "1.5rem" }}>
<div
className="admin-skeleton-row"
style={{ justifyContent: "space-between" }}
>
<div>
<div
className="admin-skeleton-line h-8"
style={{ width: "200px", marginBottom: "0.5rem" }}
/>
<div className="admin-skeleton-line" style={{ width: "140px" }} />
</div>
</div>
<div style={{ display: "flex", gap: "1.5rem" }}>
<div className="admin-card" style={{ flex: 2 }}>
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
<div
className="admin-skeleton-line h-8"
style={{ width: "120px", marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line h-10"
style={{ width: "180px" }}
/>
<div className="admin-skeleton-row">
<div style={{ flex: 1 }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
<div style={{ flex: 1 }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.5rem" }}
/>
<div
className="admin-skeleton-line w-1/4"
style={{ height: "10px" }}
/>
</div>
</div>
<div
className="admin-skeleton-line h-10"
style={{ width: "100%", borderRadius: "8px" }}
/>
</div>
</div>
<div
style={{
flex: 1,
display: "flex",
flexDirection: "column",
gap: "1rem",
}}
>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.25rem" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "80px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "100%", height: "6px", borderRadius: "3px" }}
/>
</div>
</div>
<div className="admin-card">
<div className="admin-skeleton" style={{ gap: "1rem" }}>
<div
className="admin-skeleton-line w-1/3"
style={{ marginBottom: "0.25rem" }}
/>
<div
className="admin-skeleton-line h-8"
style={{ width: "80px" }}
/>
<div
className="admin-skeleton-line"
style={{ width: "100%", height: "6px", borderRadius: "3px" }}
/>
</div>
</div>
</div>
</div>
</div>
);
}
const {
ongoing_shift: ongoingShift,
today_shifts: todayShifts,
leave_balance: leaveBalance,
} = data;
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) => {
const start = new Date(log.started_at!);
const end = log.ended_at
? new Date(log.ended_at)
: new Date();
const mins = 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={() => setShowLeaveModal(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={() => setShowLeaveModal(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) => {
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>
{showLeaveModal && (
<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={() => setShowLeaveModal(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={() => setShowLeaveModal(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.show}
onClose={() => {
setGpsConfirm({ show: false, action: null });
setSubmitting(false);
}}
onConfirm={() => {
setGpsConfirm({ show: 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>
);
}