- Č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>
1224 lines
43 KiB
TypeScript
1224 lines
43 KiB
TypeScript
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>
|
||
);
|
||
}
|