/** * Vacation entitlement & balance router. * Tracks annual leave quotas per resource per year. * Balance is computed lazily: carryover from previous year is applied on first access. */ import { VacationType, VacationStatus } from "@capakraken/db"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { RESOURCE_BRIEF_SELECT } from "../db/selects.js"; import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js"; import { createAuditEntry } from "../lib/audit.js"; import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js"; import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.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; }; function mapBalanceDetail(resource: { displayName: string; eid: string; }, balance: { year: number; entitledDays: number; carryoverDays: number; usedDays: number; pendingDays: number; remainingDays: number; sickDays: number; }) { 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, }; } function mapYearSummaryDetail( year: number, summaries: Array<{ displayName: string; eid: string; chapter: 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, year, entitled: summary.entitledDays, carryover: summary.carryoverDays, used: summary.usedDays, pending: summary.pendingDays, remaining: summary.remainingDays, })); } type EntitlementReadContext = Parameters[0]>[0]["ctx"]; async function readBalanceSnapshot( ctx: Pick, input: { resourceId: string; year: number }, ) { 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, }; } async function readYearSummarySnapshot( ctx: Pick, input: { year: number; chapter?: string }, ) { 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 }, 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, 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: Parameters[0]>[0]["ctx"]["db"], resourceId: string, year: number, defaultDays: number, ) { let entitlement = await db.vacationEntitlement.findUnique({ where: { resourceId_year: { resourceId, year } }, }); if (!entitlement) { // Check previous year for carryover 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); } /** * Recompute used/pending days from actual vacation records and update the cached values. */ async function syncEntitlement( db: Parameters[0]>[0]["ctx"]["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 holidayContext = await loadResourceHolidayContext(db, resourceId, yearStart, yearEnd); 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 }, }); let usedDays = 0; let pendingDays = 0; for (const v of vacations) { const days = countVacationChargeableDays({ vacation: v, periodStart: yearStart, periodEnd: yearEnd, countryCode: holidayContext.countryCode, federalState: holidayContext.federalState, metroCityName: holidayContext.metroCityName, calendarHolidayStrings: holidayContext.calendarHolidayStrings, publicHolidayStrings: holidayContext.publicHolidayStrings, }); if (v.status === VacationStatus.APPROVED) usedDays += days; else pendingDays += days; } return db.vacationEntitlement.update({ where: { id: entitlementWithCarryover.id }, data: { usedDays, pendingDays }, }); } export const entitlementRouter = createTRPCRouter({ /** * Get vacation balance for a resource in a year. * Creates the entitlement record if it doesn't exist (with carryover). */ getBalance: protectedProcedure .input( z.object({ resourceId: z.string(), year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()), }), ) .query(async ({ ctx, input }) => readBalanceSnapshot(ctx, input)), getBalanceDetail: protectedProcedure .input( z.object({ resourceId: z.string(), year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()), }), ) .query(async ({ ctx, input }) => { const balance = await readBalanceSnapshot(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", }); } return mapBalanceDetail(resource, balance); }), /** * Get entitlement record for a resource/year (admin/manager only). */ get: managerProcedure .input(z.object({ resourceId: z.string(), year: z.number().int() })) .query(async ({ ctx, input }) => { const settings = await ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }); const defaultDays = settings?.vacationDefaultDays ?? 28; return syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays); }), /** * Set entitlement for a resource/year (admin/manager only). */ set: managerProcedure .input( z.object({ resourceId: z.string(), year: z.number().int(), entitledDays: z.number().min(0).max(365), }), ) .mutation(async ({ ctx, input }) => { 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; }), /** * Bulk-set entitlements for multiple resources (admin only). * Useful for setting the default entitlement for a new year. */ bulkSet: adminProcedure .input( z.object({ year: z.number().int(), entitledDays: z.number().min(0).max(365), resourceIds: z.array(z.string()).optional(), // if omitted, applies to all active resources }), ) .mutation(async ({ ctx, input }) => { const resources = await ctx.db.resource.findMany({ where: { isActive: true, ...(input.resourceIds ? { id: { in: input.resourceIds } } : {}), }, select: { id: true }, }); let updated = 0; for (const r of resources) { await ctx.db.vacationEntitlement.upsert({ where: { resourceId_year: { resourceId: r.id, year: input.year } }, create: { resourceId: r.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 }; }), /** * Get year summary: all resources with their balance for a given year. * Manager/admin only. */ getYearSummary: managerProcedure .input( z.object({ year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()), chapter: z.string().optional(), }), ) .query(async ({ ctx, input }) => readYearSummarySnapshot(ctx, { year: input.year, ...(input.chapter ? { chapter: input.chapter } : {}), })), getYearSummaryDetail: managerProcedure .input( z.object({ year: z.number().int().min(2000).max(2100).default(new Date().getFullYear()), chapter: z.string().optional(), resourceName: z.string().optional(), }), ) .query(async ({ ctx, input }) => { const summaries = await readYearSummarySnapshot(ctx, { year: input.year, ...(input.chapter ? { chapter: input.chapter } : {}), }); return mapYearSummaryDetail(input.year, summaries, input.resourceName); }), });