feat(platform): checkpoint current implementation state
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user