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,6 +1,7 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import apiFetch from "../utils/api";
import { isHoliday } from "../../utils/czech-holidays";
import {
calcProjectMinutesTotal,
calcFormWorkMinutes,
@@ -10,9 +11,17 @@ import {
formatDate,
formatMinutes,
getLeaveTypeName,
getLeaveTypeBadgeClass,
formatTimeOrDatetimePrint,
calculateWorkMinutesPrint,
isWeekendDate,
getDaysInMonth,
shiftDateForMonth,
formatHoursDecimal,
calculateNightMinutes,
normalizeDateStr,
holidaysInMonth,
calculateFreeHolidayHours,
getCzechWeekday,
} from "../utils/attendanceHelpers";
import type {
ShiftFormData,
@@ -29,7 +38,7 @@ interface AlertContext {
alert: { success: (msg: string) => void; error: (msg: string) => void };
}
interface AttendanceRecord {
export interface AttendanceRecord {
id: number;
user_id: number;
shift_date: string;
@@ -39,7 +48,7 @@ interface AttendanceRecord {
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
notes?: string;
notes?: string | null;
project_id?: number | null;
project_name?: string;
project_logs?: Array<{
@@ -73,7 +82,6 @@ interface UserTotal {
working: boolean;
vacation_hours: number;
sick_hours: number;
holiday_hours: number;
unpaid_hours: number;
overtime: number;
missing: number;
@@ -81,6 +89,16 @@ interface UserTotal {
business_days: number;
worked_hours: number;
covered: number;
// mzda-style summary fields (set by computeUserTotals)
worked_hours_raw?: number;
odpracovano?: number;
vcetne_svatku?: number;
prescas?: number;
svatek?: number;
weekend_hours?: number;
night_minutes?: number;
worked_holiday_hours?: number; // sum of hours worked ON a holiday
records?: AttendanceRecord[];
}
interface LeaveBalance {
@@ -121,6 +139,11 @@ const combineDatetime = (date: string, time: string): string | null =>
/**
* Compute per-user totals from raw attendance records.
* This replaces the server-side `user_totals` that the PHP backend returned.
*
* Adds mzda-style summary fields (odpracovano, vcetne_svatku, prescas, svatek,
* weekend_hours, night_minutes) and a fund computed as
* (rawBizDays + holidayCount) × 8 — holidays count as work days for fund
* purposes, per the mzda.pdf model.
*/
function computeUserTotals(
records: AttendanceRecord[],
@@ -128,6 +151,7 @@ function computeUserTotals(
month: string,
): Record<string, UserTotal> {
const totals: Record<string, UserTotal> = {};
const recordsByUser = new Map<number, AttendanceRecord[]>();
for (const rec of records) {
const uid = String(rec.user_id);
@@ -144,7 +168,6 @@ function computeUserTotals(
working: false,
vacation_hours: 0,
sick_hours: 0,
holiday_hours: 0,
unpaid_hours: 0,
overtime: 0,
missing: 0,
@@ -152,15 +175,23 @@ function computeUserTotals(
business_days: 0,
worked_hours: 0,
covered: 0,
records: [],
};
}
const t = totals[uid];
t.records!.push(rec);
if (!recordsByUser.has(rec.user_id)) recordsByUser.set(rec.user_id, []);
recordsByUser.get(rec.user_id)!.push(rec);
const leaveType = rec.leave_type || "work";
if (leaveType === "work") {
// Only work records contribute to "minutes" (matching PHP calculateUserTotals)
t.minutes += calculateWorkMinutes(rec);
t.minutes += calculateWorkMinutes({
...rec,
notes: rec.notes ?? undefined,
});
} else {
const leaveHours = Number(rec.leave_hours) || 8;
switch (leaveType) {
@@ -170,12 +201,14 @@ function computeUserTotals(
case "sick":
t.sick_hours += leaveHours;
break;
case "holiday":
t.holiday_hours += leaveHours;
break;
case "unpaid":
t.unpaid_hours += leaveHours;
break;
// "holiday" leave_type is no longer selectable in the UI — it is
// auto-computed from Czech public holidays (see freeHolidayHours
// below). Old records may still exist in the DB; treat them as a
// paid non-work entry and skip so they don't double-count with
// the auto-detected svátek.
}
}
@@ -190,7 +223,10 @@ function computeUserTotals(
const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10) - 1;
// Count business days in month (Mon-Fri)
// Count Mon-Fri business days in month (INCLUDING holidays).
// The fund is rawBizDays × 8 — holidays stay in the count so the
// user is paid for them via the fund. Svátek (free holiday hours) is
// computed separately as "8h per holiday the user did not work".
let rawBizDays = 0;
const cur = new Date(yr, mo, 1);
while (cur.getMonth() === mo) {
@@ -199,23 +235,100 @@ function computeUserTotals(
cur.setDate(cur.getDate() + 1);
}
const holidayDates = holidaysInMonth(yr, mo + 1);
const holidayCount = holidayDates.length;
for (const uid of Object.keys(totals)) {
const t = totals[uid];
// Subtract holiday days from business days for this user
const holidayDays = Math.round(t.holiday_hours / 8);
const bizDays = Math.max(0, rawBizDays - holidayDays);
const fund = bizDays * 8;
const workedHours = Math.round((t.minutes / 60) * 10) / 10;
// Covered = worked + vacation + sick (NOT holiday/unpaid — matching PHP)
const leaveHours = t.vacation_hours + t.sick_hours;
const covered = Math.round((workedHours + leaveHours) * 10) / 10;
const userRecords = recordsByUser.get(Number(uid)) || [];
// Odpracováno: floor(worked / 8) × 8 — round to whole days, drop remainder.
const workedHoursRaw = t.minutes / 60;
const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
// So/Ne: sum of worked hours on Sat/Sun (raw, not rounded).
const weekendHours = userRecords
.filter(
(r) =>
(r.leave_type || "work") === "work" && isWeekendDate(r.shift_date),
)
.reduce((sum, r) => sum + calculateWorkMinutesPrint(r) / 60, 0);
// Práce v noci: minutes in 22:00-06:00 across all work records.
const nightMinutes = userRecords
.filter((r) => (r.leave_type || "work") === "work")
.reduce((sum, r) => sum + calculateNightMinutes(r), 0);
// Svátek (free): 8h per holiday the user did not work.
const freeHolidayHours = calculateFreeHolidayHours(
userRecords,
holidayDates,
);
// Worked holidays: holidays the user DID work (counted toward
// Odpracováno, but their 8h must not flow into Přesčas).
const workedDatesSet = new Set(
userRecords
.filter((r) => (r.leave_type || "work") === "work")
.map((r) => normalizeDateStr(r.shift_date))
.filter(Boolean),
);
let workedHolidayCount = 0;
let workedHolidayHours = 0;
for (const hd of holidayDates) {
if (workedDatesSet.has(hd)) workedHolidayCount++;
}
// Sum the actual hours worked on holiday dates (for the KPI card's
// "fond used" total = real_work + vacation + worked_holiday + free_holiday).
for (const r of userRecords) {
if ((r.leave_type || "work") !== "work") continue;
const d = normalizeDateStr(r.shift_date);
if (d && holidayDates.includes(d)) {
workedHolidayHours += calculateWorkMinutesPrint(r) / 60;
}
}
// Fund = Mon-Fri × 8h. Holidays count as work days (already in rawBizDays).
const fund = rawBizDays * 8;
// Včetně svátků a přesčasů (mzda formula):
// odpracovano + vacation + remainder min(freeHols, workedHols × 8)
//
// Two-way logic matching the print:
// • If the user worked at least one holiday, that worked holiday's
// 8h is in Odpracováno and shouldn't also count as Přesčas. We
// subtract one 8h per worked holiday (capped at the total free
// holiday hours — can't go negative).
// • If the user did NOT work any holiday, no adjustment is needed:
// all unworked holidays are already paid via the Svátek line and
// don't flow into Přesčas, so vcetne_svatku just equals the
// work + vacation + remainder.
const remainder = workedHoursRaw - odpracovano;
const holidayAdj = Math.min(freeHolidayHours, workedHolidayCount * 8);
const vcetneSv = odpracovano - holidayAdj + t.vacation_hours + remainder;
// Přesčas = vcetneSv odpracovano (derived).
const prescas = vcetneSv - odpracovano;
// Legacy fields (kept so existing UI doesn't break).
const workedHours = Math.round(workedHoursRaw * 10) / 10;
const covered = Math.round((workedHours + t.vacation_hours) * 10) / 10;
t.fund = fund;
t.business_days = bizDays;
t.business_days = rawBizDays;
t.worked_hours = workedHours;
t.covered = covered;
t.missing = Math.max(0, Math.round((fund - covered) * 10) / 10);
t.overtime = Math.max(0, Math.round((covered - fund) * 10) / 10);
t.worked_hours_raw = workedHoursRaw;
t.odpracovano = odpracovano;
t.vcetne_svatku = vcetneSv;
t.prescas = prescas;
t.svatek = freeHolidayHours;
t.weekend_hours = weekendHours;
t.night_minutes = nightMinutes;
t.worked_holiday_hours = workedHolidayHours;
}
return totals;
@@ -234,14 +347,6 @@ function escapeHtml(str: string): string {
.replace(/'/g, "&#039;");
}
function renderFundStatus(userData: Record<string, any>): string {
if (userData.overtime > 0)
return `<span class="leave-badge badge-overtime">+${escapeHtml(String(userData.overtime))}h přesčas</span>`;
if (userData.missing > 0)
return `<span style="color:#dc2626">${escapeHtml(String(userData.missing))}h</span>`;
return '<span style="color:#16a34a">splněno</span>';
}
function buildProjectLogsHtml(record: Record<string, any>): string {
if (record.project_logs && record.project_logs.length > 0) {
return record.project_logs
@@ -272,94 +377,220 @@ function buildProjectLogsHtml(record: Record<string, any>): string {
return escapeHtml(record.project_name || "—");
}
function buildLeaveSummaryHtml(
userId: string,
userData: Record<string, any>,
printData: Record<string, any>,
): string {
const bal = printData.leave_balances[userId];
let parts = `<strong>Dovolená ${escapeHtml(String(printData.year))}:</strong> Zbývá ${bal.vacation_remaining.toFixed(1)}h z ${bal.vacation_total}h`;
if (userData.vacation_hours > 0)
parts += ` | <span class="leave-badge badge-vacation">Tento měsíc: ${escapeHtml(String(userData.vacation_hours))}h</span>`;
if (userData.sick_hours > 0)
parts += ` | <span class="leave-badge badge-sick">Nemoc: ${escapeHtml(String(userData.sick_hours))}h</span>`;
if (userData.holiday_hours > 0)
parts += ` | <span class="leave-badge badge-holiday">Svátek: ${escapeHtml(String(userData.holiday_hours))}h</span>`;
if (userData.overtime > 0)
parts += ` | <span class="leave-badge badge-overtime">Přesčas: +${escapeHtml(String(userData.overtime))}h</span>`;
return `<div class="leave-summary">${parts}</div>`;
}
function buildUserSectionHtml(
userId: string,
userData: Record<string, any>,
printData: Record<string, any>,
): string {
const leaveHtml = printData.leave_balances[userId]
? buildLeaveSummaryHtml(userId, userData, printData)
: "";
// Build a date-keyed lookup of the user's records.
const recordsByDate = new Map<string, Record<string, any>>();
for (const r of userData.records || []) {
recordsByDate.set(normalizeDateStr(r.shift_date), r);
}
const recordRows = (userData.records || [])
.map((record: any) => {
const leaveType = record.leave_type || "work";
const isLeave = leaveType !== "work";
const workMinutes = calculateWorkMinutesPrint(record);
const hours = Math.floor(workMinutes / 60);
const mins = workMinutes % 60;
const breakCell =
isLeave || !record.break_start || !record.break_end
? "—"
: `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
// Iterate one row per day of the month.
const [yearStr, monthStr] = printData.month.split("-");
const yr = parseInt(yearStr, 10);
const mo = parseInt(monthStr, 10);
const daysInMonth = getDaysInMonth(yr, mo);
return `<tr>
<td>${escapeHtml(formatDate(record.shift_date))}</td>
<td><span class="leave-badge ${escapeHtml(getLeaveTypeBadgeClass(leaveType))}">${escapeHtml(getLeaveTypeName(leaveType))}</span></td>
const userRecords = (userData.records || []) as Record<string, any>[];
const rows: string[] = [];
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = shiftDateForMonth(yr, mo, d);
const record = recordsByDate.get(dateStr);
const weekend = isWeekendDate(dateStr);
const holiday = isHoliday(dateStr);
const leaveType = (record?.leave_type as string) || "work";
const rowClasses = [
weekend && "weekend",
holiday && "holiday",
leaveType === "vacation" && "vacation",
]
.filter(Boolean)
.join(" ");
if (!record) {
rows.push(`<tr class="${rowClasses}">
<td>${escapeHtml(formatDate(dateStr))}</td>
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
<td>—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td class="text-center">—</td>
<td></td>
<td></td>
</tr>`);
continue;
}
const isLeave = leaveType !== "work";
const workMinutes = calculateWorkMinutesPrint(record);
const hours = Math.floor(workMinutes / 60);
const mins = workMinutes % 60;
const breakCell =
isLeave || !record.break_start || !record.break_end
? "—"
: `${escapeHtml(formatTimeOrDatetimePrint(record.break_start, record.shift_date))} - ${escapeHtml(formatTimeOrDatetimePrint(record.break_end, record.shift_date))}`;
let hoursCell: string;
if (workMinutes > 0 && !isLeave) {
hoursCell = `${hours}:${String(mins).padStart(2, "0")} (${formatHoursDecimal(workMinutes)})`;
} else if (isLeave) {
hoursCell = formatHoursDecimal((Number(record.leave_hours) || 8) * 60);
} else {
hoursCell = "—";
}
rows.push(`<tr class="${rowClasses}">
<td>${escapeHtml(formatDate(dateStr))}</td>
<td>${escapeHtml(getCzechWeekday(dateStr))}</td>
<td>${escapeHtml(getLeaveTypeName(leaveType))}</td>
<td class="text-center">${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.arrival_time, record.shift_date))}</td>
<td class="text-center">${breakCell}</td>
<td class="text-center">${isLeave ? "—" : escapeHtml(formatTimeOrDatetimePrint(record.departure_time, record.shift_date))}</td>
<td class="text-center">${workMinutes > 0 ? `${hours}:${String(mins).padStart(2, "0")}` : "—"}</td>
<td class="text-center">${hoursCell}</td>
<td style="font-size:8px">${buildProjectLogsHtml(record)}</td>
<td>${escapeHtml(record.notes || "")}</td>
</tr>`;
})
.join("");
</tr>`);
}
const fundRow =
userData.fund !== null
? `<tr>
<td colspan="6" class="text-right">Fond měsíce:</td>
<td class="text-center">${escapeHtml(String(userData.covered))}h / ${escapeHtml(String(userData.fund))}h</td>
<td colspan="2">${renderFundStatus(userData)}</td>
</tr>`
: "";
// ----- mzda-style summary numbers (computed here, not from userData,
// because the backend's getPrintData only returns the legacy fields). -----
// Worked minutes: sum of calculateWorkMinutesPrint across work records.
let workedMinutes = 0;
let weekendHoursRaw = 0;
let nightMinutes = 0;
for (const r of userRecords) {
const leaveType = r.leave_type || "work";
if (leaveType !== "work") continue;
const m = calculateWorkMinutesPrint(r);
workedMinutes += m;
if (isWeekendDate(r.shift_date)) {
weekendHoursRaw += m / 60;
}
nightMinutes += calculateNightMinutes(r);
}
// Sum of vacation/sick/unpaid hours (in hours, not minutes). The
// "holiday" leave_type is auto-computed from Czech holidays further down
// and no longer selectable in the UI, so it's deliberately omitted here.
let vacationHours = 0,
sickHours = 0;
for (const r of userRecords) {
const lt = r.leave_type || "work";
if (lt === "work") continue;
const h = Number(r.leave_hours) || 8;
if (lt === "vacation") vacationHours += h;
else if (lt === "sick") sickHours += h;
}
// Odpracováno: floor(worked / 8) × 8.
const workedHoursRaw = workedMinutes / 60;
const odpracovano = Math.floor(workedHoursRaw / 8) * 8;
// Free Svátek: 8h per holiday date the user did not work.
const holidayDates = holidaysInMonth(yr, mo);
const holidayCount = holidayDates.length;
const workedDates = new Set(
userRecords
.filter((r) => (r.leave_type || "work") === "work")
.map((r) => normalizeDateStr(r.shift_date))
.filter(Boolean),
);
let freeHolidayHours = 0;
let workedHolidayCount = 0;
for (const hd of holidayDates) {
if (workedDates.has(hd)) workedHolidayCount++;
else freeHolidayHours += 8;
}
// Fund: count Mon-Fri (incl. holidays) × 8h.
let rawBizDays = 0;
const cur = new Date(yr, mo - 1, 1);
while (cur.getMonth() === mo - 1) {
const dow = cur.getDay();
if (dow !== 0 && dow !== 6) rawBizDays++;
cur.setDate(cur.getDate() + 1);
}
const businessDays = rawBizDays;
const fund = businessDays * 8;
// Včetně svátků a přesčasů: floor(worked/8)*8 worked_holiday*8 + vacation + remainder.
// (A worked holiday counts toward Odpracováno, but its 8h should not flow
// into Přesčas — so we subtract it here. Unworked holidays are paid via
// the fund and don't affect this line.)
const remainder = workedHoursRaw - odpracovano;
const vcetneSv =
odpracovano - workedHolidayCount * 8 + vacationHours + remainder;
// Přesčas = #2 - #1.
const prescas = vcetneSv - odpracovano;
const summaryHtml = `<div class="user-summary">
<div class="summary-row">
<span class="summary-label">Odpracováno:</span>
<span class="summary-value">${formatHoursDecimal(odpracovano * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Odpracováno včetně svátků a přesčasů:</span>
<span class="summary-value">${formatHoursDecimal(vcetneSv * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Dovolená:</span>
<span class="summary-value">${formatHoursDecimal(vacationHours * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Přesčas:</span>
<span class="summary-value">${formatHoursDecimal(prescas * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Svátek:</span>
<span class="summary-value">${formatHoursDecimal(freeHolidayHours * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">So/Ne:</span>
<span class="summary-value">${formatHoursDecimal(weekendHoursRaw * 60)}h</span>
</div>
<div class="summary-row">
<span class="summary-label">Práce v noci:</span>
<span class="summary-value">${formatHoursDecimal(nightMinutes)}h</span>
</div>
<div class="summary-row summary-fund">
<span class="summary-label">Fond měsíce:</span>
<span class="summary-value">${formatHoursDecimal(fund * 60)}h (${businessDays} dnů)</span>
</div>
<div class="legend">
<span class="legend-item"><span class="legend-swatch weekend"></span>Víkend (So/Ne)</span>
<span class="legend-item"><span class="legend-swatch holiday"></span>Svátek</span>
<span class="legend-item"><span class="legend-swatch weekend-holiday"></span>Víkend + svátek</span>
<span class="legend-item"><span class="legend-swatch vacation"></span>Dovolená</span>
</div>
</div>`;
return `<div class="user-section">
<div class="user-header">
<h3>${escapeHtml(userData.name)}</h3>
<span class="total">Odpracováno: ${escapeHtml(formatMinutes(userData.minutes))} h</span>
</div>
${leaveHtml}
<table>
<thead><tr>
<th style="width:70px">Datum</th>
<th style="width:70px">Typ</th>
<th class="text-center" style="width:70px">Příchod</th>
<th class="text-center" style="width:90px">Pauza</th>
<th class="text-center" style="width:70px">Odchod</th>
<th class="text-center" style="width:80px">Hodiny</th>
<th style="width:55px">Datum</th>
<th style="width:55px">Den</th>
<th style="width:60px">Typ</th>
<th class="text-center" style="width:55px">Příchod</th>
<th class="text-center" style="width:80px">Pauza</th>
<th class="text-center" style="width:55px">Odchod</th>
<th class="text-center" style="width:85px">Hodiny</th>
<th>Projekty</th>
<th>Poznámka</th>
</tr></thead>
<tbody>${recordRows}</tbody>
<tfoot>
<tr>
<td colspan="6" class="text-right">Odpracováno:</td>
<td class="text-center">${escapeHtml(formatMinutes(userData.minutes))} h</td>
<td colspan="2"></td>
</tr>
${fundRow}
</tfoot>
<tbody>${rows.join("")}</tbody>
</table>
${summaryHtml}
</div>`;
}
@@ -377,7 +608,13 @@ function buildPrintHtml(
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Docházka - ${escapeHtml(pData.month_name)}</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 11px; line-height: 1.4; color: #000; background: #fff; padding: 15mm;
@@ -407,19 +644,47 @@ function buildPrintHtml(
.user-section th { background: #333; color: #fff; font-weight: 600; font-size: 10px; text-transform: uppercase; }
.user-section td { font-size: 10px; }
.user-section tr:nth-child(even) { background: #f9f9f9; }
/* Weekend and holiday row highlighting — must come after the
:nth-child(even) rule and use higher specificity (tr.X td) to win. */
.user-section tr.weekend td { background: #fde68a; }
.user-section tr.holiday td { background: #bbf7d0; }
.user-section tr.weekend.holiday td { background: #fcd34d; }
.user-section tr.vacation td { background: #bfdbfe; }
.text-center { text-align: center; }
.text-right { text-align: right; }
.user-section tfoot td { background: #eee; font-weight: 600; }
.leave-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: 500; }
.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; }
.leave-summary {
margin-top: 10px; padding: 8px 15px; background: #f9f9f9;
border: 1px solid #ddd; font-size: 10px;
.user-summary {
margin-top: 10px; padding: 10px 15px;
background: #f9f9f9; border: 1px solid #ddd; font-size: 10px;
}
.user-summary .summary-row {
display: flex; justify-content: space-between; padding: 2px 0;
}
.user-summary .summary-label { color: #555; }
.user-summary .summary-value { font-weight: 600; }
.user-summary .summary-fund {
border-top: 1px solid #ddd; margin-top: 4px; padding-top: 6px;
}
.legend {
display: flex; flex-wrap: wrap; gap: 14px;
margin-top: 8px; padding-top: 6px;
border-top: 1px dashed #ddd;
font-size: 9px; color: #555;
}
.legend-item { display: inline-flex; align-items: center; gap: 5px; }
.legend-swatch {
display: inline-block; width: 12px; height: 12px;
border: 1px solid #333; vertical-align: middle;
}
.legend-swatch.weekend { background: #fde68a; }
.legend-swatch.holiday { background: #bbf7d0; }
.legend-swatch.weekend-holiday { background: #fcd34d; }
.legend-swatch.vacation { background: #bfdbfe; }
.print-wrapper-table { width: 100%; border-collapse: collapse; border: none; }
.print-wrapper-table > thead > tr > td,
.print-wrapper-table > tbody > tr > td { padding: 0; border: none; background: none; }
@@ -664,7 +929,15 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
formData: ShiftFormData,
): boolean => {
const totalWork = calcFormWorkMinutes(formData);
const totalProject = calcProjectMinutesTotal(logs);
const totalProject = calcProjectMinutesTotal(
logs.map((l) => ({
...l,
project_id:
l.project_id !== "" && l.project_id != null
? Number(l.project_id)
: undefined,
})),
);
if (totalWork > 0 && totalProject !== totalWork) {
const wH = Math.floor(totalWork / 60);
const wM = totalWork % 60;
@@ -869,7 +1142,7 @@ export default function useAttendanceAdmin({ alert }: AlertContext) {
const userName = record.users
? `${record.users.first_name} ${record.users.last_name}`.trim() ||
record.users.username
: ((record as Record<string, unknown>).user_name as string) ||
: ((record as unknown as Record<string, unknown>).user_name as string) ||
`User #${record.user_id}`;
const enriched = { ...record, user_name: userName };
setEditingRecord(enriched);