feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { UpdateVacationStatusSchema, getPublicHolidays, buildTaskAction } from "@capakraken/shared";
|
||||
import { UpdateVacationStatusSchema, buildTaskAction } from "@capakraken/shared";
|
||||
import { VacationStatus, VacationType } from "@capakraken/db";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
@@ -12,9 +12,82 @@ import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../
|
||||
import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js";
|
||||
import { dispatchWebhooks } from "../lib/webhook-dispatcher.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
import { asHolidayResolverDb, getResolvedCalendarHolidays } from "../lib/holiday-availability.js";
|
||||
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
|
||||
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
|
||||
|
||||
/** Types that consume from annual leave balance */
|
||||
const BALANCE_TYPES = [VacationType.ANNUAL, VacationType.OTHER];
|
||||
const BALANCE_TYPES = new Set<VacationType>([VacationType.ANNUAL, VacationType.OTHER]);
|
||||
|
||||
function isSameUtcDay(left: Date, right: Date): boolean {
|
||||
return left.toISOString().slice(0, 10) === right.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
const PreviewVacationRequestSchema = z.object({
|
||||
resourceId: z.string(),
|
||||
type: z.nativeEnum(VacationType),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
isHalfDay: z.boolean().optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.endDate < data.startDate) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "End date must be after start date",
|
||||
path: ["endDate"],
|
||||
});
|
||||
}
|
||||
|
||||
if (data.isHalfDay && !isSameUtcDay(data.startDate, data.endDate)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Half-day requests must start and end on the same day",
|
||||
path: ["isHalfDay"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const CreateVacationRequestSchema = z.object({
|
||||
resourceId: z.string(),
|
||||
type: z.nativeEnum(VacationType),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
note: z.string().max(500).optional(),
|
||||
isHalfDay: z.boolean().optional(),
|
||||
halfDayPart: z.enum(["MORNING", "AFTERNOON"]).optional(),
|
||||
}).superRefine((data, ctx) => {
|
||||
if (data.endDate < data.startDate) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "End date must be after start date",
|
||||
path: ["endDate"],
|
||||
});
|
||||
}
|
||||
|
||||
if (data.isHalfDay && !isSameUtcDay(data.startDate, data.endDate)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Half-day requests must start and end on the same day",
|
||||
path: ["isHalfDay"],
|
||||
});
|
||||
}
|
||||
|
||||
if (data.isHalfDay && !data.halfDayPart) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Half-day requests require a half-day part",
|
||||
path: ["halfDayPart"],
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.isHalfDay && data.halfDayPart) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Half-day part is only allowed for half-day requests",
|
||||
path: ["halfDayPart"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function anonymizeVacationRecord<T extends {
|
||||
resource?: { id: string } | null;
|
||||
@@ -78,6 +151,64 @@ async function notifyVacationStatus(
|
||||
}
|
||||
|
||||
export const vacationRouter = createTRPCRouter({
|
||||
previewRequest: protectedProcedure
|
||||
.input(PreviewVacationRequestSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const holidayContext = await loadResourceHolidayContext(
|
||||
ctx.db,
|
||||
input.resourceId,
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
);
|
||||
const vacation = {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
isHalfDay: input.isHalfDay ?? false,
|
||||
};
|
||||
const requestedDays = countCalendarDaysInPeriod(vacation);
|
||||
const effectiveDays = BALANCE_TYPES.has(input.type)
|
||||
? countVacationChargeableDays({
|
||||
vacation,
|
||||
countryCode: holidayContext.countryCode,
|
||||
federalState: holidayContext.federalState,
|
||||
metroCityName: holidayContext.metroCityName,
|
||||
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
|
||||
publicHolidayStrings: holidayContext.publicHolidayStrings,
|
||||
})
|
||||
: requestedDays;
|
||||
const publicHolidayDates = [...new Set([
|
||||
...holidayContext.calendarHolidayStrings,
|
||||
...holidayContext.publicHolidayStrings,
|
||||
])].sort();
|
||||
const holidayDetails = publicHolidayDates.map((date) => ({
|
||||
date,
|
||||
source:
|
||||
holidayContext.calendarHolidayStrings.includes(date) && holidayContext.publicHolidayStrings.includes(date)
|
||||
? "CALENDAR_AND_LEGACY"
|
||||
: holidayContext.calendarHolidayStrings.includes(date)
|
||||
? "CALENDAR"
|
||||
: "LEGACY_PUBLIC_HOLIDAY",
|
||||
}));
|
||||
|
||||
return {
|
||||
requestedDays,
|
||||
effectiveDays,
|
||||
deductedDays: BALANCE_TYPES.has(input.type) ? effectiveDays : 0,
|
||||
publicHolidayDates,
|
||||
holidayDetails,
|
||||
holidayContext: {
|
||||
countryCode: holidayContext.countryCode ?? null,
|
||||
countryName: holidayContext.countryName ?? null,
|
||||
federalState: holidayContext.federalState ?? null,
|
||||
metroCityName: holidayContext.metroCityName ?? null,
|
||||
sources: {
|
||||
hasCalendarHolidays: holidayContext.calendarHolidayStrings.length > 0,
|
||||
hasLegacyPublicHolidayEntries: holidayContext.publicHolidayStrings.length > 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* List vacations with optional filters.
|
||||
*/
|
||||
@@ -141,21 +272,15 @@ export const vacationRouter = createTRPCRouter({
|
||||
* Adds isHalfDay + halfDayPart support.
|
||||
*/
|
||||
create: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
resourceId: z.string(),
|
||||
type: z.nativeEnum(VacationType),
|
||||
startDate: z.coerce.date(),
|
||||
endDate: z.coerce.date(),
|
||||
note: z.string().max(500).optional(),
|
||||
isHalfDay: z.boolean().optional(),
|
||||
halfDayPart: z.enum(["MORNING", "AFTERNOON"]).optional(),
|
||||
}).refine((d) => d.endDate >= d.startDate, {
|
||||
message: "End date must be after start date",
|
||||
path: ["endDate"],
|
||||
}),
|
||||
)
|
||||
.input(CreateVacationRequestSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.type === VacationType.PUBLIC_HOLIDAY) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Public holidays must be managed via Holiday Calendars or the legacy holiday import, not via manual vacation requests",
|
||||
});
|
||||
}
|
||||
|
||||
const userRecord = await ctx.db.user.findUnique({
|
||||
where: { email: ctx.session.user?.email ?? "" },
|
||||
select: { id: true, systemRole: true },
|
||||
@@ -186,6 +311,9 @@ export const vacationRouter = createTRPCRouter({
|
||||
status: { in: ["APPROVED", "PENDING"] },
|
||||
startDate: { lte: input.endDate },
|
||||
endDate: { gte: input.startDate },
|
||||
...(BALANCE_TYPES.has(input.type)
|
||||
? { type: { not: VacationType.PUBLIC_HOLIDAY } }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
if (overlapping) {
|
||||
@@ -195,6 +323,35 @@ export const vacationRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
let effectiveDays: number | null = null;
|
||||
if (BALANCE_TYPES.has(input.type)) {
|
||||
const holidayContext = await loadResourceHolidayContext(
|
||||
ctx.db,
|
||||
input.resourceId,
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
);
|
||||
effectiveDays = countVacationChargeableDays({
|
||||
vacation: {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
isHalfDay: input.isHalfDay ?? false,
|
||||
},
|
||||
countryCode: holidayContext.countryCode,
|
||||
federalState: holidayContext.federalState,
|
||||
metroCityName: holidayContext.metroCityName,
|
||||
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
|
||||
publicHolidayStrings: holidayContext.publicHolidayStrings,
|
||||
});
|
||||
|
||||
if (effectiveDays <= 0) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Selected vacation period only contains public holidays and does not deduct any vacation days",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const status = isManager ? VacationStatus.APPROVED : VacationStatus.PENDING;
|
||||
|
||||
const vacation = await ctx.db.vacation.create({
|
||||
@@ -265,7 +422,8 @@ export const vacationRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
const directory = await getAnonymizationDirectory(ctx.db);
|
||||
return anonymizeVacationRecord(vacation, directory);
|
||||
const result = anonymizeVacationRecord(vacation, directory);
|
||||
return effectiveDays === null ? result : { ...result, effectiveDays };
|
||||
}),
|
||||
|
||||
/**
|
||||
@@ -698,19 +856,25 @@ export const vacationRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const holidays = getPublicHolidays(input.year, input.federalState);
|
||||
if (holidays.length === 0) {
|
||||
return { created: 0 };
|
||||
}
|
||||
|
||||
const resources = await ctx.db.resource.findMany({
|
||||
where: {
|
||||
isActive: true,
|
||||
...(input.chapter ? { chapter: input.chapter } : {}),
|
||||
},
|
||||
select: { id: true },
|
||||
select: {
|
||||
id: true,
|
||||
federalState: true,
|
||||
countryId: true,
|
||||
metroCityId: true,
|
||||
country: { select: { code: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (resources.length === 0) {
|
||||
return { created: 0 };
|
||||
}
|
||||
|
||||
const adminUser = await ctx.db.user.findUnique({
|
||||
where: { email: ctx.session.user?.email ?? "" },
|
||||
select: { id: true },
|
||||
@@ -718,8 +882,19 @@ export const vacationRouter = createTRPCRouter({
|
||||
if (!adminUser) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
|
||||
let created = 0;
|
||||
let holidayCount = 0;
|
||||
|
||||
for (const resource of resources) {
|
||||
const holidays = await getResolvedCalendarHolidays(asHolidayResolverDb(ctx.db), {
|
||||
periodStart: new Date(`${input.year}-01-01T00:00:00.000Z`),
|
||||
periodEnd: new Date(`${input.year}-12-31T00:00:00.000Z`),
|
||||
countryId: resource.countryId,
|
||||
countryCode: resource.country?.code,
|
||||
federalState: input.federalState ?? resource.federalState,
|
||||
metroCityId: resource.metroCityId,
|
||||
metroCityName: resource.metroCity?.name,
|
||||
});
|
||||
holidayCount += holidays.length;
|
||||
for (const holiday of holidays) {
|
||||
const startDate = new Date(holiday.date);
|
||||
const endDate = new Date(holiday.date);
|
||||
@@ -771,12 +946,12 @@ export const vacationRouter = createTRPCRouter({
|
||||
entityName: `Public Holidays ${input.year}${input.federalState ? ` (${input.federalState})` : ""}`,
|
||||
action: "CREATE",
|
||||
userId: adminUser.id,
|
||||
after: { created, holidays: holidays.length, resources: resources.length, year: input.year, federalState: input.federalState } as unknown as Record<string, unknown>,
|
||||
after: { created, holidays: holidayCount, resources: resources.length, year: input.year, federalState: input.federalState } as unknown as Record<string, unknown>,
|
||||
source: "ui",
|
||||
summary: `Batch created ${created} public holidays for ${resources.length} resources (${input.year})`,
|
||||
});
|
||||
|
||||
return { created, holidays: holidays.length, resources: resources.length };
|
||||
return { created, holidays: holidayCount, resources: resources.length };
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user