import { VacationType, VacationStatus } from "@nexus/db"; import type { Prisma, PrismaClient } from "@nexus/db"; import { toIsoDate } from "@nexus/shared"; import { TRPCError } from "@trpc/server"; import { type EntitlementSnapshot, type ResourceHolidayContext, type SyncEntitlementDeps, syncEntitlement, } from "./sync-entitlement.js"; /** Types that consume from annual leave balance */ const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER]; type DbClient = Pick< PrismaClient, "vacation" | "vacationEntitlement" | "systemSettings" | "resource" >; 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; }; }; }; type VacationPreviewInput = { type: VacationType; startDate: Date; endDate: Date; isHalfDay: boolean; holidayContext: ResourceHolidayContext; }; type VacationPreviewResult = { requestedDays: number; deductedDays: number; 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 type ReadEntitlementBalanceDeps = SyncEntitlementDeps & { buildVacationPreview: (input: VacationPreviewInput) => VacationPreviewResult; parseVacationSnapshotDateList: (value: Prisma.JsonValue | null | undefined) => string[]; }; export type EntitlementBalanceInput = { resourceId: string; year: number; }; export type EntitlementYearSummaryInput = { year: number; chapter?: string | undefined; }; export type EntitlementYearSummaryDetailInput = EntitlementYearSummaryInput & { resourceName?: string | undefined; }; 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 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 hasPersistedHolidaySnapshot(vacation: { deductedDays: number | null; holidayCountryCode: string | null; holidayCountryName: string | null; holidayFederalState: string | null; holidayMetroCityName: string | null; holidayCalendarDates: Prisma.JsonValue | null; holidayLegacyPublicHolidayDates: 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}`, }); } function buildEntitlementHolidayDateUnion( vacations: EntitlementVacationExplainability[], ): string[] { return [ ...new Set( vacations.flatMap((vacation) => vacation.holidayDetails.map((detail) => detail.date)), ), ].sort(); } 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 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 } : {}), }; } async function readBalanceSnapshot( db: DbClient, input: EntitlementBalanceInput, deps: ReadEntitlementBalanceDeps, ) { const settings = await db.systemSettings.findUnique({ where: { id: "singleton" } }); const defaultDays = settings?.vacationDefaultDays ?? 28; const entitlement = await syncEntitlement(db, input.resourceId, input.year, defaultDays, deps); const sickVacationsResult = await 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 + deps.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, }; } async function readEntitlementVacationExplainability( db: DbClient, input: EntitlementBalanceInput, deps: ReadEntitlementBalanceDeps, ): 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 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 = deps.loadResourceHolidayContext( db, input.resourceId, period.startDate, period.endDate, ); } return vacationHolidayContextPromise; }; const fallbackHolidayContext = await getVacationHolidayContext(); const preview = deps.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( deps.parseVacationSnapshotDateList(vacation.holidayCalendarDates), period.startDate, period.endDate, ), publicHolidayStrings: filterIsoDatesToRange( deps.parseVacationSnapshotDateList(vacation.holidayLegacyPublicHolidayDates), period.startDate, period.endDate, ), } : fallbackHolidayContext, }); const persistedDeductedDays = deps.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( db: DbClient, input: EntitlementYearSummaryInput, deps: ReadEntitlementBalanceDeps, ) { const settings = await db.systemSettings.findUnique({ where: { id: "singleton" } }); const defaultDays = settings?.vacationDefaultDays ?? 28; const resources = await db.resource.findMany({ where: { isActive: true, ...(input.chapter ? { chapter: input.chapter } : {}), }, select: { id: true, displayName: true, eid: true, lcrCents: true, 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(db, resource.id, input.year, defaultDays, deps); 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, ), }; }), ); } export type EntitlementBalanceResult = Awaited>; export type EntitlementYearSummaryRow = Awaited>[number]; export async function getEntitlementBalance( db: DbClient, input: EntitlementBalanceInput, deps: ReadEntitlementBalanceDeps, ) { return readBalanceSnapshot(db, input, deps); } export async function getEntitlementBalanceDetail( db: DbClient, input: EntitlementBalanceInput, deps: ReadEntitlementBalanceDeps, ) { const [balance, vacations] = await Promise.all([ readBalanceSnapshot(db, input, deps), readEntitlementVacationExplainability(db, input, deps), ]); const resource = await 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 getEntitlementSync( db: DbClient, input: EntitlementBalanceInput, deps: ReadEntitlementBalanceDeps, ): Promise { const settings = await db.systemSettings.findUnique({ where: { id: "singleton" } }); const defaultDays = settings?.vacationDefaultDays ?? 28; return syncEntitlement(db, input.resourceId, input.year, defaultDays, deps); } export async function getEntitlementYearSummary( db: DbClient, input: EntitlementYearSummaryInput, deps: ReadEntitlementBalanceDeps, ) { return readYearSummarySnapshot(db, input, deps); } export async function getEntitlementYearSummaryDetail( db: DbClient, input: EntitlementYearSummaryDetailInput, deps: ReadEntitlementBalanceDeps, ) { const summaries = await readYearSummarySnapshot( db, { year: input.year, ...(input.chapter ? { chapter: input.chapter } : {}), }, deps, ); const needle = input.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: input.year, entitled: summary.entitledDays, carryover: summary.carryoverDays, used: summary.usedDays, pending: summary.pendingDays, remaining: summary.remainingDays, })); }