Add educator leave management and working time accounts
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user