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
243 lines
7.8 KiB
TypeScript
243 lines
7.8 KiB
TypeScript
import { Link } from "react-router-dom";
|
|
import {
|
|
formatDate,
|
|
formatDatetime,
|
|
formatTime,
|
|
calculateWorkMinutes,
|
|
formatMinutes,
|
|
getLeaveTypeName,
|
|
getLeaveTypeBadgeClass,
|
|
} from "../utils/attendanceHelpers";
|
|
import type { AttendanceRecord as HookAttendanceRecord } from "../hooks/useAttendanceAdmin";
|
|
|
|
interface AttendanceRecord extends HookAttendanceRecord {
|
|
arrival_lat?: number | string | null;
|
|
arrival_lng?: number | string | null;
|
|
departure_lat?: number | string | null;
|
|
departure_lng?: number | string | null;
|
|
}
|
|
|
|
interface AttendanceShiftTableProps {
|
|
records: AttendanceRecord[];
|
|
onEdit: (record: AttendanceRecord) => void;
|
|
onDelete: (record: AttendanceRecord) => void;
|
|
}
|
|
|
|
function formatBreak(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 renderProjectCell(record: AttendanceRecord): React.ReactNode {
|
|
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;
|
|
let durationValid = true;
|
|
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 start = log.started_at ? new Date(log.started_at) : null;
|
|
if (start && !isNaN(start.getTime()) && !isNaN(end.getTime())) {
|
|
const mins = Math.max(
|
|
0,
|
|
Math.floor((end.getTime() - start.getTime()) / 60000),
|
|
);
|
|
h = Math.floor(mins / 60);
|
|
m = mins % 60;
|
|
} else {
|
|
durationValid = false;
|
|
h = 0;
|
|
m = 0;
|
|
}
|
|
}
|
|
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}`}{" "}
|
|
{durationValid
|
|
? `(${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 AttendanceShiftTable({
|
|
records,
|
|
onEdit,
|
|
onDelete,
|
|
}: AttendanceShiftTableProps) {
|
|
if (records.length === 0) {
|
|
return (
|
|
<div className="admin-empty-state">
|
|
<p>Za tento měsíc nejsou žádné záznamy.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="admin-table-responsive">
|
|
<table className="admin-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Datum</th>
|
|
<th>Zaměstnanec</th>
|
|
<th>Typ</th>
|
|
<th>Příchod</th>
|
|
<th>Pauza</th>
|
|
<th>Odchod</th>
|
|
<th>Hodiny</th>
|
|
<th>Projekt</th>
|
|
<th>GPS</th>
|
|
<th>Poznámka</th>
|
|
<th>Akce</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{records.map((record) => {
|
|
const leaveType = record.leave_type || "work";
|
|
const isLeave = leaveType !== "work";
|
|
const workMinutes = isLeave
|
|
? (record.leave_hours != null ? Number(record.leave_hours) : 8) *
|
|
60
|
|
: calculateWorkMinutes({
|
|
...record,
|
|
notes: record.notes ?? undefined,
|
|
});
|
|
const hasLocation =
|
|
(record.arrival_lat && record.arrival_lng) ||
|
|
(record.departure_lat && record.departure_lng);
|
|
|
|
return (
|
|
<tr key={record.id}>
|
|
<td className="admin-mono">{formatDate(record.shift_date)}</td>
|
|
<td>{record.user_name}</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 ? "—" : formatBreak(record)}
|
|
</td>
|
|
<td className="admin-mono">
|
|
{isLeave ? "—" : formatDatetime(record.departure_time)}
|
|
</td>
|
|
<td className="admin-mono">
|
|
{workMinutes > 0 ? `${formatMinutes(workMinutes)} h` : "—"}
|
|
</td>
|
|
<td>{renderProjectCell(record)}</td>
|
|
<td>
|
|
{hasLocation ? (
|
|
<Link
|
|
to={`/attendance/location/${record.id}`}
|
|
className="attendance-gps-link"
|
|
title="Zobrazit polohu"
|
|
aria-label="Zobrazit polohu"
|
|
>
|
|
<span aria-hidden="true">{"📍"}</span>
|
|
</Link>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</td>
|
|
<td
|
|
style={{
|
|
maxWidth: "100px",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
title={record.notes || ""}
|
|
>
|
|
{record.notes || ""}
|
|
</td>
|
|
<td>
|
|
<div className="admin-table-actions">
|
|
<button
|
|
onClick={() => onEdit(record)}
|
|
className="admin-btn-icon"
|
|
title="Upravit"
|
|
aria-label="Upravit"
|
|
>
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={() => onDelete(record)}
|
|
className="admin-btn-icon danger"
|
|
title="Smazat"
|
|
aria-label="Smazat"
|
|
>
|
|
<svg
|
|
width="18"
|
|
height="18"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<polyline points="3 6 5 6 21 6" />
|
|
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|