chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 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 { 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<Parameters<typeof protectedProcedure["query"]>[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<Parameters<typeof protectedProcedure["query"]>[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 }) => {
|
||||
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: { id: true, displayName: true, eid: true, 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;
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user