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
This commit is contained in:
BOHA
2026-06-03 23:13:10 +02:00
parent dac45baaa8
commit 5233db2002
149 changed files with 11132 additions and 26950 deletions

View File

@@ -10,9 +10,7 @@ import AttendanceShiftTable from "../components/AttendanceShiftTable";
import useModalLock from "../hooks/useModalLock";
import useAttendanceAdmin from "../hooks/useAttendanceAdmin";
import FormField from "../components/FormField";
import { formatMinutes } from "../utils/attendanceHelpers";
import { Skeleton } from "boneyard-js/react";
import AttendanceAdminFixture from "../fixtures/AttendanceAdminFixture";
import { formatMinutes, formatHoursDecimal } from "../utils/attendanceHelpers";
interface UserTotalData {
name: string;
@@ -20,20 +18,34 @@ interface UserTotalData {
working: boolean;
vacation_hours: number;
sick_hours: number;
holiday_hours: number;
unpaid_hours: number;
fund: number | null;
worked_hours: number;
covered: number;
missing: number;
overtime: number;
// mzda-style summary fields (set by computeUserTotals in useAttendanceAdmin)
odpracovano?: number;
vcetne_svatku?: number;
prescas?: number;
svatek?: number;
worked_holiday_hours?: number;
}
function getFundBarBackground(data: UserTotalData) {
if (data.overtime > 0)
return "linear-gradient(135deg, var(--warning), #d97706)";
if (data.covered >= (data.fund ?? 0))
return "linear-gradient(135deg, var(--success), #059669)";
// fondUsed mirrors the Fond "used" line in the IIFE below:
// real_worked + vacation + worked_holiday + free_holiday
// (delta is fondUsed - fund; 0 means exactly at the fund).
const realWorked = data.worked_hours_raw ?? data.worked_hours;
const fondUsed =
realWorked +
(data.vacation_hours ?? 0) +
(data.worked_holiday_hours ?? 0) +
(data.svatek ?? 0);
const fundVal = data.fund ?? 0;
const delta = fondUsed - fundVal;
if (delta > 0) return "linear-gradient(135deg, var(--warning), #d97706)";
if (delta >= 0) return "linear-gradient(135deg, var(--success), #059669)";
return "var(--gradient)";
}
@@ -89,7 +101,7 @@ export default function AttendanceAdmin() {
if (!hasPermission("attendance.manage")) return <Forbidden />;
// Show skeleton only on initial load (no data yet), not on filter changes
// Show spinner only on initial load (no data yet), not on filter changes
const isInitialLoad =
loading &&
data.records.length === 0 &&
@@ -97,13 +109,9 @@ export default function AttendanceAdmin() {
if (isInitialLoad) {
return (
<Skeleton
name="attendance-admin"
loading={isInitialLoad}
fixture={<AttendanceAdminFixture />}
>
<div />
</Skeleton>
<div className="admin-loading">
<div className="admin-spinner" />
</div>
);
}
@@ -212,8 +220,15 @@ export default function AttendanceAdmin() {
{Object.entries(data.user_totals).map(([uid, userData]) => {
const ut = userData as UserTotalData;
return (
<div key={uid} className="admin-card">
<div className="admin-card-body">
<div key={uid} className="admin-card" style={{ display: "flex" }}>
<div
className="admin-card-body"
style={{
display: "flex",
flexDirection: "column",
flex: 1,
}}
>
<div className="flex-row gap-2 mb-2">
<span style={{ fontWeight: 600 }}>{ut.name}</span>
<span
@@ -229,9 +244,11 @@ export default function AttendanceAdmin() {
<div
style={{
marginTop: "0.5rem",
marginBottom: "0.75rem",
display: "flex",
flexWrap: "wrap",
gap: "0.25rem",
gap: "0.4rem",
minHeight: "2rem",
}}
>
{ut.vacation_hours > 0 && (
@@ -244,9 +261,9 @@ export default function AttendanceAdmin() {
Nem: {ut.sick_hours}h
</span>
)}
{ut.holiday_hours > 0 && (
{ut.svatek !== undefined && ut.svatek > 0 && (
<span className="attendance-leave-badge badge-holiday">
Sv: {ut.holiday_hours}h
Sv: {Math.round(ut.svatek)}h
</span>
)}
{ut.unpaid_hours > 0 && (
@@ -255,62 +272,93 @@ export default function AttendanceAdmin() {
</span>
)}
</div>
{ut.fund !== null && (
<div className="mt-2">
<div
className="text-secondary"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "0.8rem",
}}
>
<span>
Fond: {ut.worked_hours}h / {ut.fund}h
</span>
{ut.overtime > 0 && (
<span className="text-warning fw-600">
+{ut.overtime}h
</span>
)}
{ut.overtime <= 0 && ut.missing > 0 && (
<span className="text-danger fw-600">
-{ut.missing}h
</span>
)}
</div>
<div
style={{
marginTop: "0.375rem",
height: "4px",
background: "var(--bg-tertiary)",
borderRadius: "2px",
overflow: "hidden",
}}
>
{ut.fund !== null &&
(() => {
// Fond "used" = real_worked + vacation + worked_holiday + free_holiday
// (everything the user actually got paid for this month:
// raw worked time with sub-day remainders, plus
// vacation days, plus hours worked on holidays, plus
// the 8h per unworked holiday from the fund).
const realWorked = ut.worked_hours_raw ?? ut.worked_hours;
const fondUsed =
realWorked +
(ut.vacation_hours ?? 0) +
(ut.worked_holiday_hours ?? 0) +
(ut.svatek ?? 0);
const fundVal = ut.fund ?? 0;
const delta = fondUsed - fundVal;
return (
<div
className="kpi-card-footer"
style={{
height: "100%",
width: `${Math.min(100, (ut.covered / (ut.fund || 1)) * 100)}%`,
background: getFundBarBackground(ut),
borderRadius: "2px",
transition: "width 0.3s ease",
marginTop: "auto",
paddingTop: "0.75rem",
borderTop: "1px solid var(--border-color)",
}}
/>
</div>
</div>
)}
{data.leave_balances[uid] && (
<div
className="text-secondary"
style={{ marginTop: "0.5rem", fontSize: "0.8rem" }}
>
Zbývá dovolené:{" "}
{data.leave_balances[uid].vacation_remaining.toFixed(1)}h
/ {data.leave_balances[uid].vacation_total}h
</div>
)}
>
<div
className="text-secondary"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
fontSize: "0.8rem",
}}
>
<span>
Fond: {formatHoursDecimal(fondUsed * 60)}h /{" "}
{formatHoursDecimal(fundVal * 60)}h
</span>
{delta > 0 && (
<span className="text-warning fw-600">
+{formatHoursDecimal(delta * 60)}h
</span>
)}
{delta <= 0 && delta < 0 && (
<span className="text-danger fw-600">
-{formatHoursDecimal(Math.abs(delta) * 60)}h
</span>
)}
</div>
<div
style={{
marginTop: "0.375rem",
height: "4px",
background: "var(--bg-tertiary)",
borderRadius: "2px",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${Math.min(
100,
(fondUsed / (ut.fund || 1)) * 100,
)}%`,
background: getFundBarBackground(ut),
borderRadius: "2px",
transition: "width 0.3s ease",
}}
/>
</div>
</div>
);
})()}
<div
className="text-secondary"
style={{ marginTop: "0.75rem", fontSize: "0.8rem" }}
>
{data.leave_balances[uid] ? (
<>
Zbývá dovolené:{" "}
{data.leave_balances[uid].vacation_remaining.toFixed(1)}
h / {data.leave_balances[uid].vacation_total}h
</>
) : (
<span style={{ visibility: "hidden" }}></span>
)}
</div>
</div>
</div>
);
@@ -374,7 +422,14 @@ export default function AttendanceAdmin() {
projectList={projectList}
users={data.users}
onShiftDateChange={handleCreateShiftDateChange}
editingRecord={editingRecord}
editingRecord={
editingRecord
? {
user_name: editingRecord.user_name ?? "",
shift_date: editingRecord.shift_date,
}
: null
}
/>
<ConfirmModal