feat(platform): checkpoint current implementation state

This commit is contained in:
2026-04-01 07:42:03 +02:00
parent 3e53471f05
commit 8c5be51251
125 changed files with 10269 additions and 17808 deletions
@@ -3,7 +3,6 @@
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ProgressRing } from "~/components/ui/ProgressRing.js";
interface BalanceCardProps {
resourceId: string;
year?: number;
@@ -11,7 +10,7 @@ interface BalanceCardProps {
}
export function BalanceCard({ resourceId, year = new Date().getFullYear(), compact = false }: BalanceCardProps) {
const { data: balance, isLoading } = trpc.entitlement.getBalance.useQuery(
const { data: balance, isLoading } = trpc.entitlement.getBalanceDetail.useQuery(
{ resourceId, year },
{ staleTime: 30_000 },
);
@@ -27,20 +26,20 @@ export function BalanceCard({ resourceId, year = new Date().getFullYear(), compa
if (!balance) return null;
const pct = balance.entitledDays > 0
? Math.round((balance.usedDays / balance.entitledDays) * 100)
const pct = balance.entitlement > 0
? Math.round((balance.taken / balance.entitlement) * 100)
: 0;
if (compact) {
return (
<div className="flex items-center gap-3 text-sm">
<span className="font-medium text-gray-900 dark:text-gray-100">{balance.remainingDays}d remaining</span>
<span className="font-medium text-gray-900 dark:text-gray-100">{balance.remaining}d remaining</span>
<span className="text-gray-400 dark:text-gray-600">·</span>
<span className="text-gray-500 dark:text-gray-400">{balance.usedDays}d used of {balance.entitledDays}d</span>
{balance.pendingDays > 0 && (
<span className="text-gray-500 dark:text-gray-400">{balance.taken}d used of {balance.entitlement}d</span>
{balance.pending > 0 && (
<>
<span className="text-gray-400 dark:text-gray-600">·</span>
<span className="text-amber-600">{balance.pendingDays}d pending</span>
<span className="text-amber-600">{balance.pending}d pending</span>
</>
)}
</div>
@@ -52,31 +51,36 @@ export function BalanceCard({ resourceId, year = new Date().getFullYear(), compa
: pct >= 70
? "var(--color-amber-500, #f59e0b)"
: "var(--color-emerald-500, #10b981)";
const holidayBasisVariants = balance.deductionSummary?.holidayBasisVariants ?? [];
const excludedHolidayCount = balance.deductionSummary?.excludedHolidayDates.length ?? 0;
const excludedHolidayTooltip = (balance.vacations ?? [])
.flatMap((vacation) => vacation.holidayDetails.map((detail) => `${detail.date} · ${detail.source}`))
.join("\n");
return (
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<ProgressRing value={pct} size={52} strokeWidth={4} color={ringColor}>
<span className="text-sm font-bold text-gray-900 dark:text-gray-100">{balance.remainingDays}d</span>
<span className="text-sm font-bold text-gray-900 dark:text-gray-100">{balance.remaining}d</span>
</ProgressRing>
<div>
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
Vacation Balance {year}
</h3>
<p className="text-xs text-gray-400 dark:text-gray-500">{balance.usedDays} of {balance.entitledDays} days used</p>
<p className="text-xs text-gray-400 dark:text-gray-500">{balance.taken} of {balance.entitlement} days used</p>
</div>
</div>
{balance.carryoverDays > 0 && (
<span className="text-xs text-gray-400 dark:text-gray-500 inline-flex items-center">+{balance.carryoverDays}d carried over<InfoTooltip content="Unused days from the previous year. Automatically calculated on first access." /></span>
{balance.carryOver > 0 && (
<span className="text-xs text-gray-400 dark:text-gray-500 inline-flex items-center">+{balance.carryOver}d carried over<InfoTooltip content="Unused days from the previous year. Automatically calculated on first access." /></span>
)}
</div>
<div className="grid grid-cols-4 gap-3">
<Stat label="Entitled" value={balance.entitledDays} color="text-gray-900 dark:text-gray-100" tooltip="Total vacation days granted for this year, including carryover from previous year." />
<Stat label="Used" value={balance.usedDays} color="text-gray-600 dark:text-gray-400" tooltip="Days already consumed by approved vacations that have passed." />
<Stat label="Pending" value={balance.pendingDays} color="text-amber-600" tooltip="Days reserved by approved future vacations not yet started." />
<Stat label="Remaining" value={balance.remainingDays} color={balance.remainingDays < 5 ? "text-red-600" : "text-emerald-600"} tooltip="Entitled - Used - Pending. Red if fewer than 5 days remain." />
<Stat label="Entitled" value={balance.entitlement} color="text-gray-900 dark:text-gray-100" tooltip="Total vacation days granted for this year, including carryover from previous year." />
<Stat label="Used" value={balance.taken} color="text-gray-600 dark:text-gray-400" tooltip="Days already consumed by approved vacations that have passed." />
<Stat label="Pending" value={balance.pending} color="text-amber-600" tooltip="Days reserved by approved future vacations not yet started." />
<Stat label="Remaining" value={balance.remaining} color={balance.remaining < 5 ? "text-red-600" : "text-emerald-600"} tooltip="Entitled - Used - Pending. Red if fewer than 5 days remain." />
</div>
{/* Progress bar */}
@@ -85,12 +89,12 @@ export function BalanceCard({ resourceId, year = new Date().getFullYear(), compa
className="absolute inset-y-0 left-0 bg-emerald-500 rounded-full transition-all"
style={{ width: `${Math.min(100, pct)}%` }}
/>
{balance.pendingDays > 0 && (
{balance.pending > 0 && (
<div
className="absolute inset-y-0 bg-amber-400 rounded-full"
style={{
left: `${Math.min(100, pct)}%`,
width: `${Math.min(100 - pct, Math.round((balance.pendingDays / balance.entitledDays) * 100))}%`,
width: `${Math.min(100 - pct, Math.round((balance.pending / balance.entitlement) * 100))}%`,
}}
/>
)}
@@ -101,6 +105,35 @@ export function BalanceCard({ resourceId, year = new Date().getFullYear(), compa
{balance.sickDays} sick day{balance.sickDays !== 1 ? "s" : ""} recorded (not deducted from annual leave)
</p>
)}
{!!balance.deductionSummary && (balance.deductionSummary.approvedVacationCount > 0 || balance.deductionSummary.pendingVacationCount > 0) && (
<div className="rounded-lg border border-gray-100 bg-gray-50 px-3 py-2 text-xs text-gray-500 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-400">
<div className="flex flex-wrap gap-x-3 gap-y-1">
<span className="inline-flex items-center gap-1">
Formula
<InfoTooltip content={balance.deductionSummary.formula} />
</span>
<span>
Vacation deductions: {balance.deductionSummary.approvedDeductedDays}d approved
{balance.deductionSummary.pendingDeductedDays > 0 ? ` · ${balance.deductionSummary.pendingDeductedDays}d pending` : ""}
</span>
<span>
Requested: {balance.deductionSummary.approvedRequestedDays + balance.deductionSummary.pendingRequestedDays}d
</span>
{excludedHolidayCount > 0 && (
<span className="inline-flex items-center gap-1">
Excluded holidays: {excludedHolidayCount}
{excludedHolidayTooltip.length > 0 && <InfoTooltip content={excludedHolidayTooltip} />}
</span>
)}
</div>
{holidayBasisVariants.length > 0 && (
<div className="mt-1">
Holiday basis: {holidayBasisVariants.join(" · ")}
</div>
)}
</div>
)}
</div>
);
}
@@ -79,6 +79,11 @@ export function EntitlementManager() {
<tr className="bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
<th className="text-left px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Resource</th>
<th className="text-left px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Chapter</th>
<th className="text-left px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center gap-0.5">
Location <InfoTooltip content="Country, state and city determine which regional holidays can reduce deducted vacation days differently per person." />
</span>
</th>
<th className="text-right px-4 py-2.5 text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
<span className="inline-flex items-center justify-end gap-0.5">
Entitled <InfoTooltip content="Total vacation days granted to this resource for the selected year." />
@@ -114,6 +119,11 @@ export function EntitlementManager() {
<span className="text-xs text-gray-400 dark:text-gray-500 ml-1">({row.eid})</span>
</td>
<td className="px-4 py-2.5 text-gray-500 dark:text-gray-400">{row.chapter ?? "—"}</td>
<td className="px-4 py-2.5 text-gray-500 dark:text-gray-400">
{[row.countryCode ?? row.countryName, row.federalState, row.metroCityName]
.filter((value): value is string => Boolean(value))
.join(" / ") || "—"}
</td>
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{row.entitledDays}</td>
<td className="px-4 py-2.5 text-right text-gray-400 dark:text-gray-500">{row.carryoverDays}</td>
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{row.usedDays}</td>
@@ -32,6 +32,28 @@ type CountryRow = {
metroCities: { id: string; name: string }[];
};
type HolidayPreviewDetail = {
count: number;
locationContext: {
countryCode: string | null;
stateCode: string | null;
metroCity: string | null;
year: number;
};
summary: {
byScope: Array<{ scope: string; count: number }>;
bySourceType: Array<{ sourceType: string; count: number }>;
byCalendar: Array<{ calendarName: string; count: number }>;
};
holidays: Array<{
date: string;
name: string;
scope: string;
calendarName: string;
sourceType: string;
}>;
};
const SCOPE_LABELS: Record<ScopeType, string> = {
COUNTRY: "Land",
STATE: "Bundesland/Region",
@@ -87,7 +109,15 @@ export function HolidayCalendarEditor() {
return rows.find((country) => country.id === selectedCalendar?.country.id) ?? null;
}, [countries, selectedCalendar]);
const previewQuery = trpc.holidayCalendar.previewResolvedHolidays.useQuery(
const previewQuery = (trpc.holidayCalendar.previewResolvedHolidaysDetail.useQuery as unknown as (
input: {
countryId: string;
year: number;
stateCode?: string;
metroCityId?: string;
},
options: { enabled: boolean; staleTime: number },
) => { data: HolidayPreviewDetail | undefined; isLoading: boolean })(
{
countryId: selectedCalendar?.country.id ?? countryId,
year: previewYear,
@@ -105,6 +135,7 @@ export function HolidayCalendarEditor() {
utils.holidayCalendar.listCalendars.invalidate(),
utils.holidayCalendar.getCalendarById.invalidate(),
utils.holidayCalendar.previewResolvedHolidays.invalidate(),
utils.holidayCalendar.previewResolvedHolidaysDetail.invalidate(),
]);
};
@@ -829,26 +860,45 @@ export function HolidayCalendarEditor() {
</div>
<div className="max-h-80 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
{previewQuery.data && (
<div className="border-b border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-300">
<div className="font-medium text-gray-700 dark:text-gray-200">
Basis: {[
previewQuery.data.locationContext.countryCode,
previewQuery.data.locationContext.stateCode,
previewQuery.data.locationContext.metroCity,
].filter(Boolean).join(" / ") || "n/a"}
</div>
<div className="mt-1">
Scope: {previewQuery.data.summary.byScope.map((item) => `${item.scope} ${item.count}`).join(" · ") || "none"}
</div>
<div className="mt-1">
Sources: {previewQuery.data.summary.bySourceType.map((item) => `${item.sourceType} ${item.count}`).join(" · ") || "none"}
</div>
</div>
)}
<table data-testid="holiday-preview-table" className="w-full text-sm">
<thead className="bg-gray-50 dark:bg-gray-900/60">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Datum</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Name</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Scope</th>
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Quelle</th>
</tr>
</thead>
<tbody>
{(previewQuery.data ?? []).length === 0 && (
{(!previewQuery.data || previewQuery.data.holidays.length === 0) && (
<tr>
<td colSpan={3} className="px-3 py-6 text-center text-sm text-gray-400">
<td colSpan={4} className="px-3 py-6 text-center text-sm text-gray-400">
{previewQuery.isLoading ? "Laedt Vorschau..." : "Keine Feiertage fuer diese Auswahl vorhanden."}
</td>
</tr>
)}
{(previewQuery.data ?? []).map((entry) => (
{(previewQuery.data?.holidays ?? []).map((entry) => (
<tr key={`${entry.date}-${entry.name}`} className="border-t border-gray-200 dark:border-gray-700">
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{entry.date}</td>
<td className="px-3 py-2 text-gray-900 dark:text-gray-100">{entry.name}</td>
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{entry.scope}</td>
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{entry.calendarName}</td>
</tr>
))}
@@ -8,6 +8,17 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { BalanceCard } from "./BalanceCard.js";
import { VacationCalendar } from "./VacationCalendar.js";
import { VACATION_STATUS_BADGE as STATUS_BADGE, VACATION_TYPE_LABELS as TYPE_LABELS, VACATION_TYPE_BADGE } from "~/lib/status-styles.js";
import { getHolidayBasis, getHolidayBreakdown, getRequestedDays, type VacationExplainabilityEntry } from "./vacationExplainability.js";
type VacationListItem = VacationExplainabilityEntry & {
id: string;
startDate: Date | string;
endDate: Date | string;
status: string;
type: string;
note?: string | null;
rejectionReason?: string | null;
};
export function MyVacationsClient() {
const [showModal, setShowModal] = useState(false);
@@ -21,8 +32,17 @@ export function MyVacationsClient() {
const resourceId = myResource?.id;
const { data: vacations, isLoading, refetch } = trpc.vacation.list.useQuery(
{ resourceId, limit: 200 },
const vacationListQuery = trpc.vacation.list.useQuery as unknown as (
input: { limit: number; resourceId?: string | undefined },
options: { enabled: boolean; staleTime: number },
) => {
data: VacationListItem[] | undefined;
isLoading: boolean;
refetch: () => Promise<unknown>;
};
const { data: vacations, isLoading, refetch } = vacationListQuery(
{ limit: 200, ...(resourceId ? { resourceId } : {}) },
{ enabled: !!resourceId, staleTime: 15_000 },
);
@@ -33,7 +53,7 @@ export function MyVacationsClient() {
},
});
const vacationList = vacations ?? [];
const vacationList: VacationListItem[] = vacations ?? [];
return (
<div className="p-6 max-w-4xl mx-auto space-y-6">
@@ -102,10 +122,13 @@ export function MyVacationsClient() {
{vacationList.map((v) => {
const start = new Date(v.startDate);
const end = new Date(v.endDate);
const days = Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1;
const requestedDays = getRequestedDays(v);
const deductedDays = typeof v.deductedDays === "number" ? v.deductedDays : null;
const holidayBasis = getHolidayBasis(v);
const holidayBreakdown = getHolidayBreakdown(v);
const status = v.status as string;
const type = v.type as string;
const vWithExtra = v as unknown as { rejectionReason?: string | null; isHalfDay?: boolean };
const affectsBalance = type === VacationType.ANNUAL || type === VacationType.OTHER;
return (
<tr key={v.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
@@ -116,16 +139,54 @@ export function MyVacationsClient() {
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{start.toLocaleDateString("en-GB")}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{end.toLocaleDateString("en-GB")}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{vWithExtra.isHalfDay ? "0.5" : days}</td>
<td className="px-4 py-3 text-right text-gray-600 dark:text-gray-400">
<div className="space-y-1">
<div>{requestedDays}</div>
{affectsBalance && deductedDays !== null && (
<div className="text-[11px] text-gray-400 dark:text-gray-500">
Deducted: {deductedDays}
</div>
)}
{affectsBalance && deductedDays === 0 && holidayBreakdown.length > 0 && (
<div className="text-[11px] text-emerald-600 dark:text-emerald-400">
Fully covered by holidays
</div>
)}
</div>
</td>
<td className="px-4 py-3">
<span className={`inline-flex px-2 py-0.5 rounded-full text-xs font-medium ${STATUS_BADGE[status] ?? ""}`}>
{status}
</span>
</td>
<td className="px-4 py-3 text-gray-400 dark:text-gray-500 text-xs max-w-[200px]">
{vWithExtra.rejectionReason ? (
<span className="text-red-500">{vWithExtra.rejectionReason}</span>
) : (v.note ?? "—")}
<td className="px-4 py-3 text-gray-400 dark:text-gray-500 text-xs max-w-[280px]">
{v.rejectionReason ? (
<span className="text-red-500">{v.rejectionReason}</span>
) : (
<div className="space-y-1">
<div>{v.note ?? "—"}</div>
{affectsBalance && holidayBasis.length > 0 && (
<div className="text-[11px] text-gray-500 dark:text-gray-400">
Holiday basis: {holidayBasis.join(" / ")}
</div>
)}
{affectsBalance && holidayBreakdown.length > 0 && (
<div className="text-[11px] text-gray-500 dark:text-gray-400">
Excluded holidays: {holidayBreakdown.map((holiday) => `${holiday.date} (${holiday.source})`).join(", ")}
</div>
)}
</div>
)}
{!v.rejectionReason && affectsBalance && deductedDays !== null && deductedDays !== requestedDays && holidayBreakdown.length === 0 && (
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
Local holiday-adjusted deduction snapshot applied.
</div>
)}
{!v.rejectionReason && !affectsBalance && (
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
This leave type does not reduce annual vacation entitlement.
</div>
)}
</td>
<td className="px-4 py-3 text-right">
{(status === VacationStatus.PENDING || status === VacationStatus.APPROVED) && (
@@ -1,10 +1,11 @@
"use client";
import { useState } from "react";
import { VacationStatus, VacationType } from "@capakraken/shared";
import { VacationStatus } from "@capakraken/shared";
import { VACATION_CALENDAR_COLORS } from "~/lib/status-styles.js";
import { buildVacationExplainabilityTooltip, type VacationExplainabilityEntry } from "./vacationExplainability.js";
interface VacationEntry {
interface VacationEntry extends VacationExplainabilityEntry {
id: string;
startDate: Date | string;
endDate: Date | string;
@@ -142,11 +143,12 @@ export function VacationCalendar({ vacations, year = new Date().getFullYear(), i
const colorClass = VACATION_CALENDAR_COLORS[v.type] ?? "bg-gray-400";
const opacityClass = STATUS_OPACITY[v.status] ?? "opacity-100";
const name = v.resource?.displayName ?? "—";
const explainabilityTitle = buildVacationExplainabilityTooltip(v);
return (
<div
key={v.id + dateStr}
className={`${colorClass} ${opacityClass} text-white text-xs px-1 rounded truncate`}
title={`${name}${v.type} (${v.status})`}
title={[`${name}${v.type} (${v.status})`, explainabilityTitle].filter(Boolean).join("\n")}
>
{name.split(" ")[0]}
</div>
@@ -0,0 +1,88 @@
"use client";
export const HOLIDAY_SOURCE_LABELS = {
CALENDAR: "Holiday Calendar",
LEGACY_PUBLIC_HOLIDAY: "Legacy import",
CALENDAR_AND_LEGACY: "Holiday Calendar + legacy",
} as const;
export type VacationExplainabilityEntry = {
startDate: Date | string;
endDate: Date | string;
isHalfDay?: boolean | null;
deductedDays?: number | null;
holidayCountryCode?: string | null;
holidayCountryName?: string | null;
holidayFederalState?: string | null;
holidayMetroCityName?: string | null;
holidayCalendarDates?: unknown;
holidayLegacyPublicHolidayDates?: unknown;
};
function toSortedDateList(value: unknown): string[] {
return Array.isArray(value) && value.every((entry) => typeof entry === "string")
? [...value].sort()
: [];
}
export function getRequestedDays(vacation: Pick<VacationExplainabilityEntry, "startDate" | "endDate" | "isHalfDay">): number {
if (vacation.isHalfDay) {
return 0.5;
}
const start = new Date(vacation.startDate);
const end = new Date(vacation.endDate);
return Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1;
}
export function getHolidayBasis(vacation: VacationExplainabilityEntry): string[] {
return [
vacation.holidayCountryName ?? vacation.holidayCountryCode ?? null,
vacation.holidayFederalState ?? null,
vacation.holidayMetroCityName ?? null,
].filter((value): value is string => Boolean(value));
}
export function getHolidayBreakdown(vacation: VacationExplainabilityEntry): Array<{ date: string; source: string }> {
const calendarDates = toSortedDateList(vacation.holidayCalendarDates);
const legacyDates = toSortedDateList(vacation.holidayLegacyPublicHolidayDates);
const uniqueDates = [...new Set([...calendarDates, ...legacyDates])].sort();
return uniqueDates.map((date) => ({
date,
source:
calendarDates.includes(date) && legacyDates.includes(date)
? HOLIDAY_SOURCE_LABELS.CALENDAR_AND_LEGACY
: calendarDates.includes(date)
? HOLIDAY_SOURCE_LABELS.CALENDAR
: HOLIDAY_SOURCE_LABELS.LEGACY_PUBLIC_HOLIDAY,
}));
}
export function buildVacationExplainabilityTooltip(
vacation: VacationExplainabilityEntry & { type?: string | null },
): string | null {
const requestedDays = getRequestedDays(vacation);
const deductedDays = typeof vacation.deductedDays === "number" ? vacation.deductedDays : null;
const holidayBasis = getHolidayBasis(vacation);
const holidayBreakdown = getHolidayBreakdown(vacation);
const lines = [`Requested: ${requestedDays}d`];
if (deductedDays !== null) {
lines.push(`Deducted: ${deductedDays}d`);
}
if (holidayBasis.length > 0) {
lines.push(`Holiday basis: ${holidayBasis.join(" / ")}`);
}
if (holidayBreakdown.length > 0) {
lines.push(`Excluded holidays: ${holidayBreakdown.map((holiday) => `${holiday.date} (${holiday.source})`).join(", ")}`);
}
if ((vacation.type === "SICK" || vacation.type === "PUBLIC_HOLIDAY") && deductedDays === 0) {
lines.push("Does not reduce annual vacation entitlement.");
}
return lines.length > 0 ? lines.join("\n") : null;
}