- 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>
344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
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;
|
||
};
|