import { VacationType, VacationStatus } from "@capakraken/db"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; import { createAuditEntry } from "../lib/audit.js"; import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js"; import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js"; import { countVacationChargeableDaysFromSnapshot, parseVacationSnapshotDateList, } from "../lib/vacation-deduction-snapshot.js"; import type { TRPCContext } from "../trpc.js"; import { buildVacationPreview } from "./vacation-read-support.js"; /** Types that consume from annual leave balance */ const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER]; type EntitlementSnapshot = { id: string; entitledDays: number; carryoverDays: number; usedDays: number; pendingDays: number; }; type EntitlementReadContext = Pick; type EntitlementWriteContext = Pick; type EntitlementVacationStatus = "APPROVED" | "PENDING"; type EntitlementVacationExplainability = { type: VacationType; status: EntitlementVacationStatus; startDate: string; endDate: string; isHalfDay: boolean; requestedDays: number; deductedDays: number; holidayCountryCode: string | null; holidayCountryName: string | null; holidayFederalState: string | null; holidayMetroCityName: string | null; holidayCalendarDates: string[]; holidayLegacyPublicHolidayDates: string[]; holidayDetails: Array<{ date: string; source: "CALENDAR" | "LEGACY_PUBLIC_HOLIDAY" | "CALENDAR_AND_LEGACY"; }>; holidayContext: { countryCode: string | null; countryName: string | null; federalState: string | null; metroCityName: string | null; sources: { hasCalendarHolidays: boolean; hasLegacyPublicHolidayEntries: boolean; }; }; }; export const EntitlementBalanceInputSchema = z.object({ resourceId: z.string(), year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()), }); export const EntitlementGetInputSchema = z.object({ resourceId: z.string(), year: z.number().int(), }); export const EntitlementSetInputSchema = z.object({ resourceId: z.string(), year: z.number().int(), entitledDays: z.number().min(0).max(365), }); export const EntitlementBulkSetInputSchema = z.object({ year: z.number().int(), entitledDays: z.number().min(0).max(365), resourceIds: z.array(z.string()).optional(), }); export const EntitlementYearSummaryInputSchema = z.object({ year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()), chapter: z.string().optional(), }); export const EntitlementYearSummaryDetailInputSchema = EntitlementYearSummaryInputSchema.extend({ resourceName: z.string().optional(), }); function mapBalanceDetail(resource: { displayName: string; eid: string; }, balance: { year: number; entitledDays: number; carryoverDays: number; usedDays: number; pendingDays: number; remainingDays: number; sickDays: number; deductionSummary?: { formula: string; approvedVacationCount: number; pendingVacationCount: number; approvedRequestedDays: number; pendingRequestedDays: number; approvedDeductedDays: number; pendingDeductedDays: number; excludedHolidayDates: string[]; holidayBasisVariants: string[]; sources: { hasCalendarHolidays: boolean; hasLegacyPublicHolidayEntries: boolean; }; }; vacations?: EntitlementVacationExplainability[]; }) { return { resource: resource.displayName, eid: resource.eid, year: balance.year, entitlement: balance.entitledDays, carryOver: balance.carryoverDays, taken: balance.usedDays, pending: balance.pendingDays, remaining: balance.remainingDays, sickDays: balance.sickDays, ...(balance.deductionSummary ? { deductionSummary: balance.deductionSummary } : {}), ...(balance.vacations ? { vacations: balance.vacations } : {}), }; } function mapYearSummaryDetail( year: number, summaries: Array<{ displayName: string; eid: string; chapter: string | null; countryCode: string | null; countryName: string | null; federalState: string | null; metroCityName: string | null; entitledDays: number; carryoverDays: number; usedDays: number; pendingDays: number; remainingDays: number; }>, resourceName?: string, ) { const needle = resourceName?.toLowerCase(); return summaries .filter((summary) => { if (!needle) { return true; } return summary.displayName.toLowerCase().includes(needle) || summary.eid.toLowerCase().includes(needle); }) .slice(0, 50) .map((summary) => ({ resource: summary.displayName, eid: summary.eid, chapter: summary.chapter ?? null, countryCode: summary.countryCode ?? null, countryName: summary.countryName ?? null, federalState: summary.federalState ?? null, metroCityName: summary.metroCityName ?? null, year, entitled: summary.entitledDays, carryover: summary.carryoverDays, used: summary.usedDays, pending: summary.pendingDays, remaining: summary.remainingDays, })); } async function readBalanceSnapshot( ctx: EntitlementReadContext, input: z.infer, ) { if (ctx.dbUser) { const allowedRoles = ["ADMIN", "MANAGER", "CONTROLLER"]; if (!allowedRoles.includes(ctx.dbUser.systemRole)) { const resource = await ctx.db.resource.findUnique({ where: { id: input.resourceId }, select: { userId: true }, }); if (!resource || resource.userId !== ctx.dbUser.id) { throw new TRPCError({ code: "FORBIDDEN", message: "You can only view your own vacation balance", }); } } } const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); const defaultDays = settings?.vacationDefaultDays ?? 28; const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays); const sickVacationsResult = await ctx.db.vacation.findMany({ where: { resourceId: input.resourceId, type: VacationType.SICK, status: VacationStatus.APPROVED, startDate: { lte: new Date(`${input.year}-12-31T00:00:00.000Z`) }, endDate: { gte: new Date(`${input.year}-01-01T00:00:00.000Z`) }, }, select: { startDate: true, endDate: true, isHalfDay: true }, }); const sickVacations = Array.isArray(sickVacationsResult) ? sickVacationsResult : []; const sickDays = sickVacations.reduce( (sum, vacation) => sum + countCalendarDaysInPeriod( vacation, new Date(`${input.year}-01-01T00:00:00.000Z`), new Date(`${input.year}-12-31T00:00:00.000Z`), ), 0, ); return { year: input.year, resourceId: input.resourceId, entitledDays: entitlement.entitledDays, carryoverDays: entitlement.carryoverDays, usedDays: entitlement.usedDays, pendingDays: entitlement.pendingDays, remainingDays: Math.max( 0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays, ), sickDays, }; } function toIsoDate(value: Date): string { return value.toISOString().slice(0, 10); } function buildEntitlementHolidayDateUnion(vacations: EntitlementVacationExplainability[]): string[] { return [...new Set(vacations.flatMap((vacation) => vacation.holidayDetails.map((detail) => detail.date)))].sort(); } function filterIsoDatesToRange(isoDates: string[], startDate: Date, endDate: Date): string[] { const startIso = toIsoDate(startDate); const endIso = toIsoDate(endDate); return isoDates.filter((isoDate) => isoDate >= startIso && isoDate <= endIso); } function clampVacationPeriodToYear( vacation: { startDate: Date; endDate: Date }, yearStart: Date, yearEnd: Date, ): { startDate: Date; endDate: Date } { return { startDate: vacation.startDate > yearStart ? vacation.startDate : yearStart, endDate: vacation.endDate < yearEnd ? vacation.endDate : yearEnd, }; } function formatEntitlementHolidayBasis(vacation: Pick< EntitlementVacationExplainability, "holidayCountryName" | "holidayCountryCode" | "holidayFederalState" | "holidayMetroCityName" >): string { return [ vacation.holidayCountryName ?? vacation.holidayCountryCode ?? null, vacation.holidayFederalState ?? null, vacation.holidayMetroCityName ?? null, ].filter((value): value is string => Boolean(value)).join(" / "); } function hasPersistedHolidaySnapshot(vacation: { deductedDays: number | null; holidayCountryCode: string | null; holidayCountryName: string | null; holidayFederalState: string | null; holidayMetroCityName: string | null; holidayCalendarDates: import("@capakraken/db").Prisma.JsonValue | null; holidayLegacyPublicHolidayDates: import("@capakraken/db").Prisma.JsonValue | null; }): boolean { return vacation.deductedDays != null || vacation.holidayCountryCode != null || vacation.holidayCountryName != null || vacation.holidayFederalState != null || vacation.holidayMetroCityName != null || vacation.holidayCalendarDates != null || vacation.holidayLegacyPublicHolidayDates != null; } function mapEntitlementVacationStatus(status: VacationStatus): EntitlementVacationStatus { if (status === VacationStatus.APPROVED || status === VacationStatus.PENDING) { return status; } throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Unsupported entitlement vacation status: ${status}`, }); } async function readEntitlementVacationExplainability( ctx: EntitlementReadContext, input: z.infer, ): Promise { const yearStart = new Date(`${input.year}-01-01T00:00:00.000Z`); const yearEnd = new Date(`${input.year}-12-31T00:00:00.000Z`); const vacations = await ctx.db.vacation.findMany({ where: { resourceId: input.resourceId, type: { in: BALANCE_TYPES }, startDate: { lte: yearEnd }, endDate: { gte: yearStart }, status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] }, }, select: { type: true, startDate: true, endDate: true, status: true, isHalfDay: true, deductedDays: true, holidayCountryCode: true, holidayCountryName: true, holidayFederalState: true, holidayMetroCityName: true, holidayCalendarDates: true, holidayLegacyPublicHolidayDates: true, }, orderBy: [{ startDate: "asc" }, { endDate: "asc" }], }); return Promise.all(vacations.map(async (vacation) => { const period = clampVacationPeriodToYear(vacation, yearStart, yearEnd); let vacationHolidayContextPromise: Promise>> | null = null; const getVacationHolidayContext = async () => { if (!vacationHolidayContextPromise) { vacationHolidayContextPromise = loadResourceHolidayContext( ctx.db, input.resourceId, period.startDate, period.endDate, ); } return vacationHolidayContextPromise; }; const fallbackHolidayContext = await getVacationHolidayContext(); const preview = buildVacationPreview({ type: vacation.type, startDate: period.startDate, endDate: period.endDate, isHalfDay: vacation.isHalfDay, holidayContext: hasPersistedHolidaySnapshot(vacation) ? { countryCode: vacation.holidayCountryCode ?? fallbackHolidayContext.countryCode ?? null, countryName: vacation.holidayCountryName ?? fallbackHolidayContext.countryName ?? null, federalState: vacation.holidayFederalState ?? fallbackHolidayContext.federalState ?? null, metroCityName: vacation.holidayMetroCityName ?? fallbackHolidayContext.metroCityName ?? null, calendarHolidayStrings: filterIsoDatesToRange( parseVacationSnapshotDateList(vacation.holidayCalendarDates), period.startDate, period.endDate, ), publicHolidayStrings: filterIsoDatesToRange( parseVacationSnapshotDateList(vacation.holidayLegacyPublicHolidayDates), period.startDate, period.endDate, ), } : fallbackHolidayContext, }); const persistedDeductedDays = countVacationChargeableDaysFromSnapshot(vacation, yearStart, yearEnd); return { type: vacation.type, status: mapEntitlementVacationStatus(vacation.status), startDate: toIsoDate(vacation.startDate), endDate: toIsoDate(vacation.endDate), isHalfDay: vacation.isHalfDay, requestedDays: preview.requestedDays, deductedDays: persistedDeductedDays ?? preview.deductedDays, holidayCountryCode: preview.holidayContext.countryCode, holidayCountryName: preview.holidayContext.countryName, holidayFederalState: preview.holidayContext.federalState, holidayMetroCityName: preview.holidayContext.metroCityName, holidayCalendarDates: preview.holidayDetails .filter((detail) => detail.source === "CALENDAR" || detail.source === "CALENDAR_AND_LEGACY") .map((detail) => detail.date), holidayLegacyPublicHolidayDates: preview.holidayDetails .filter((detail) => detail.source === "LEGACY_PUBLIC_HOLIDAY" || detail.source === "CALENDAR_AND_LEGACY") .map((detail) => detail.date), holidayDetails: preview.holidayDetails, holidayContext: preview.holidayContext, }; })); } async function readYearSummarySnapshot( ctx: Pick, input: z.infer, ) { const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); const defaultDays = settings?.vacationDefaultDays ?? 28; const resources = await ctx.db.resource.findMany({ where: { isActive: true, ...(input.chapter ? { chapter: input.chapter } : {}), }, select: { ...RESOURCE_BRIEF_SELECT, chapter: true, federalState: true, country: { select: { code: true, name: true } }, metroCity: { select: { name: true } }, }, orderBy: [{ chapter: "asc" }, { displayName: "asc" }], }); return Promise.all( resources.map(async (resource) => { const entitlement = await syncEntitlement(ctx.db, resource.id, input.year, defaultDays); return { resourceId: resource.id, displayName: resource.displayName, eid: resource.eid, chapter: resource.chapter, countryCode: resource.country?.code ?? null, countryName: resource.country?.name ?? null, federalState: resource.federalState ?? null, metroCityName: resource.metroCity?.name ?? null, entitledDays: entitlement.entitledDays, carryoverDays: entitlement.carryoverDays, usedDays: entitlement.usedDays, pendingDays: entitlement.pendingDays, remainingDays: Math.max( 0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays, ), }; }), ); } /** * Get or create an entitlement record, applying carryover from previous year if needed. */ async function getOrCreateEntitlement( db: TRPCContext["db"], resourceId: string, year: number, defaultDays: number, ) { let entitlement = await db.vacationEntitlement.findUnique({ where: { resourceId_year: { resourceId, year } }, }); if (!entitlement) { const prevYear = await db.vacationEntitlement.findUnique({ where: { resourceId_year: { resourceId, year: year - 1 } }, }); const carryover = prevYear ? Math.max(0, prevYear.entitledDays - prevYear.usedDays - prevYear.pendingDays) : 0; entitlement = await db.vacationEntitlement.create({ data: { resourceId, year, entitledDays: defaultDays + carryover, carryoverDays: carryover, usedDays: 0, pendingDays: 0, }, }); } return entitlement; } function calculateCarryoverDays(entitlement: { entitledDays: number; usedDays: number; pendingDays: number; }): number { return Math.max(0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays); } async function calculateEntitlementVacationDays( yearStart: Date, yearEnd: Date, vacation: { startDate: Date; endDate: Date; isHalfDay: boolean; deductedDays: number | null; holidayCountryCode: string | null; holidayFederalState: string | null; holidayMetroCityName: string | null; holidayCalendarDates: import("@capakraken/db").Prisma.JsonValue | null; holidayLegacyPublicHolidayDates: import("@capakraken/db").Prisma.JsonValue | null; }, getLegacyHolidayContext: () => Promise>>, ): Promise { const persistedDays = countVacationChargeableDaysFromSnapshot(vacation, yearStart, yearEnd); if (persistedDays !== null) { return persistedDays; } const holidayContext = await getLegacyHolidayContext(); return countVacationChargeableDays({ vacation, periodStart: yearStart, periodEnd: yearEnd, countryCode: holidayContext.countryCode, federalState: holidayContext.federalState, metroCityName: holidayContext.metroCityName, calendarHolidayStrings: holidayContext.calendarHolidayStrings, publicHolidayStrings: holidayContext.publicHolidayStrings, }); } /** * Recompute used/pending days from actual vacation records and update the cached values. */ async function syncEntitlement( db: TRPCContext["db"], resourceId: string, year: number, defaultDays: number, visitedYears: Set = new Set(), ): Promise { if (visitedYears.has(year)) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Detected recursive entitlement sync for year ${year}`, }); } visitedYears.add(year); let previousYearEntitlement: EntitlementSnapshot | null = await db.vacationEntitlement.findUnique({ where: { resourceId_year: { resourceId, year: year - 1 } }, }); if (previousYearEntitlement) { previousYearEntitlement = await syncEntitlement( db, resourceId, year - 1, defaultDays, visitedYears, ); } const entitlement = await getOrCreateEntitlement(db, resourceId, year, defaultDays); const carryoverDays = previousYearEntitlement ? calculateCarryoverDays(previousYearEntitlement) : 0; const expectedEntitledDays = defaultDays + carryoverDays; const entitlementWithCarryover = ( entitlement.carryoverDays !== carryoverDays || entitlement.entitledDays !== expectedEntitledDays ) ? await db.vacationEntitlement.update({ where: { id: entitlement.id }, data: { carryoverDays, entitledDays: expectedEntitledDays, }, }) : entitlement; const yearStart = new Date(`${year}-01-01T00:00:00.000Z`); const yearEnd = new Date(`${year}-12-31T00:00:00.000Z`); const vacations = await db.vacation.findMany({ where: { resourceId, type: { in: BALANCE_TYPES }, startDate: { lte: yearEnd }, endDate: { gte: yearStart }, status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] }, }, select: { startDate: true, endDate: true, status: true, isHalfDay: true, deductedDays: true, holidayCountryCode: true, holidayFederalState: true, holidayMetroCityName: true, holidayCalendarDates: true, holidayLegacyPublicHolidayDates: true, }, }); let usedDays = 0; let pendingDays = 0; let legacyHolidayContextPromise: Promise>> | null = null; const getLegacyHolidayContext = async () => { if (!legacyHolidayContextPromise) { legacyHolidayContextPromise = loadResourceHolidayContext(db, resourceId, yearStart, yearEnd); } return legacyHolidayContextPromise; }; for (const vacation of vacations) { const days = await calculateEntitlementVacationDays( yearStart, yearEnd, vacation, getLegacyHolidayContext, ); if (vacation.status === VacationStatus.APPROVED) { usedDays += days; } else { pendingDays += days; } } return db.vacationEntitlement.update({ where: { id: entitlementWithCarryover.id }, data: { usedDays, pendingDays }, }); } export async function getEntitlementBalance( ctx: EntitlementReadContext, input: z.infer, ) { return readBalanceSnapshot(ctx, input); } export async function getEntitlementBalanceDetail( ctx: EntitlementReadContext, input: z.infer, ) { const [balance, vacations] = await Promise.all([ readBalanceSnapshot(ctx, input), readEntitlementVacationExplainability(ctx, input), ]); const resource = await ctx.db.resource.findUnique({ where: { id: input.resourceId }, select: { displayName: true, eid: true }, }); if (!resource) { throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found", }); } const approvedVacations = vacations.filter((vacation) => vacation.status === VacationStatus.APPROVED); const pendingVacations = vacations.filter((vacation) => vacation.status === VacationStatus.PENDING); return mapBalanceDetail(resource, { ...balance, deductionSummary: { formula: "remaining = entitlement - taken - pending", approvedVacationCount: approvedVacations.length, pendingVacationCount: pendingVacations.length, approvedRequestedDays: approvedVacations.reduce((sum, vacation) => sum + vacation.requestedDays, 0), pendingRequestedDays: pendingVacations.reduce((sum, vacation) => sum + vacation.requestedDays, 0), approvedDeductedDays: approvedVacations.reduce((sum, vacation) => sum + vacation.deductedDays, 0), pendingDeductedDays: pendingVacations.reduce((sum, vacation) => sum + vacation.deductedDays, 0), excludedHolidayDates: buildEntitlementHolidayDateUnion(vacations), holidayBasisVariants: [...new Set(vacations.map(formatEntitlementHolidayBasis).filter((value) => value.length > 0))], sources: { hasCalendarHolidays: vacations.some((vacation) => vacation.holidayContext.sources.hasCalendarHolidays), hasLegacyPublicHolidayEntries: vacations.some((vacation) => vacation.holidayContext.sources.hasLegacyPublicHolidayEntries), }, }, vacations, }); } export async function getEntitlement( ctx: Pick, input: z.infer, ) { const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); const defaultDays = settings?.vacationDefaultDays ?? 28; return syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays); } export async function setEntitlement( ctx: EntitlementWriteContext, input: z.infer, ) { const existing = await ctx.db.vacationEntitlement.findUnique({ where: { resourceId_year: { resourceId: input.resourceId, year: input.year } }, }); if (existing) { const updated = await ctx.db.vacationEntitlement.update({ where: { id: existing.id }, data: { entitledDays: input.entitledDays }, }); void createAuditEntry({ db: ctx.db, entityType: "VacationEntitlement", entityId: updated.id, entityName: `Entitlement ${input.resourceId} / ${input.year}`, action: "UPDATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), before: existing as unknown as Record, after: updated as unknown as Record, source: "ui", summary: `Updated entitlement from ${existing.entitledDays} to ${input.entitledDays} days (${input.year})`, }); return updated; } const created = await ctx.db.vacationEntitlement.create({ data: { resourceId: input.resourceId, year: input.year, entitledDays: input.entitledDays, carryoverDays: 0, usedDays: 0, pendingDays: 0, }, }); void createAuditEntry({ db: ctx.db, entityType: "VacationEntitlement", entityId: created.id, entityName: `Entitlement ${input.resourceId} / ${input.year}`, action: "CREATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), after: created as unknown as Record, source: "ui", summary: `Set entitlement to ${input.entitledDays} days (${input.year})`, }); return created; } export async function bulkSetEntitlements( ctx: EntitlementWriteContext, input: z.infer, ) { const resources = await ctx.db.resource.findMany({ where: { isActive: true, ...(input.resourceIds ? { id: { in: input.resourceIds } } : {}), }, select: { id: true }, }); let updated = 0; for (const resource of resources) { await ctx.db.vacationEntitlement.upsert({ where: { resourceId_year: { resourceId: resource.id, year: input.year } }, create: { resourceId: resource.id, year: input.year, entitledDays: input.entitledDays, carryoverDays: 0, usedDays: 0, pendingDays: 0, }, update: { entitledDays: input.entitledDays }, }); updated++; } void createAuditEntry({ db: ctx.db, entityType: "VacationEntitlement", entityId: `bulk-${input.year}`, entityName: `Bulk Entitlement ${input.year}`, action: "UPDATE", ...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}), after: { year: input.year, entitledDays: input.entitledDays, resourceCount: updated } as unknown as Record, source: "ui", summary: `Bulk set entitlement to ${input.entitledDays} days for ${updated} resources (${input.year})`, }); return { updated }; } export async function getEntitlementYearSummary( ctx: Pick, input: z.infer, ) { return readYearSummarySnapshot(ctx, input); } export async function getEntitlementYearSummaryDetail( ctx: Pick, input: z.infer, ) { const summaries = await readYearSummarySnapshot(ctx, { year: input.year, ...(input.chapter ? { chapter: input.chapter } : {}), }); return mapYearSummaryDetail(input.year, summaries, input.resourceName); }