Files
kita-planer/src/lib/staff/calculations.ts
T

316 lines
9.0 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 { 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,
};
}