/** * 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 "@planarchy/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"; /** Types that consume from annual leave balance */ const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER]; /** * Count calendar days between two dates (inclusive). * Half-day vacations count as 0.5. */ function countDays(startDate: Date, endDate: Date, isHalfDay: boolean): number { if (isHalfDay) return 0.5; const ms = endDate.getTime() - startDate.getTime(); return Math.round(ms / 86_400_000) + 1; } /** * 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; } /** * 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, ) { const entitlement = await getOrCreateEntitlement(db, resourceId, year, defaultDays); const vacations = await db.vacation.findMany({ where: { resourceId, type: { in: BALANCE_TYPES }, startDate: { gte: new Date(`${year}-01-01`), lte: new Date(`${year}-12-31`) }, 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 = countDays(v.startDate, v.endDate, v.isHalfDay); if (v.status === VacationStatus.APPROVED) usedDays += days; else pendingDays += days; } return db.vacationEntitlement.update({ where: { id: entitlement.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 }) => { // Ownership check: USER can only query their own balance 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; // Sync from real vacation records const entitlement = await syncEntitlement(ctx.db, input.resourceId, input.year, defaultDays); // Also count sick days (informational) const sickVacations = await ctx.db.vacation.findMany({ where: { resourceId: input.resourceId, type: VacationType.SICK, status: VacationStatus.APPROVED, startDate: { gte: new Date(`${input.year}-01-01`), lte: new Date(`${input.year}-12-31`) }, }, select: { startDate: true, endDate: true, isHalfDay: true }, }); const sickDays = sickVacations.reduce( (sum, v) => sum + countDays(v.startDate, v.endDate, v.isHalfDay), 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, }; }), /** * 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 getOrCreateEntitlement(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) { return ctx.db.vacationEntitlement.update({ where: { id: existing.id }, data: { entitledDays: input.entitledDays }, }); } return ctx.db.vacationEntitlement.create({ data: { resourceId: input.resourceId, year: input.year, entitledDays: input.entitledDays, carryoverDays: 0, usedDays: 0, pendingDays: 0, }, }); }), /** * 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++; } 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 }) => { 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" }], }); const results = await Promise.all( resources.map(async (r) => { const entitlement = await syncEntitlement(ctx.db, r.id, input.year, defaultDays); return { resourceId: r.id, displayName: r.displayName, eid: r.eid, chapter: r.chapter, entitledDays: entitlement.entitledDays, carryoverDays: entitlement.carryoverDays, usedDays: entitlement.usedDays, pendingDays: entitlement.pendingDays, remainingDays: Math.max( 0, entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays, ), }; }), ); return results; }), });