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:
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user