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

@@ -20,10 +20,12 @@ import {
getLeaveTypeBadgeClass,
calculateWorkMinutesPrint,
formatTimeOrDatetimePrint,
calculateFreeHolidayHours,
holidaysInMonth,
normalizeDateStr,
formatHoursDecimal,
} from "../utils/attendanceHelpers";
import FormField from "../components/FormField";
import { Skeleton } from "boneyard-js/react";
import AttendanceHistoryFixture from "../fixtures/AttendanceHistoryFixture";
const MONTH_NAMES = [
"Leden",
@@ -196,7 +198,6 @@ export default function AttendanceHistory() {
let totalMinutes = 0;
let vacationHours = 0;
let sickHours = 0;
let holidayHours = 0;
let unpaidHours = 0;
for (const record of records) {
@@ -208,26 +209,51 @@ export default function AttendanceHistory() {
record.leave_hours != null ? 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;
// "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);
const fund = businessDays * 8;
// 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 holiday/unpaid — holiday is excluded from fund, unpaid is voluntary)
// Covered = worked + vacation + sick (NOT unpaid — 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);
// 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,
worked,
holiday_count: holidayCount,
worked: Math.round(fondUsed * 100) / 100,
covered,
remaining,
overtime,
@@ -238,9 +264,12 @@ export default function AttendanceHistory() {
totalMinutes,
vacationHours,
sickHours,
holidayHours,
unpaidHours,
monthlyFund,
fondUsed,
freeHolidayHours,
workedHolidayHours,
delta,
};
}, [records, month]);
@@ -324,7 +353,6 @@ export default function AttendanceHistory() {
}
.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 {
@@ -411,11 +439,11 @@ export default function AttendanceHistory() {
transition={{ duration: 0.25, delay: 0.08 }}
>
<div className="admin-card-body">
<Skeleton
name="attendance-history-fund"
loading={isPending}
fixture={<AttendanceHistoryFixture />}
>
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{computed.monthlyFund && (
<div
@@ -465,51 +493,90 @@ export default function AttendanceHistory() {
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.covered / computed.monthlyFund.fund) * 100 : 0)}%`,
width: `${Math.min(100, computed.monthlyFund.fund > 0 ? (computed.monthlyFund.worked / computed.monthlyFund.fund) * 100 : 0)}%`,
background:
computed.monthlyFund.covered >=
computed.monthlyFund.fund
? "linear-gradient(135deg, var(--success), #059669)"
: "var(--gradient)",
computed.delta > 0
? "linear-gradient(135deg, var(--warning), #d97706)"
: computed.delta >= 0
? "linear-gradient(135deg, var(--success), #059669)"
: "var(--gradient)",
}}
/>
</div>
<div
className="text-muted"
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.4rem",
marginTop: "0.5rem",
minHeight: "1.5rem",
alignItems: "center",
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`}
)
<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>
{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>
@@ -527,7 +594,7 @@ export default function AttendanceHistory() {
</div>
)}
</>
</Skeleton>
)}
</div>
</motion.div>
@@ -539,11 +606,11 @@ export default function AttendanceHistory() {
transition={{ duration: 0.25, delay: 0.12 }}
>
<div className="admin-card-body">
<Skeleton
name="attendance-history-table"
loading={isPending}
fixture={<AttendanceHistoryFixture />}
>
{isPending ? (
<div className="admin-loading">
<div className="admin-spinner" />
</div>
) : (
<>
{records.length === 0 && (
<div className="admin-empty-state">
@@ -622,7 +689,7 @@ export default function AttendanceHistory() {
</div>
)}
</>
</Skeleton>
)}
</div>
</motion.div>
@@ -670,9 +737,7 @@ export default function AttendanceHistory() {
</span>
</div>
{(computed.vacationHours > 0 ||
computed.sickHours > 0 ||
computed.holidayHours > 0) && (
{(computed.vacationHours > 0 || computed.sickHours > 0) && (
<div className="leave-summary">
{computed.vacationHours > 0 && (
<>
@@ -688,13 +753,6 @@ export default function AttendanceHistory() {
</span>{" "}
</>
)}
{computed.holidayHours > 0 && (
<>
<span className="leave-badge badge-holiday">
Svátek: {computed.holidayHours}h
</span>{" "}
</>
)}
</div>
)}