Files
app/src/admin/pages/AttendanceHistory.tsx
BOHA 5233db2002 v1.8.0: warehouse module (16 commits), docházka mzda model, leave_type=holiday removal, api.ts race fix
Highlights:
- Warehouse module: receipts, issues, reservations, inventory, reports, dashboard,
  master data (categories, suppliers, locations), FIFO service, integration tests
- Docházka: mzda PDF counting model (Odpracováno / Vč. svátků / Přesčas / Svátek /
  So/Ne / Noc) with Czech weekday names and decimal hours
- AttendanceAdmin/AttendanceHistory KPI cards unified to mzda formula with
  fund bar colored by delta, badges for Práce/Dov/Nem/Sv/Nep
- Remove leave_type=holiday entirely (auto-computed from Czech public holidays)
- Allow multiple work shifts per day (overlap detection only)
- Pre-flight refresh in api.ts eliminates spurious 401s on fresh page loads
- Prisma: company_settings gets 6 nullable columns for warehouse numbering
  (PRI/VYD/INV prefixes, default patterns); migration seeds defaults
2026-06-03 23:13:10 +02:00

902 lines
34 KiB
TypeScript

import { useState, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../context/AuthContext";
import Forbidden from "../components/Forbidden";
import { motion } from "framer-motion";
import AdminDatePicker from "../components/AdminDatePicker";
import { companySettingsOptions } from "../lib/queries/settings";
import {
attendanceHistoryOptions,
type AttendanceRecord,
type ProjectLogEntry,
} from "../lib/queries/attendance";
import {
formatDate,
formatDatetime,
formatTime,
calculateWorkMinutes,
formatMinutes,
getLeaveTypeName,
getLeaveTypeBadgeClass,
calculateWorkMinutesPrint,
formatTimeOrDatetimePrint,
calculateFreeHolidayHours,
holidaysInMonth,
normalizeDateStr,
formatHoursDecimal,
} from "../utils/attendanceHelpers";
import FormField from "../components/FormField";
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 "—";
};
function getEasterSunday(year: number): string {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
function getCzechHolidays(year: number): string[] {
const y = String(year);
const holidays = [
`${y}-01-01`,
`${y}-05-01`,
`${y}-05-08`,
`${y}-07-05`,
`${y}-07-06`,
`${y}-09-28`,
`${y}-10-28`,
`${y}-11-17`,
`${y}-12-24`,
`${y}-12-25`,
`${y}-12-26`,
];
const easterSunday = getEasterSunday(year);
const easterDate = new Date(easterSunday);
const goodFriday = new Date(easterDate);
goodFriday.setDate(goodFriday.getDate() - 2);
const easterMonday = new Date(easterDate);
easterMonday.setDate(easterMonday.getDate() + 1);
const pad = (n: number) => String(n).padStart(2, "0");
holidays.push(
`${goodFriday.getFullYear()}-${pad(goodFriday.getMonth() + 1)}-${pad(goodFriday.getDate())}`,
);
holidays.push(
`${easterMonday.getFullYear()}-${pad(easterMonday.getMonth() + 1)}-${pad(easterMonday.getDate())}`,
);
holidays.sort();
return holidays;
}
function getBusinessDaysInMonth(year: number, month: number): number {
const holidays = getCzechHolidays(year);
let count = 0;
const daysInMonth = new Date(year, month + 1, 0).getDate();
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
const dow = date.getDay();
if (dow !== 0 && dow !== 6) {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
if (!holidays.includes(dateStr)) {
count++;
}
}
}
return count;
}
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.max(
0,
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 { user, hasPermission } = useAuth();
const queryClient = useQueryClient();
const { data: companySettings } = useQuery(companySettingsOptions());
const companyName = companySettings?.company_name || "";
const printRef = useRef<HTMLDivElement>(null);
const [month, setMonth] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
});
const { data, isPending } = useQuery(
attendanceHistoryOptions({ month, userId: user?.id }),
);
const records = data ?? [];
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 unpaidHours = 0;
for (const record of records) {
const leaveType = record.leave_type || "work";
if (leaveType === "work") {
totalMinutes += calculateWorkMinutes(record);
} else {
const hours =
record.leave_hours != null ? Number(record.leave_hours) : 8;
if (leaveType === "vacation") vacationHours += hours;
else if (leaveType === "sick") sickHours += hours;
else if (leaveType === "unpaid") unpaidHours += hours;
// "holiday" is no longer a user-selectable leave_type — it is
// auto-computed from Czech public holidays by the print/KPI code.
// Old records may still exist; skip so they don't double-count.
}
}
const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10) - 1;
const businessDays = getBusinessDaysInMonth(yr, mo);
// Fund counts Mon-Fri + holidays (holidays are paid via the fond).
const holidayDates = holidaysInMonth(yr, mo + 1);
const holidayCount = holidayDates.length;
const fund = (businessDays + holidayCount) * 8;
const worked = Math.round((totalMinutes / 60) * 100) / 100;
// Covered = worked + vacation + sick (NOT unpaid — unpaid is voluntary).
const leaveHours = vacationHours + sickHours;
const covered = Math.round((worked + leaveHours) * 100) / 100;
// fondUsed = real_worked + vacation + worked_holiday + free_holiday.
// (the all-in number: everything the user got paid for this month).
const realWorked = totalMinutes / 60;
const freeHolidayHours = calculateFreeHolidayHours(
records as unknown as Parameters<typeof calculateFreeHolidayHours>[0],
holidayDates,
);
const workedHolidayHours = records
.filter(
(r) =>
(r.leave_type || "work") === "work" &&
holidayDates.includes(normalizeDateStr(r.shift_date)),
)
.reduce((sum, r) => sum + calculateWorkMinutesPrint(r) / 60, 0);
const fondUsed =
realWorked + vacationHours + workedHolidayHours + freeHolidayHours;
const delta = fondUsed - fund;
const missing = Math.max(0, Math.round(-delta * 100) / 100);
const overtime = Math.max(0, Math.round(delta * 100) / 100);
const remaining = missing;
const monthlyFund = {
fund,
business_days: businessDays,
holiday_count: holidayCount,
worked: Math.round(fondUsed * 100) / 100,
covered,
remaining,
overtime,
};
return {
monthName,
totalMinutes,
vacationHours,
sickHours,
unpaidHours,
monthlyFund,
fondUsed,
freeHolidayHours,
workedHolidayHours,
delta,
};
}, [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-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">
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{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ů
{computed.monthlyFund.holiday_count > 0 &&
` + ${computed.monthlyFund.holiday_count} svátky`}
</span>
</div>
<div className="attendance-balance-bar">
<div
className="attendance-balance-progress"
style={{
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.worked / computed.monthlyFund.fund) * 100 : 0)}%`,
background:
computed.delta > 0
? "linear-gradient(135deg, var(--warning), #d97706)"
: computed.delta >= 0
? "linear-gradient(135deg, var(--success), #059669)"
: "var(--gradient)",
}}
/>
</div>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
marginTop: "0.5rem",
minHeight: "1.5rem",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
}}
>
{computed.totalMinutes > 0 && (
<span
className="attendance-leave-badge"
title="Odpracováno (reálně)"
>
Práce: {formatHoursDecimal(computed.totalMinutes)}h
</span>
)}
{computed.vacationHours > 0 && (
<span className="attendance-leave-badge badge-vacation">
Dov: {computed.vacationHours}h
</span>
)}
{computed.sickHours > 0 && (
<span className="attendance-leave-badge badge-sick">
Nem: {computed.sickHours}h
</span>
)}
{computed.freeHolidayHours > 0 && (
<span className="attendance-leave-badge badge-holiday">
Sv: {Math.round(computed.freeHolidayHours)}h
</span>
)}
{computed.unpaidHours > 0 && (
<span className="attendance-leave-badge badge-unpaid">
Nep: {computed.unpaidHours}h
</span>
)}
</div>
<span
style={{
fontSize: "0.75rem",
color: "var(--text-muted)",
}}
className={
computed.delta > 0
? "text-warning fw-600"
: computed.delta < 0
? "text-danger fw-600"
: "text-success fw-600"
}
>
{computed.delta > 0
? `Přesčas: +${formatHoursDecimal(computed.delta * 60)}h`
: computed.delta < 0
? `Zbývá: ${formatHoursDecimal(-computed.delta * 60)}h`
: "Fond splněn"}
</span>
</div>
</div>
</div>
)}
{!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">
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{records.length === 0 && (
<div className="admin-empty-state">
<p>Za tento měsíc nejsou žádné záznamy.</p>
</div>
)}
{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) && (
<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>{" "}
</>
)}
</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>
);
}