Add educator leave management and working time accounts

This commit is contained in:
t.indorf
2026-10-07 15:25:55 +02:00
parent ec3f4f933d
commit 043fed2e46
19 changed files with 5631 additions and 200 deletions
+315
View File
@@ -0,0 +1,315 @@
import { z } from "zod";
export const DAY_NAMES = [
"Montag",
"Dienstag",
"Mittwoch",
"Donnerstag",
"Freitag",
"Samstag",
"Sonntag",
];
export const STATUS_LABELS: Record<string, string> = {
PENDING: "Beantragt",
APPROVED: "Genehmigt",
REJECTED: "Abgelehnt",
CANCEL_REQUESTED: "Stornierung beantragt",
CANCELLED: "Storniert",
DRAFT: "Offen",
SUBMITTED: "Zur Prüfung",
};
export const KIND_LABELS: Record<string, string> = {
VACATION: "Urlaub",
TIME_OFF: "Freizeitausgleich",
SICKNESS: "Krankheit",
};
export const dateSchema = z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.refine((key) => {
const d = new Date(`${key}T00:00:00Z`);
return (
!Number.isNaN(d.getTime()) &&
d.toISOString().slice(0, 10) === key &&
key >= "2000-01-01" &&
key <= "2100-12-31"
);
}, "Bitte ein gültiges Datum zwischen 2000 und 2100 angeben.");
export function dateKey(date: Date) {
return date.toISOString().slice(0, 10);
}
export function dateOnly(key: string) {
return new Date(`${dateSchema.parse(key)}T00:00:00Z`);
}
export function todayKey(now = new Date()) {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(now);
}
export function addDays(key: string, amount: number) {
const d = dateOnly(key);
d.setUTCDate(d.getUTCDate() + amount);
return dateKey(d);
}
export function weekStart(key: string) {
const day = dateOnly(key).getUTCDay();
return addDays(key, -(day === 0 ? 6 : day - 1));
}
export function datesBetween(start: string, end: string) {
if (end < start)
throw new Error("Das Enddatum darf nicht vor dem Beginn liegen.");
if ((dateOnly(end).getTime() - dateOnly(start).getTime()) / 86400000 > 370)
throw new Error("Bitte höchstens ein Jahr auf einmal auswählen.");
const days: string[] = [];
for (let d = start; d <= end; d = addDays(d, 1)) days.push(d);
return days;
}
export function formatMinutes(minutes: number) {
const abs = Math.abs(Math.round(minutes));
return `${minutes < 0 ? "−" : ""}${Math.floor(abs / 60)}:${String(abs % 60).padStart(2, "0")} h`;
}
export function shortDate(key: string) {
return new Intl.DateTimeFormat("de-DE", {
timeZone: "UTC",
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(dateOnly(key));
}
export function clockMinute(value: string) {
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(value))
throw new Error("Bitte eine gültige Uhrzeit angeben.");
const [h, m] = value.split(":").map(Number);
return h * 60 + m;
}
export function minuteClock(value: number) {
return `${String(Math.floor(value / 60)).padStart(2, "0")}:${String(value % 60).padStart(2, "0")}`;
}
export type Schedule = {
employmentStart: Date;
employmentEnd: Date | null;
trackingStart: Date;
workPatterns: { effectiveFrom: Date; dailyMinutes: number[] }[];
dayOverrides: { date: Date; minutes: number }[];
};
export function scheduledMinutes(
profile: Schedule,
key: string,
holidays: Set<string>,
) {
if (
key < dateKey(profile.employmentStart) ||
(profile.employmentEnd && key > dateKey(profile.employmentEnd)) ||
holidays.has(key)
)
return 0;
const override = profile.dayOverrides.find((o) => dateKey(o.date) === key);
if (override) return override.minutes;
const pattern = profile.workPatterns
.filter((p) => dateKey(p.effectiveFrom) <= key)
.sort((a, b) => b.effectiveFrom.getTime() - a.effectiveFrom.getTime())[0];
const weekday = dateOnly(key).getUTCDay();
return pattern?.dailyMinutes[weekday === 0 ? 6 : weekday - 1] ?? 0;
}
export type BookingDay = {
date: string;
vacationUnits: number;
creditMinutes: number;
};
export function calculateLeaveDays(
profile: Schedule,
holidays: Set<string>,
input: {
kind: string;
startDate: string;
endDate: string;
halfDay: boolean;
minutes?: number;
},
) {
if (
input.startDate < dateKey(profile.employmentStart) ||
(profile.employmentEnd && input.endDate > dateKey(profile.employmentEnd))
)
throw new Error("Der Zeitraum liegt außerhalb der Beschäftigung.");
if (input.halfDay && input.startDate !== input.endDate)
throw new Error("Halbe Tage bitte einzeln beantragen.");
if (input.kind === "TIME_OFF" && input.startDate !== input.endDate)
throw new Error(
"Freizeitausgleich bitte für einen einzelnen Tag beantragen.",
);
const days: BookingDay[] = [];
for (const date of datesBetween(input.startDate, input.endDate)) {
const expected = scheduledMinutes(profile, date, holidays);
if (!expected) continue;
const creditMinutes =
input.kind === "TIME_OFF"
? (input.minutes ?? 0)
: input.halfDay
? Math.round(expected / 2)
: expected;
if (creditMinutes <= 0 || creditMinutes > expected)
throw new Error(
"Freizeitausgleich muss zwischen einer Minute und der Sollzeit dieses Tages liegen.",
);
days.push({
date,
creditMinutes,
vacationUnits: input.kind === "VACATION" ? (input.halfDay ? 1 : 2) : 0,
});
}
if (!days.length)
throw new Error("Im ausgewählten Zeitraum liegen keine Arbeitstage.");
return days;
}
export type DailyTotal = {
date: string;
expected: number;
worked: number;
credit: number;
timeOff: number;
balance: number;
};
export function calculateWeek(
profile: Schedule,
holidays: Set<string>,
start: string,
entries: {
date: Date;
startMinute: number;
endMinute: number;
breakMinutes: number;
}[],
leaves: {
kind: string;
status: string;
days: { date: Date; creditMinutes: number }[];
}[],
): DailyTotal[] {
return datesBetween(start, addDays(start, 6)).map((date) => {
const expected =
date < dateKey(profile.trackingStart)
? 0
: scheduledMinutes(profile, date, holidays);
const worked = entries
.filter((e) => dateKey(e.date) === date)
.reduce(
(sum, e) => sum + e.endMinute - e.startMinute - e.breakMinutes,
0,
);
let credit = 0,
timeOff = 0;
for (const leave of leaves) {
if (!["APPROVED", "CANCEL_REQUESTED"].includes(leave.status)) continue;
const minutes = leave.days
.filter((d) => dateKey(d.date) === date)
.reduce((sum, d) => sum + d.creditMinutes, 0);
if (leave.kind === "TIME_OFF") timeOff += minutes;
else credit += minutes;
}
return {
date,
expected,
worked,
credit,
timeOff,
balance: worked + credit - expected,
};
});
}
export function readSnapshot(value: unknown): DailyTotal[] {
const parsed = z
.array(
z.object({
date: dateSchema,
expected: z.number().int(),
worked: z.number().int(),
credit: z.number().int(),
timeOff: z.number().int(),
balance: z.number().int(),
}),
)
.safeParse(value);
return parsed.success ? parsed.data : [];
}
export function getTimeBalance(
profile: { openingMinutes: number; trackingStart: Date },
weeks: { status: string; dailySnapshot: unknown }[],
adjustments: { date: Date; minutes: number }[],
leaves: {
kind: string;
status: string;
days: { date: Date; creditMinutes: number }[];
}[],
asOf = todayKey(),
) {
const approvedDays = weeks
.filter((w) => w.status === "APPROVED")
.flatMap((w) => readSnapshot(w.dailySnapshot));
const postedDates = new Set(
approvedDays.filter((d) => d.date <= asOf).map((d) => d.date),
);
const confirmed =
profile.openingMinutes +
approvedDays
.filter((d) => d.date <= asOf)
.reduce((sum, d) => sum + d.balance, 0) +
adjustments
.filter((a) => dateKey(a.date) <= asOf)
.reduce((sum, a) => sum + a.minutes, 0);
const reserved = leaves
.filter(
(l) =>
l.kind === "TIME_OFF" &&
["APPROVED", "CANCEL_REQUESTED"].includes(l.status),
)
.flatMap((l) => l.days)
.filter(
(d) =>
dateKey(d.date) >= dateKey(profile.trackingStart) &&
!postedDates.has(dateKey(d.date)),
)
.reduce((sum, d) => sum + d.creditMinutes, 0);
return { confirmed, reserved, available: confirmed - reserved };
}
export function vacationBalance(
account: { year: number; entitlementUnits: number; carryUnits: number },
leaves: {
kind: string;
status: string;
days: { date: Date; vacationUnits: number }[];
}[],
asOf = todayKey(),
) {
let taken = 0,
planned = 0,
pending = 0;
for (const leave of leaves.filter((l) => l.kind === "VACATION")) {
for (const day of leave.days.filter(
(d) => d.date.getUTCFullYear() === account.year,
)) {
if (leave.status === "PENDING") pending += day.vacationUnits;
if (["APPROVED", "CANCEL_REQUESTED"].includes(leave.status)) {
if (dateKey(day.date) < asOf) taken += day.vacationUnits;
else planned += day.vacationUnits;
}
}
}
return {
entitlement: account.entitlementUnits + account.carryUnits,
taken,
planned,
pending,
available: account.entitlementUnits + account.carryUnits - taken - planned,
};
}