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

@@ -1,4 +1,7 @@
import { getHolidays } from "../../utils/czech-holidays";
interface AttendanceRecord {
user_id?: number;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
@@ -6,7 +9,7 @@ interface AttendanceRecord {
leave_type?: string;
leave_hours?: number;
shift_date?: string;
notes?: string;
notes?: string | null;
project_logs?: Array<{
id?: number;
project_id?: number;
@@ -79,7 +82,6 @@ export const getLeaveTypeName = (type: string): string => {
work: "Práce",
vacation: "Dovolená",
sick: "Nemoc",
holiday: "Svátek",
unpaid: "Neplacené volno",
};
return types[type] || "Práce";
@@ -89,7 +91,6 @@ export const getLeaveTypeBadgeClass = (type: string): string => {
const classes: Record<string, string> = {
vacation: "badge-vacation",
sick: "badge-sick",
holiday: "badge-holiday",
unpaid: "badge-unpaid",
};
return classes[type] || "";
@@ -187,3 +188,158 @@ export const calculateWorkMinutesPrint = (record: AttendanceRecord): number => {
return Math.max(0, Math.floor(minutes));
};
// ---------------------------------------------------------------------------
// Print template helpers (mzda-style counting)
// ---------------------------------------------------------------------------
/** Returns the Czech weekday name with the first letter capitalized. */
export const getCzechWeekday = (dateStr: string): string => {
const wd = new Date(dateStr).toLocaleDateString("cs-CZ", {
weekday: "long",
});
return wd.charAt(0).toUpperCase() + wd.slice(1);
};
/** True if the date is a Saturday or Sunday. */
export const isWeekendDate = (dateStr: string): boolean => {
const dow = new Date(dateStr).getDay();
return dow === 0 || dow === 6;
};
/** Number of days in a month (1-12). e.g. May 2026 → 31. */
export const getDaysInMonth = (year: number, month1to12: number): number => {
return new Date(year, month1to12, 0).getDate();
};
/** Build a "YYYY-MM-DD" string for a given year/month/day. */
export const shiftDateForMonth = (
year: number,
month1to12: number,
day: number,
): string => {
return `${year}-${String(month1to12).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
};
/** Format minutes as decimal hours, comma separator, always 2 decimals. */
export const formatHoursDecimal = (minutes: number): string => {
return ((minutes / 60).toFixed(2) as string).replace(".", ",");
};
/** Format minutes as decimal hours, comma separator, 1 decimal. */
export const formatHoursDecimal1 = (minutes: number): string => {
return ((minutes / 60).toFixed(1) as string).replace(".", ",");
};
/**
* Compute minutes that fall within the 22:00-06:00 night window (Czech law §94).
*
* Algorithm (per Czech spec):
* 1. Subtract all break intervals from the shift interval → list of worked
* sub-intervals.
* 2. For each worked sub-interval, compute its intersection with the
* 22:00-06:00 night window. Iterate from (sub-interval start day - 1)
* to (sub-interval end day + 1) to be safe with cross-midnight shifts.
* 3. Sum all overlaps in minutes.
*/
export const calculateNightMinutes = (record: AttendanceRecord): number => {
if (!record.arrival_time || !record.departure_time) return 0;
const startMs = new Date(record.arrival_time).getTime();
let endMs = new Date(record.departure_time).getTime();
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) return 0;
if (endMs <= startMs) endMs += 24 * 60 * 60 * 1000;
// STEP 1: subtract breaks from the shift interval.
let intervals: Array<[number, number]> = [[startMs, endMs]];
if (record.break_start && record.break_end) {
const bStart = new Date(record.break_start).getTime();
let bEnd = new Date(record.break_end).getTime();
if (Number.isFinite(bStart) && Number.isFinite(bEnd) && bEnd > bStart) {
const next: Array<[number, number]> = [];
for (const [s, e] of intervals) {
if (bEnd <= s || bStart >= e) {
next.push([s, e]);
} else {
if (bStart > s) next.push([s, bStart]);
if (bEnd < e) next.push([bEnd, e]);
}
}
intervals = next;
}
}
// Helper: midnight of the local day for a timestamp.
const dayMidnight = (ms: number): number => {
const d = new Date(ms);
return new Date(
d.getFullYear(),
d.getMonth(),
d.getDate(),
0,
0,
0,
0,
).getTime();
};
// STEP 2: for each worked sub-interval, intersect with every relevant
// 22:00-06:00 night window. A 24h shift touches at most 2 windows, but
// we iterate (startDay 1) to (endDay + 1) to be defensive.
let totalMs = 0;
for (const [s, e] of intervals) {
const fromDay = dayMidnight(s) - 24 * 60 * 60 * 1000;
const toDay = dayMidnight(e) + 24 * 60 * 60 * 1000;
for (let day = fromDay; day <= toDay; day += 24 * 60 * 60 * 1000) {
const winStart = day + 22 * 60 * 60 * 1000;
const winEnd = day + 30 * 60 * 60 * 1000; // 06:00 next day
const ov = Math.max(0, Math.min(e, winEnd) - Math.max(s, winStart));
totalMs += ov;
}
}
return Math.floor(totalMs / 60000);
};
/**
* Normalize a date string to "YYYY-MM-DD".
* Accepts both "2026-05-15" and "2026-05-15T00:00:00" / "2026-05-15T12:00:00"
* and returns just the date part.
*/
export const normalizeDateStr = (
dateStr: string | null | undefined,
): string => {
if (!dateStr) return "";
const m = String(dateStr).match(/^(\d{4})-(\d{2})-(\d{2})/);
return m ? `${m[1]}-${m[2]}-${m[3]}` : "";
};
/** All Czech public holidays in a given month (1-12) as YYYY-MM-DD. */
export const holidaysInMonth = (year: number, month1to12: number): string[] => {
const prefix = `${year}-${String(month1to12).padStart(2, "0")}-`;
return getHolidays(year).filter((d) => d.startsWith(prefix));
};
/**
* Hours the user "got for free" because they didn't work on a holiday.
* Returns 8h for each holiday in the month that has no work record.
* Leave records (vacation, sick, holiday, unpaid) on a holiday date do NOT
* count as work — the user must have an actual work shift to forfeit the
* 100% bonus.
*/
export const calculateFreeHolidayHours = (
records: AttendanceRecord[],
holidayDates: string[],
): number => {
const workedDates = new Set(
records
.filter((r) => (r.leave_type || "work") === "work")
.map((r) => normalizeDateStr(r.shift_date))
.filter(Boolean),
);
let free = 0;
for (const hd of holidayDates) {
if (!workedDates.has(hd)) free += 8;
}
return free;
};