diff --git a/src/admin/pages/AttendanceBalances.tsx b/src/admin/pages/AttendanceBalances.tsx index b1ef596..6089a1e 100644 --- a/src/admin/pages/AttendanceBalances.tsx +++ b/src/admin/pages/AttendanceBalances.tsx @@ -347,7 +347,7 @@ export default function AttendanceBalances() { Zbývá (h) Nemoc (h) Fond roku - Odpracováno + Pokryto +/− Akce diff --git a/src/services/attendance.service.ts b/src/services/attendance.service.ts index a31d66f..367b4cf 100644 --- a/src/services/attendance.service.ts +++ b/src/services/attendance.service.ts @@ -1,5 +1,6 @@ import { attendance_leave_type } from '@prisma/client'; import prisma from '../config/database'; +import { getBusinessDaysInMonth } from '../utils/czech-holidays'; const VALID_LEAVE_TYPES = ['work', 'vacation', 'sick', 'holiday', 'unpaid'] as const; @@ -10,16 +11,6 @@ const MONTH_NAMES = [ // ── Helpers ────────────────────────────────────────────────────────── -function countWorkingDays(year: number, month: number, upToDay?: number): number { - let count = 0; - const cur = new Date(year, month, 1); - while (cur.getMonth() === month && (!upToDay || cur.getDate() <= upToDay)) { - const dow = cur.getDay(); - if (dow !== 0 && dow !== 6) count++; - cur.setDate(cur.getDate() + 1); - } - return count; -} function calcWorkedHours(arrival: Date, departure: Date, breakStart: Date | null, breakEnd: Date | null): number { let mins = (departure.getTime() - arrival.getTime()) / 60000; @@ -162,7 +153,7 @@ export async function getStatus(userId: number) { where: { user_id: userId, shift_date: { gte: monthStart, lte: monthEnd } }, }); - const workingDays = countWorkingDays(y, m); + const workingDays = getBusinessDaysInMonth(y, m); const fund = workingDays * 8; let workedHours = 0; @@ -375,8 +366,8 @@ export async function getWorkfund(year: number) { for (let m = 0; m <= maxMonth; m++) { const isCurrentMonth = year === currentYear && m === currentMonth; - const bizDays = countWorkingDays(year, m); - const bizDaysToDate = isCurrentMonth ? countWorkingDays(year, m, now.getDate()) : bizDays; + const bizDays = getBusinessDaysInMonth(year, m); + const bizDaysToDate = isCurrentMonth ? getBusinessDaysInMonth(year, m, now.getDate()) : bizDays; const fund = bizDays * 8; const fundToDate = bizDaysToDate * 8; const monthStart = new Date(year, m, 1); @@ -406,10 +397,9 @@ export async function getWorkfund(year: number) { } } - const userFund = bizDaysToDate * 8; + const userFund = fundToDate; const workedRound = Math.round(worked * 10) / 10; - const holidayHours = holidayDays * 8; - const leaveHours = vacationHours + sickHours + holidayHours; + const leaveHours = vacationHours + sickHours; const covered = Math.round((worked + leaveHours) * 10) / 10; const missing = Math.max(0, Math.round((userFund - covered) * 10) / 10); const overtime = Math.max(0, Math.round((covered - userFund) * 10) / 10); @@ -552,7 +542,7 @@ export async function getPrintData(monthStr: string, filterUserId: number | null orderBy: [{ users: { last_name: 'asc' } }, { shift_date: 'asc' }], }); - const fundHours = countWorkingDays(yr, mo - 1) * 8; + const fundHours = getBusinessDaysInMonth(yr, mo - 1) * 8; // Load project names for enrichment const projectIds = [...new Set(records.flatMap(r => (r as any).attendance_project_logs?.map((l: any) => l.project_id) || []).filter(Boolean))]; @@ -656,7 +646,7 @@ export async function getPrintData(monthStr: string, filterUserId: number | null selected_user: filterUserId, selected_user_name: selectedUserName, year: yr, - fund: { business_days: countWorkingDays(yr, mo - 1), hours: fundHours }, + fund: { business_days: getBusinessDaysInMonth(yr, mo - 1), hours: fundHours }, }; } diff --git a/src/utils/czech-holidays.ts b/src/utils/czech-holidays.ts new file mode 100644 index 0000000..415e162 --- /dev/null +++ b/src/utils/czech-holidays.ts @@ -0,0 +1,92 @@ +/** + * Czech public holidays & work fund calculator. + * Port of PHP CzechHolidays class. + */ + +const holidayCache = new Map(); + +/** Easter Sunday using the Anonymous Gregorian algorithm */ +function getEasterSunday(year: number): string { + const a = year % 19; + const b = Math.floor(year / 100); + const c = year % 100; + const d = Math.floor(b / 4); + const e = b % 4; + const f = Math.floor((b + 8) / 25); + const g = Math.floor((b - f + 1) / 3); + const h = (19 * a + b - d - g + 15) % 30; + const i = Math.floor(c / 4); + const k = c % 4; + const l = (32 + 2 * e + 2 * i - h - k) % 7; + const m = Math.floor((a + 11 * h + 22 * l) / 451); + const month = Math.floor((h + l - 7 * m + 114) / 31); + const day = ((h + l - 7 * m + 114) % 31) + 1; + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + +/** All Czech public holidays for a year (11 fixed + 2 Easter-based) */ +export function getHolidays(year: number): string[] { + if (holidayCache.has(year)) return holidayCache.get(year)!; + + const y = String(year); + const holidays = [ + `${y}-01-01`, // Den obnovy samostatného českého státu + `${y}-05-01`, // Svátek práce + `${y}-05-08`, // Den vítězství + `${y}-07-05`, // Den slovanských věrozvěstů Cyrila a Metoděje + `${y}-07-06`, // Den upálení mistra Jana Husa + `${y}-09-28`, // Den české státnosti + `${y}-10-28`, // Den vzniku samostatného československého státu + `${y}-11-17`, // Den boje za svobodu a demokracii + `${y}-12-24`, // Štědrý den + `${y}-12-25`, // 1. svátek vánoční + `${y}-12-26`, // 2. svátek vánoční + ]; + + // Easter-based + const easterSunday = getEasterSunday(year); + const easterDate = new Date(easterSunday); + const goodFriday = new Date(easterDate); + goodFriday.setDate(goodFriday.getDate() - 2); + const easterMonday = new Date(easterDate); + easterMonday.setDate(easterMonday.getDate() + 1); + + const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + holidays.push(fmt(goodFriday)); // Velký pátek + holidays.push(fmt(easterMonday)); // Velikonoční pondělí + + holidays.sort(); + holidayCache.set(year, holidays); + return holidays; +} + +/** Check if a date string (YYYY-MM-DD) is a Czech public holiday */ +export function isHoliday(dateStr: string): boolean { + const year = parseInt(dateStr.substring(0, 4), 10); + return getHolidays(year).includes(dateStr); +} + +/** Business days in a month (Mon-Fri excluding public holidays) */ +export function getBusinessDaysInMonth(year: number, month: number, upToDay?: number): number { + const holidays = getHolidays(year); + let count = 0; + const daysInMonth = new Date(year, month + 1, 0).getDate(); + const maxDay = upToDay ? Math.min(upToDay, daysInMonth) : daysInMonth; + + for (let day = 1; day <= maxDay; day++) { + const date = new Date(year, month, day); + const dow = date.getDay(); + if (dow !== 0 && dow !== 6) { + const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + if (!holidays.includes(dateStr)) { + count++; + } + } + } + return count; +} + +/** Monthly work fund in hours (business days × 8) */ +export function getMonthlyWorkFund(year: number, month: number, upToDay?: number): number { + return getBusinessDaysInMonth(year, month, upToDay) * 8; +}