Files
app/src/admin/utils/attendanceHelpers.ts
BOHA 522afefc80 refactor(frontend): fix undefined --danger-color, dedup formatDate & require2FAOptions, drop double plan.css import
- base.css: var(--danger-color) (undefined, no fallback -> inherited color) -> var(--danger)
- attendanceHelpers: re-export formatDate from formatters instead of a byte-identical copy
- dashboard.ts: remove dead duplicate require2FAOptions (canonical copy is in settings.ts)
- settings.ts: remove unused @deprecated systemSettingsOptions alias
- PlanWork: drop redundant per-page plan.css import (loaded globally in AdminApp)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:22:51 +02:00

344 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getHolidays } from "../../utils/czech-holidays";
interface AttendanceRecord {
user_id?: number;
arrival_time?: string | null;
departure_time?: string | null;
break_start?: string | null;
break_end?: string | null;
leave_type?: string;
leave_hours?: number;
shift_date?: string;
notes?: string | null;
project_logs?: Array<{
id?: number;
project_id?: number;
project_name?: string;
started_at?: string;
ended_at?: string | null;
hours?: string | number | null;
minutes?: string | number | null;
}>;
}
// formatDate is defined once in ./formatters and re-exported here so existing
// importers of attendanceHelpers keep working without a duplicate definition.
export { formatDate } from "./formatters";
/** Extract time as HH:MM from a datetime string without timezone conversion */
const extractTime = (datetime: string): string => {
// Try ISO format: "2026-03-23T08:00:00.000Z" or "2026-03-23T08:00:00"
const tMatch = datetime.match(/T(\d{2}):(\d{2})/);
if (tMatch) return `${tMatch[1]}:${tMatch[2]}`;
// Try space format: "2026-03-23 08:00:00"
const sMatch = datetime.match(/\s(\d{2}):(\d{2})/);
if (sMatch) return `${sMatch[1]}:${sMatch[2]}`;
// Fallback: try parsing time-only "08:00"
const hMatch = datetime.match(/^(\d{2}):(\d{2})/);
if (hMatch) return `${hMatch[1]}:${hMatch[2]}`;
return datetime;
};
export const formatDatetime = (datetime: string | null | undefined): string => {
if (!datetime) return "—";
// Extract date part without timezone conversion
const dMatch = datetime.match(/(\d{4})-(\d{2})-(\d{2})/);
const datePart = dMatch
? `${parseInt(dMatch[3])}.${parseInt(dMatch[2])}.`
: "";
return `${datePart} ${extractTime(datetime)}`;
};
export const formatTime = (datetime: string | null | undefined): string => {
if (!datetime) return "—";
return extractTime(datetime);
};
export const calculateWorkMinutes = (record: AttendanceRecord): number => {
if (!record.arrival_time || !record.departure_time) return 0;
const arrival = new Date(record.arrival_time).getTime();
const departure = new Date(record.departure_time).getTime();
let minutes = (departure - arrival) / 60000;
if (record.break_start && record.break_end) {
const breakStart = new Date(record.break_start).getTime();
const breakEnd = new Date(record.break_end).getTime();
minutes -= (breakEnd - breakStart) / 60000;
}
return Math.max(0, Math.floor(minutes));
};
export const formatMinutes = (minutes: number, withUnit = false): string => {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
return `${h}:${String(m).padStart(2, "0")}${withUnit ? " h" : ""}`;
};
export const getLeaveTypeName = (type: string): string => {
const types: Record<string, string> = {
work: "Práce",
vacation: "Dovolená",
sick: "Nemoc",
unpaid: "Neplacené volno",
};
return types[type] || "Práce";
};
export const getLeaveTypeBadgeClass = (type: string): string => {
const classes: Record<string, string> = {
vacation: "badge-vacation",
sick: "badge-sick",
unpaid: "badge-unpaid",
};
return classes[type] || "";
};
export const getDatePart = (datetime: string | null | undefined): string => {
if (!datetime) return "";
if (datetime.includes("T")) {
return datetime.split("T")[0];
}
return datetime.split(" ")[0];
};
export const getTimePart = (datetime: string | null | undefined): string => {
if (!datetime) return "";
const d = new Date(datetime);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
};
export const calcProjectMinutesTotal = (
logs: Array<{
project_id?: number;
hours?: string | number;
minutes?: string | number;
}>,
): number => {
return logs
.filter((l) => l.project_id)
.reduce((sum, l) => {
return (
sum +
(parseInt(String(l.hours)) || 0) * 60 +
(parseInt(String(l.minutes)) || 0)
);
}, 0);
};
interface ShiftForm {
arrival_time?: string;
departure_time?: string;
arrival_date?: string;
departure_date?: string;
break_start_time?: string;
break_end_time?: string;
break_start_date?: string;
break_end_date?: string;
}
export const calcFormWorkMinutes = (form: ShiftForm): number => {
if (!form.arrival_time || !form.departure_time) return 0;
const arrivalStr = `${form.arrival_date}T${form.arrival_time}`;
const departureStr = `${form.departure_date}T${form.departure_time}`;
let mins =
(new Date(departureStr).getTime() - new Date(arrivalStr).getTime()) / 60000;
if (form.break_start_time && form.break_end_time) {
const bsStr = `${form.break_start_date}T${form.break_start_time}`;
const beStr = `${form.break_end_date}T${form.break_end_time}`;
mins -= (new Date(beStr).getTime() - new Date(bsStr).getTime()) / 60000;
}
return Math.max(0, Math.floor(mins));
};
export const formatTimeOrDatetimePrint = (
datetime: string | null | undefined,
shiftDate: string,
): string => {
if (!datetime) return "—";
// Extract date from the datetime string directly (no timezone conversion)
const dateMatch = datetime.match(/(\d{4})-(\d{2})-(\d{2})/);
const timeDate = dateMatch
? `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`
: "";
if (timeDate && timeDate !== shiftDate) {
const datePart = `${parseInt(dateMatch![3])}.${parseInt(dateMatch![2])}.`;
return `${datePart} ${extractTime(datetime)}`;
}
return extractTime(datetime);
};
export const calculateWorkMinutesPrint = (record: AttendanceRecord): number => {
const leaveType = record.leave_type || "work";
if (leaveType !== "work") {
return (Number(record.leave_hours) || 8) * 60;
}
if (!record.arrival_time || !record.departure_time) return 0;
const arrival = new Date(record.arrival_time).getTime();
const departure = new Date(record.departure_time).getTime();
let minutes = (departure - arrival) / 60000;
if (record.break_start && record.break_end) {
const breakStart = new Date(record.break_start).getTime();
const breakEnd = new Date(record.break_end).getTime();
minutes -= (breakEnd - breakStart) / 60000;
}
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;
};