- 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>
855 lines
32 KiB
TypeScript
855 lines
32 KiB
TypeScript
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
|
import { useAlert } from "../context/AlertContext";
|
|
import { useAuth } from "../context/AuthContext";
|
|
import Forbidden from "../components/Forbidden";
|
|
import { motion } from "framer-motion";
|
|
import AdminDatePicker from "../components/AdminDatePicker";
|
|
import {
|
|
formatDate,
|
|
formatDatetime,
|
|
formatTime,
|
|
calculateWorkMinutes,
|
|
formatMinutes,
|
|
getLeaveTypeName,
|
|
getLeaveTypeBadgeClass,
|
|
calculateWorkMinutesPrint,
|
|
formatTimeOrDatetimePrint,
|
|
} from "../utils/attendanceHelpers";
|
|
import FormField from "../components/FormField";
|
|
import apiFetch from "../utils/api";
|
|
|
|
const API_BASE = "/api/admin";
|
|
|
|
interface ProjectLog {
|
|
id?: number;
|
|
project_id?: number;
|
|
project_name?: string;
|
|
started_at?: string;
|
|
ended_at?: string | null;
|
|
hours?: string | number | null;
|
|
minutes?: string | number | null;
|
|
}
|
|
|
|
interface AttendanceRecord {
|
|
id: number;
|
|
shift_date: string;
|
|
leave_type?: string;
|
|
leave_hours?: number;
|
|
arrival_time?: string | null;
|
|
departure_time?: string | null;
|
|
break_start?: string | null;
|
|
break_end?: string | null;
|
|
notes?: string;
|
|
project_name?: string;
|
|
project_logs?: ProjectLog[];
|
|
}
|
|
|
|
const MONTH_NAMES = [
|
|
"Leden",
|
|
"Únor",
|
|
"Březen",
|
|
"Duben",
|
|
"Květen",
|
|
"Červen",
|
|
"Červenec",
|
|
"Srpen",
|
|
"Září",
|
|
"Říjen",
|
|
"Listopad",
|
|
"Prosinec",
|
|
];
|
|
|
|
const formatBreakRange = (record: AttendanceRecord): string => {
|
|
if (record.break_start && record.break_end) {
|
|
return `${formatTime(record.break_start)} - ${formatTime(record.break_end)}`;
|
|
}
|
|
if (record.break_start) {
|
|
return `${formatTime(record.break_start)} - ?`;
|
|
}
|
|
return "—";
|
|
};
|
|
|
|
const renderProjectCell = (record: AttendanceRecord) => {
|
|
if (record.project_logs && record.project_logs.length > 0) {
|
|
return (
|
|
<div
|
|
style={{ display: "flex", flexDirection: "column", gap: "0.125rem" }}
|
|
>
|
|
{record.project_logs.map((log, i) => {
|
|
let h: number,
|
|
m: number,
|
|
isActive = false;
|
|
if (log.hours !== null && log.hours !== undefined) {
|
|
h = parseInt(String(log.hours)) || 0;
|
|
m = parseInt(String(log.minutes)) || 0;
|
|
} else {
|
|
isActive = !log.ended_at;
|
|
const end = log.ended_at ? new Date(log.ended_at) : new Date();
|
|
const mins = Math.floor(
|
|
(end.getTime() - new Date(log.started_at!).getTime()) / 60000,
|
|
);
|
|
h = Math.floor(mins / 60);
|
|
m = mins % 60;
|
|
}
|
|
return (
|
|
<span
|
|
key={log.id || i}
|
|
className="admin-badge"
|
|
style={{
|
|
fontSize: "0.7rem",
|
|
display: "inline-block",
|
|
background: isActive ? "var(--accent-light)" : undefined,
|
|
}}
|
|
>
|
|
{log.project_name || `#${log.project_id}`} ({h}:
|
|
{String(m).padStart(2, "0")}h{isActive ? " ▸" : ""})
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
if (record.project_name) {
|
|
return (
|
|
<span
|
|
className="admin-badge admin-badge-wrap"
|
|
style={{ fontSize: "0.75rem" }}
|
|
>
|
|
{record.project_name}
|
|
</span>
|
|
);
|
|
}
|
|
return "—";
|
|
};
|
|
|
|
export default function AttendanceHistory() {
|
|
const alert = useAlert();
|
|
const { user, hasPermission } = useAuth();
|
|
const [loading, setLoading] = useState(true);
|
|
const [companyName, setCompanyName] = useState("");
|
|
const printRef = useRef<HTMLDivElement>(null);
|
|
const [month, setMonth] = useState(() => {
|
|
const now = new Date();
|
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
|
});
|
|
const [records, setRecords] = useState<AttendanceRecord[]>([]);
|
|
|
|
const fetchData = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [yearStr, monthStr] = month.split("-");
|
|
const response = await apiFetch(
|
|
`${API_BASE}/attendance?year=${yearStr}&month=${monthStr}&limit=1000&user_id=${user?.id || ""}`,
|
|
);
|
|
if (response.status === 401) return;
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
setRecords(result.data);
|
|
}
|
|
} catch {
|
|
alert.error("Nepodařilo se načíst data");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [month, alert, user?.id]);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [fetchData]);
|
|
|
|
useEffect(() => {
|
|
apiFetch(`${API_BASE}/company-settings`)
|
|
.then((r) => r.json())
|
|
.then((d) => {
|
|
if (d.success) setCompanyName(d.data.company_name || "");
|
|
})
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const computed = useMemo(() => {
|
|
const [yearStr, monthStr] = month.split("-");
|
|
const monthIndex = parseInt(monthStr, 10) - 1;
|
|
const monthName = `${MONTH_NAMES[monthIndex]} ${yearStr}`;
|
|
|
|
let totalMinutes = 0;
|
|
let vacationHours = 0;
|
|
let sickHours = 0;
|
|
let holidayHours = 0;
|
|
let unpaidHours = 0;
|
|
|
|
for (const record of records) {
|
|
const leaveType = record.leave_type || "work";
|
|
if (leaveType === "work") {
|
|
totalMinutes += calculateWorkMinutes(record);
|
|
} else {
|
|
const hours = Number(record.leave_hours) || 8;
|
|
if (leaveType === "vacation") vacationHours += hours;
|
|
else if (leaveType === "sick") sickHours += hours;
|
|
else if (leaveType === "holiday") holidayHours += hours;
|
|
else if (leaveType === "unpaid") unpaidHours += hours;
|
|
}
|
|
}
|
|
|
|
// Exclude holidays from business days (matching PHP CzechHolidays logic)
|
|
const yr = parseInt(yearStr, 10);
|
|
const mo = parseInt(monthStr, 10) - 1;
|
|
const holidayDays = records.filter(
|
|
(r) => (r.leave_type || "work") === "holiday",
|
|
).length;
|
|
let businessDays = 0;
|
|
const cur = new Date(yr, mo, 1);
|
|
while (cur.getMonth() === mo) {
|
|
const dow = cur.getDay();
|
|
if (dow !== 0 && dow !== 6) businessDays++;
|
|
cur.setDate(cur.getDate() + 1);
|
|
}
|
|
// Subtract holidays from business days (holidays are non-working days, not part of the fund)
|
|
businessDays = Math.max(0, businessDays - holidayDays);
|
|
const fund = businessDays * 8;
|
|
const worked = Math.round((totalMinutes / 60) * 100) / 100;
|
|
// Covered = worked + vacation + sick (NOT holiday/unpaid — holiday is excluded from fund, unpaid is voluntary)
|
|
const leaveHours = vacationHours + sickHours;
|
|
const covered = Math.round((worked + leaveHours) * 100) / 100;
|
|
const remaining = Math.max(0, Math.round((fund - covered) * 100) / 100);
|
|
const overtime = Math.max(0, Math.round((covered - fund) * 100) / 100);
|
|
|
|
const monthlyFund = {
|
|
fund,
|
|
business_days: businessDays,
|
|
worked,
|
|
covered,
|
|
remaining,
|
|
overtime,
|
|
};
|
|
|
|
return {
|
|
monthName,
|
|
totalMinutes,
|
|
vacationHours,
|
|
sickHours,
|
|
holidayHours,
|
|
unpaidHours,
|
|
monthlyFund,
|
|
};
|
|
}, [records, month]);
|
|
|
|
if (!hasPermission("attendance.history")) return <Forbidden />;
|
|
|
|
const handlePrint = () => {
|
|
if (!printRef.current) return;
|
|
const content = printRef.current.innerHTML;
|
|
const printWindow = window.open("", "_blank");
|
|
if (!printWindow) return;
|
|
printWindow.document.write(`
|
|
<!DOCTYPE html>
|
|
<html lang="cs">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Docházka - ${computed.monthName}</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
|
font-size: 11px;
|
|
line-height: 1.4;
|
|
color: #000;
|
|
background: #fff;
|
|
padding: 15mm;
|
|
}
|
|
.print-wrapper-table { width: 100%; border-collapse: collapse; border: none; }
|
|
.print-wrapper-table > thead > tr > td,
|
|
.print-wrapper-table > tbody > tr > td { padding: 0; border: none; background: none; }
|
|
.print-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
margin-bottom: 20px;
|
|
padding-bottom: 15px;
|
|
border-bottom: 2px solid #333;
|
|
}
|
|
.print-header-left { display: flex; align-items: center; gap: 12px; }
|
|
.print-logo { height: 40px; width: auto; }
|
|
.print-header-text { text-align: left; }
|
|
.print-header-right { text-align: right; }
|
|
.print-header h1 { font-size: 18px; font-weight: 700; margin-bottom: 3px; }
|
|
.print-header .company { font-size: 11px; color: #666; }
|
|
.print-header .period { font-size: 13px; font-weight: 600; color: #333; margin-bottom: 2px; }
|
|
.print-header .filters { font-size: 10px; color: #666; }
|
|
.print-header .generated { font-size: 9px; color: #888; margin-top: 5px; }
|
|
.user-section { margin-bottom: 25px; page-break-inside: avoid; }
|
|
.user-header {
|
|
background: #f5f5f5;
|
|
border: 1px solid #ddd;
|
|
padding: 10px 15px;
|
|
margin-bottom: 10px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.user-header h3 { font-size: 13px; font-weight: 600; }
|
|
.user-header .total { font-size: 12px; font-weight: 600; }
|
|
.leave-summary {
|
|
margin-top: 10px;
|
|
padding: 8px 15px;
|
|
background: #f9f9f9;
|
|
border: 1px solid #ddd;
|
|
font-size: 10px;
|
|
}
|
|
.user-section table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
|
|
.user-section th, .user-section td { border: 1px solid #333; padding: 6px 8px; text-align: left; }
|
|
.user-section th { background: #333; color: #fff; font-weight: 600; font-size: 10px; text-transform: uppercase; }
|
|
.user-section td { font-size: 10px; }
|
|
.user-section tr:nth-child(even) { background: #f9f9f9; }
|
|
.text-center { text-align: center; }
|
|
.text-right { text-align: right; }
|
|
.user-section tfoot td { background: #eee; font-weight: 600; }
|
|
.leave-badge {
|
|
display: inline-block;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-size: 9px;
|
|
font-weight: 500;
|
|
}
|
|
.badge-vacation { background: #dbeafe; color: #1d4ed8; }
|
|
.badge-sick { background: #fee2e2; color: #dc2626; }
|
|
.badge-holiday { background: #dcfce7; color: #16a34a; }
|
|
.badge-unpaid { background: #f3f4f6; color: #6b7280; }
|
|
.badge-overtime { background: #fef3c7; color: #d97706; }
|
|
@media print {
|
|
body { padding: 0; margin: 0; }
|
|
@page { size: A4 portrait; margin: 10mm; }
|
|
.user-section { page-break-inside: avoid; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
${content}
|
|
</body>
|
|
</html>
|
|
`);
|
|
printWindow.document.close();
|
|
printWindow.onload = () => {
|
|
printWindow.print();
|
|
};
|
|
};
|
|
|
|
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">Historie docházky</h1>
|
|
<p className="admin-page-subtitle">{computed.monthName}</p>
|
|
</div>
|
|
<div className="admin-page-actions">
|
|
{records.length > 0 && (
|
|
<button
|
|
onClick={handlePrint}
|
|
className="admin-btn admin-btn-secondary"
|
|
title="Tisk docházky"
|
|
>
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
style={{ marginRight: "0.5rem" }}
|
|
>
|
|
<polyline points="6 9 6 2 18 2 18 9" />
|
|
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2" />
|
|
<rect x="6" y="14" width="12" height="8" />
|
|
</svg>
|
|
Tisk
|
|
</button>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Filters */}
|
|
<motion.div
|
|
className="admin-card mb-6"
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, delay: 0.06 }}
|
|
>
|
|
<div className="admin-card-body">
|
|
<div className="admin-form-row">
|
|
<FormField label="Měsíc">
|
|
<AdminDatePicker
|
|
mode="month"
|
|
value={month}
|
|
onChange={(val: string) => setMonth(val)}
|
|
/>
|
|
</FormField>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Monthly Fund Card */}
|
|
<motion.div
|
|
className="admin-card mb-6"
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, delay: 0.08 }}
|
|
>
|
|
<div className="admin-card-body">
|
|
{loading && (
|
|
<div className="admin-skeleton" style={{ gap: "0.5rem" }}>
|
|
<div className="admin-skeleton-row" style={{ gap: "1rem" }}>
|
|
<div
|
|
className="admin-skeleton-line"
|
|
style={{
|
|
width: "48px",
|
|
height: "48px",
|
|
borderRadius: "12px",
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
<div className="flex-1">
|
|
<div
|
|
className="admin-skeleton-line w-1/2"
|
|
style={{ marginBottom: "0.5rem" }}
|
|
/>
|
|
<div
|
|
className="admin-skeleton-line w-full"
|
|
style={{ height: "6px", borderRadius: "3px" }}
|
|
/>
|
|
<div
|
|
className="admin-skeleton-line w-1/3"
|
|
style={{ height: "10px", marginTop: "0.5rem" }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!loading && computed.monthlyFund && (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "1rem",
|
|
flexWrap: "wrap",
|
|
}}
|
|
>
|
|
<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 style={{ flex: 1, minWidth: "200px" }}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "baseline",
|
|
marginBottom: "0.375rem",
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontWeight: 600,
|
|
fontSize: "1rem",
|
|
color: "var(--text-primary)",
|
|
}}
|
|
>
|
|
Fond: {computed.monthlyFund.worked}h /{" "}
|
|
{computed.monthlyFund.fund}h
|
|
</span>
|
|
<span
|
|
className="text-secondary"
|
|
style={{ fontSize: "0.8125rem" }}
|
|
>
|
|
{computed.monthlyFund.business_days} prac. dnů
|
|
</span>
|
|
</div>
|
|
<div className="attendance-balance-bar">
|
|
<div
|
|
className="attendance-balance-progress"
|
|
style={{
|
|
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.covered / computed.monthlyFund.fund) * 100 : 0)}%`,
|
|
background:
|
|
computed.monthlyFund.covered >=
|
|
computed.monthlyFund.fund
|
|
? "linear-gradient(135deg, var(--success), #059669)"
|
|
: "var(--gradient)",
|
|
}}
|
|
/>
|
|
</div>
|
|
<div
|
|
className="text-muted"
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
fontSize: "0.75rem",
|
|
marginTop: "0.375rem",
|
|
}}
|
|
>
|
|
<span>
|
|
{"Pokryto: "}
|
|
{computed.monthlyFund.covered}h (práce{" "}
|
|
{computed.monthlyFund.worked}h
|
|
{computed.vacationHours > 0 &&
|
|
` + dovolená ${computed.vacationHours}h`}
|
|
{computed.sickHours > 0 &&
|
|
` + nemoc ${computed.sickHours}h`}
|
|
{computed.holidayHours > 0 &&
|
|
` + svátek ${computed.holidayHours}h`}
|
|
{computed.unpaidHours > 0 &&
|
|
` + neplacené ${computed.unpaidHours}h`}
|
|
)
|
|
</span>
|
|
{computed.monthlyFund.overtime > 0 ? (
|
|
<span className="text-warning fw-600">
|
|
Přesčas: +{computed.monthlyFund.overtime}h
|
|
</span>
|
|
) : (
|
|
<span>Zbývá: {computed.monthlyFund.remaining}h</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{!loading && !computed.monthlyFund && (
|
|
<div
|
|
className="text-muted"
|
|
style={{
|
|
fontSize: "0.875rem",
|
|
textAlign: "center",
|
|
padding: "0.5rem 0",
|
|
}}
|
|
>
|
|
Fond měsíce není k dispozici
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Records Table */}
|
|
<motion.div
|
|
className="admin-card"
|
|
initial={{ opacity: 0, y: 12 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ duration: 0.25, delay: 0.12 }}
|
|
>
|
|
<div className="admin-card-body">
|
|
{loading && (
|
|
<div className="admin-skeleton" style={{ gap: "1.25rem" }}>
|
|
{[0, 1, 2, 3, 4].map((i) => (
|
|
<div key={i} className="admin-skeleton-row">
|
|
<div className="admin-skeleton-line w-1/4" />
|
|
<div className="admin-skeleton-line w-1/3" />
|
|
<div className="admin-skeleton-line w-1/4" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{!loading && records.length === 0 && (
|
|
<div className="admin-empty-state">
|
|
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
|
</div>
|
|
)}
|
|
{!loading && records.length > 0 && (
|
|
<div className="admin-table-responsive">
|
|
<table className="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Datum</th>
|
|
<th>Typ</th>
|
|
<th>Příchod</th>
|
|
<th>Pauza</th>
|
|
<th>Odchod</th>
|
|
<th>Hodiny</th>
|
|
<th>Projekty</th>
|
|
<th>Poznámka</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{records.map((record) => {
|
|
const leaveType = record.leave_type || "work";
|
|
const isLeave = leaveType !== "work";
|
|
const workMinutes = isLeave
|
|
? (Number(record.leave_hours) || 8) * 60
|
|
: calculateWorkMinutes(record);
|
|
|
|
return (
|
|
<tr key={record.id}>
|
|
<td className="admin-mono">
|
|
{formatDate(record.shift_date)}
|
|
</td>
|
|
<td>
|
|
<span
|
|
className={`attendance-leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
|
|
>
|
|
{getLeaveTypeName(leaveType)}
|
|
</span>
|
|
</td>
|
|
<td className="admin-mono">
|
|
{isLeave ? "—" : formatDatetime(record.arrival_time)}
|
|
</td>
|
|
<td className="admin-mono">
|
|
{isLeave ? "—" : formatBreakRange(record)}
|
|
</td>
|
|
<td className="admin-mono">
|
|
{isLeave
|
|
? "—"
|
|
: formatDatetime(record.departure_time)}
|
|
</td>
|
|
<td className="admin-mono">
|
|
{workMinutes > 0
|
|
? formatMinutes(workMinutes, true)
|
|
: "—"}
|
|
</td>
|
|
<td>{renderProjectCell(record)}</td>
|
|
<td
|
|
style={{
|
|
maxWidth: "150px",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
>
|
|
{record.notes || ""}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Hidden Print Content */}
|
|
{records.length > 0 && (
|
|
<div ref={printRef} style={{ display: "none" }}>
|
|
<table className="print-wrapper-table">
|
|
<thead>
|
|
<tr>
|
|
<td>
|
|
<div className="print-header">
|
|
<div className="print-header-left">
|
|
<img
|
|
src="/api/admin/company-settings/logo?variant=light"
|
|
alt=""
|
|
className="print-logo"
|
|
/>
|
|
<div className="print-header-text">
|
|
<h1>EVIDENCE DOCHÁZKY</h1>
|
|
<div className="company">{companyName}</div>
|
|
</div>
|
|
</div>
|
|
<div className="print-header-right">
|
|
<div className="period">{computed.monthName}</div>
|
|
<div className="filters">
|
|
Zaměstnanec: {user?.fullName || ""}
|
|
</div>
|
|
<div className="generated">
|
|
Vygenerováno: {new Date().toLocaleString("cs-CZ")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>
|
|
<div className="user-section">
|
|
<div className="user-header">
|
|
<h3>{user?.fullName || ""}</h3>
|
|
<span className="total">
|
|
Odpracováno:{" "}
|
|
{formatMinutes(computed.totalMinutes, true)}
|
|
</span>
|
|
</div>
|
|
|
|
{(computed.vacationHours > 0 ||
|
|
computed.sickHours > 0 ||
|
|
computed.holidayHours > 0) && (
|
|
<div className="leave-summary">
|
|
{computed.vacationHours > 0 && (
|
|
<>
|
|
<span className="leave-badge badge-vacation">
|
|
Dovolená: {computed.vacationHours}h
|
|
</span>{" "}
|
|
</>
|
|
)}
|
|
{computed.sickHours > 0 && (
|
|
<>
|
|
<span className="leave-badge badge-sick">
|
|
Nemoc: {computed.sickHours}h
|
|
</span>{" "}
|
|
</>
|
|
)}
|
|
{computed.holidayHours > 0 && (
|
|
<>
|
|
<span className="leave-badge badge-holiday">
|
|
Svátek: {computed.holidayHours}h
|
|
</span>{" "}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th style={{ width: "70px" }}>Datum</th>
|
|
<th style={{ width: "70px" }}>Typ</th>
|
|
<th className="text-center" style={{ width: "70px" }}>
|
|
Příchod
|
|
</th>
|
|
<th className="text-center" style={{ width: "90px" }}>
|
|
Pauza
|
|
</th>
|
|
<th className="text-center" style={{ width: "70px" }}>
|
|
Odchod
|
|
</th>
|
|
<th className="text-center" style={{ width: "80px" }}>
|
|
Hodiny
|
|
</th>
|
|
<th>Projekty</th>
|
|
<th>Poznámka</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{[...records]
|
|
.sort((a, b) =>
|
|
a.shift_date.localeCompare(b.shift_date),
|
|
)
|
|
.map((record) => {
|
|
const leaveType = record.leave_type || "work";
|
|
const isLeave = leaveType !== "work";
|
|
const workMinutes =
|
|
calculateWorkMinutesPrint(record);
|
|
const hours = Math.floor(workMinutes / 60);
|
|
const mins = workMinutes % 60;
|
|
|
|
return (
|
|
<tr key={record.id}>
|
|
<td>{formatDate(record.shift_date)}</td>
|
|
<td>
|
|
<span
|
|
className={`leave-badge ${getLeaveTypeBadgeClass(leaveType)}`}
|
|
>
|
|
{getLeaveTypeName(leaveType)}
|
|
</span>
|
|
</td>
|
|
<td className="text-center">
|
|
{isLeave
|
|
? "—"
|
|
: formatTimeOrDatetimePrint(
|
|
record.arrival_time,
|
|
record.shift_date,
|
|
)}
|
|
</td>
|
|
<td className="text-center">
|
|
{isLeave ||
|
|
!record.break_start ||
|
|
!record.break_end
|
|
? "—"
|
|
: `${formatTimeOrDatetimePrint(record.break_start, record.shift_date)} - ${formatTimeOrDatetimePrint(record.break_end, record.shift_date)}`}
|
|
</td>
|
|
<td className="text-center">
|
|
{isLeave
|
|
? "—"
|
|
: formatTimeOrDatetimePrint(
|
|
record.departure_time,
|
|
record.shift_date,
|
|
)}
|
|
</td>
|
|
<td className="text-center">
|
|
{workMinutes > 0
|
|
? `${hours}:${String(mins).padStart(2, "0")}`
|
|
: "—"}
|
|
</td>
|
|
<td style={{ fontSize: "8px" }}>
|
|
{record.project_logs &&
|
|
record.project_logs.length > 0
|
|
? record.project_logs.map((log, i) => {
|
|
let h: number, m: number;
|
|
if (
|
|
log.hours !== null &&
|
|
log.hours !== undefined
|
|
) {
|
|
h = parseInt(String(log.hours)) || 0;
|
|
m =
|
|
parseInt(String(log.minutes)) || 0;
|
|
} else if (
|
|
log.started_at &&
|
|
log.ended_at
|
|
) {
|
|
const mins2 = Math.max(
|
|
0,
|
|
Math.floor(
|
|
(new Date(
|
|
log.ended_at,
|
|
).getTime() -
|
|
new Date(
|
|
log.started_at,
|
|
).getTime()) /
|
|
60000,
|
|
),
|
|
);
|
|
h = Math.floor(mins2 / 60);
|
|
m = mins2 % 60;
|
|
} else {
|
|
h = 0;
|
|
m = 0;
|
|
}
|
|
return (
|
|
<div key={log.id || i}>
|
|
{log.project_name ||
|
|
`#${log.project_id}`}{" "}
|
|
({h}:{String(m).padStart(2, "0")}h)
|
|
</div>
|
|
);
|
|
})
|
|
: record.project_name || "—"}
|
|
</td>
|
|
<td>{record.notes || ""}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
<tfoot>
|
|
<tr>
|
|
<td colSpan={6} className="text-right">
|
|
Odpracováno:
|
|
</td>
|
|
<td className="text-center">
|
|
{formatMinutes(computed.totalMinutes, true)}
|
|
</td>
|
|
<td colSpan={2}></td>
|
|
</tr>
|
|
</tfoot>
|
|
</table>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|