feat(api): explain holiday-aware vacation deductions
This commit is contained in:
@@ -141,6 +141,22 @@ describe("entitlement router authorization", () => {
|
||||
pending: 0,
|
||||
remaining: 25,
|
||||
sickDays: 0,
|
||||
deductionSummary: {
|
||||
formula: "remaining = entitlement - taken - pending",
|
||||
approvedVacationCount: 0,
|
||||
pendingVacationCount: 0,
|
||||
approvedRequestedDays: 0,
|
||||
pendingRequestedDays: 0,
|
||||
approvedDeductedDays: 0,
|
||||
pendingDeductedDays: 0,
|
||||
excludedHolidayDates: [],
|
||||
holidayBasisVariants: [],
|
||||
sources: {
|
||||
hasCalendarHolidays: false,
|
||||
hasLegacyPublicHolidayEntries: false,
|
||||
},
|
||||
},
|
||||
vacations: [],
|
||||
});
|
||||
expect(resourceFindUnique).toHaveBeenCalledWith({
|
||||
where: { id: "res_2" },
|
||||
|
||||
@@ -617,8 +617,119 @@ describe("entitlement.getBalanceDetail", () => {
|
||||
pending: 0.5,
|
||||
remaining: 28.5,
|
||||
sickDays: 1,
|
||||
deductionSummary: {
|
||||
formula: "remaining = entitlement - taken - pending",
|
||||
approvedVacationCount: 0,
|
||||
pendingVacationCount: 0,
|
||||
approvedRequestedDays: 0,
|
||||
pendingRequestedDays: 0,
|
||||
approvedDeductedDays: 0,
|
||||
pendingDeductedDays: 0,
|
||||
excludedHolidayDates: [],
|
||||
holidayBasisVariants: [],
|
||||
sources: {
|
||||
hasCalendarHolidays: false,
|
||||
hasLegacyPublicHolidayEntries: false,
|
||||
},
|
||||
},
|
||||
vacations: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("includes holiday-adjusted vacation breakdown for explainability", async () => {
|
||||
const entitlement = sampleEntitlement({ carryoverDays: 1, usedDays: 1, pendingDays: 0 });
|
||||
const db = {
|
||||
systemSettings: {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "singleton", vacationDefaultDays: 28 }),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockImplementation(async ({ select }: { select?: Record<string, unknown> } = {}) => ({
|
||||
...(select?.userId ? { userId: "user_1" } : {}),
|
||||
...(select?.displayName ? { displayName: "Alice Example" } : {}),
|
||||
...(select?.eid ? { eid: "EMP-001" } : {}),
|
||||
})),
|
||||
},
|
||||
vacationEntitlement: {
|
||||
findUnique: mockEntitlementFindUniqueByYear({ 2026: entitlement }),
|
||||
update: vi.fn().mockResolvedValue(entitlement),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockImplementation(async ({ where }: { where?: { type?: string | { in?: string[] } } } = {}) => {
|
||||
if (where?.type === "SICK") {
|
||||
return [];
|
||||
}
|
||||
if (typeof where?.type === "object" && Array.isArray(where.type.in)) {
|
||||
return [
|
||||
{
|
||||
type: "ANNUAL",
|
||||
startDate: new Date("2026-05-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-05-02T00:00:00.000Z"),
|
||||
status: "APPROVED",
|
||||
isHalfDay: false,
|
||||
deductedDays: 1,
|
||||
holidayCountryCode: "DE",
|
||||
holidayCountryName: "Germany",
|
||||
holidayFederalState: "BY",
|
||||
holidayMetroCityName: null,
|
||||
holidayCalendarDates: ["2026-05-01"],
|
||||
holidayLegacyPublicHolidayDates: [],
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.getBalanceDetail({ resourceId: "res_1", year: 2026 });
|
||||
|
||||
expect(result.deductionSummary).toEqual({
|
||||
formula: "remaining = entitlement - taken - pending",
|
||||
approvedVacationCount: 1,
|
||||
pendingVacationCount: 0,
|
||||
approvedRequestedDays: 2,
|
||||
pendingRequestedDays: 0,
|
||||
approvedDeductedDays: 1,
|
||||
pendingDeductedDays: 0,
|
||||
excludedHolidayDates: ["2026-05-01"],
|
||||
holidayBasisVariants: ["Germany / BY"],
|
||||
sources: {
|
||||
hasCalendarHolidays: true,
|
||||
hasLegacyPublicHolidayEntries: false,
|
||||
},
|
||||
});
|
||||
expect(result.vacations).toEqual([
|
||||
{
|
||||
type: "ANNUAL",
|
||||
status: "APPROVED",
|
||||
startDate: "2026-05-01",
|
||||
endDate: "2026-05-02",
|
||||
isHalfDay: false,
|
||||
requestedDays: 2,
|
||||
deductedDays: 1,
|
||||
holidayCountryCode: "DE",
|
||||
holidayCountryName: "Germany",
|
||||
holidayFederalState: "BY",
|
||||
holidayMetroCityName: null,
|
||||
holidayCalendarDates: ["2026-05-01"],
|
||||
holidayLegacyPublicHolidayDates: [],
|
||||
holidayDetails: [
|
||||
{ date: "2026-05-01", source: "CALENDAR" },
|
||||
],
|
||||
holidayContext: {
|
||||
countryCode: "DE",
|
||||
countryName: "Germany",
|
||||
federalState: "BY",
|
||||
metroCityName: null,
|
||||
sources: {
|
||||
hasCalendarHolidays: true,
|
||||
hasLegacyPublicHolidayEntries: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── get ─────────────────────────────────────────────────────────────────────
|
||||
@@ -813,8 +924,24 @@ describe("entitlement.bulkSet", () => {
|
||||
describe("entitlement.getYearSummary", () => {
|
||||
it("returns summary for all active resources (manager role)", async () => {
|
||||
const resources = [
|
||||
{ id: "res_1", displayName: "Alice", eid: "alice", chapter: "VFX" },
|
||||
{ id: "res_2", displayName: "Bob", eid: "bob", chapter: "Animation" },
|
||||
{
|
||||
id: "res_1",
|
||||
displayName: "Alice",
|
||||
eid: "alice",
|
||||
chapter: "VFX",
|
||||
federalState: "BY",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Muenchen" },
|
||||
},
|
||||
{
|
||||
id: "res_2",
|
||||
displayName: "Bob",
|
||||
eid: "bob",
|
||||
chapter: "Animation",
|
||||
federalState: "HH",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
},
|
||||
];
|
||||
const entitlement = sampleEntitlement({ usedDays: 5, pendingDays: 2 });
|
||||
const db = {
|
||||
@@ -845,6 +972,12 @@ describe("entitlement.getYearSummary", () => {
|
||||
expect(result[0]).toHaveProperty("resourceId");
|
||||
expect(result[0]).toHaveProperty("displayName");
|
||||
expect(result[0]).toHaveProperty("remainingDays");
|
||||
expect(result[0]).toMatchObject({
|
||||
countryCode: "DE",
|
||||
countryName: "Germany",
|
||||
federalState: "BY",
|
||||
metroCityName: "Muenchen",
|
||||
});
|
||||
});
|
||||
|
||||
it("filters by chapter when provided", async () => {
|
||||
@@ -881,8 +1014,24 @@ describe("entitlement.getYearSummary", () => {
|
||||
describe("entitlement.getYearSummaryDetail", () => {
|
||||
it("returns assistant-friendly year summary detail from the canonical summary workflow", async () => {
|
||||
const resources = [
|
||||
{ id: "res_1", displayName: "Alice Example", eid: "EMP-001", chapter: "Delivery" },
|
||||
{ id: "res_2", displayName: "Bob Example", eid: "EMP-002", chapter: "CGI" },
|
||||
{
|
||||
id: "res_1",
|
||||
displayName: "Alice Example",
|
||||
eid: "EMP-001",
|
||||
chapter: "Delivery",
|
||||
federalState: "BY",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Muenchen" },
|
||||
},
|
||||
{
|
||||
id: "res_2",
|
||||
displayName: "Bob Example",
|
||||
eid: "EMP-002",
|
||||
chapter: "CGI",
|
||||
federalState: "HH",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Hamburg" },
|
||||
},
|
||||
];
|
||||
const db = {
|
||||
systemSettings: {
|
||||
@@ -931,6 +1080,10 @@ describe("entitlement.getYearSummaryDetail", () => {
|
||||
resource: "Alice Example",
|
||||
eid: "EMP-001",
|
||||
chapter: "Delivery",
|
||||
countryCode: "DE",
|
||||
countryName: "Germany",
|
||||
federalState: "BY",
|
||||
metroCityName: "Muenchen",
|
||||
year: 2026,
|
||||
entitled: 28,
|
||||
carryover: 0,
|
||||
|
||||
@@ -110,6 +110,13 @@ const sampleVacation = {
|
||||
note: "Summer vacation",
|
||||
isHalfDay: false,
|
||||
halfDayPart: null,
|
||||
deductedDays: null,
|
||||
holidayCountryCode: null,
|
||||
holidayCountryName: null,
|
||||
holidayFederalState: null,
|
||||
holidayMetroCityName: null,
|
||||
holidayCalendarDates: null,
|
||||
holidayLegacyPublicHolidayDates: null,
|
||||
requestedById: "user_1",
|
||||
approvedById: null,
|
||||
approvedAt: null,
|
||||
@@ -636,6 +643,70 @@ describe("vacation router", () => {
|
||||
|
||||
expect(db.vacation.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps mixed vacation ranges chargeable when only some days are holidays", async () => {
|
||||
const createdVacation = {
|
||||
...sampleVacation,
|
||||
startDate: new Date("2026-11-14T00:00:00.000Z"),
|
||||
endDate: new Date("2026-11-16T00:00:00.000Z"),
|
||||
};
|
||||
const db = createVacationDb({
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
userId: "user_1",
|
||||
countryId: "country_de",
|
||||
metroCityId: "city_muc",
|
||||
federalState: "BY",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Muenchen" },
|
||||
}),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "cal_muc",
|
||||
name: "Muenchen lokal",
|
||||
scopeType: "CITY",
|
||||
priority: 10,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
entries: [
|
||||
{
|
||||
date: new Date("2020-11-15T00:00:00.000Z"),
|
||||
name: "Lokaler Stadtfeiertag",
|
||||
isRecurringAnnual: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
create: vi.fn().mockResolvedValue(createdVacation),
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createProtectedCaller(db);
|
||||
const result = await caller.create({
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-11-14T00:00:00.000Z"),
|
||||
endDate: new Date("2026-11-16T00:00:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.effectiveDays).toBe(2);
|
||||
expect(db.vacation.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
deductedDays: 2,
|
||||
holidayCountryCode: "DE",
|
||||
holidayCountryName: "Germany",
|
||||
holidayFederalState: "BY",
|
||||
holidayMetroCityName: "Muenchen",
|
||||
holidayCalendarDates: ["2026-11-15"],
|
||||
holidayLegacyPublicHolidayDates: [],
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("previewRequest", () => {
|
||||
@@ -833,6 +904,9 @@ describe("vacation router", () => {
|
||||
data: expect.objectContaining({
|
||||
status: VacationStatus.APPROVED,
|
||||
rejectionReason: null,
|
||||
deductedDays: 5,
|
||||
holidayCalendarDates: [],
|
||||
holidayLegacyPublicHolidayDates: [],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -865,6 +939,53 @@ describe("vacation router", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects approval when the current holiday context reduces the request to zero days", async () => {
|
||||
const db = createVacationDb({
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
countryId: "country_de",
|
||||
metroCityId: "city_muc",
|
||||
federalState: "BY",
|
||||
country: { code: "DE", name: "Germany" },
|
||||
metroCity: { name: "Muenchen" },
|
||||
}),
|
||||
},
|
||||
holidayCalendar: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: "cal_muc",
|
||||
name: "Muenchen lokal",
|
||||
scopeType: "CITY",
|
||||
priority: 10,
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
entries: [
|
||||
{
|
||||
date: new Date("2020-11-15T00:00:00.000Z"),
|
||||
name: "Lokaler Stadtfeiertag",
|
||||
isRecurringAnnual: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
vacation: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
...sampleVacation,
|
||||
startDate: new Date("2026-11-15T00:00:00.000Z"),
|
||||
endDate: new Date("2026-11-15T00:00:00.000Z"),
|
||||
}),
|
||||
update: vi.fn(),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
});
|
||||
|
||||
const caller = createManagerCaller(db);
|
||||
await expect(caller.approve({ id: "vac_1" })).rejects.toThrow(
|
||||
"Vacation no longer deducts any vacation days for the current holiday calendar and cannot be approved",
|
||||
);
|
||||
expect(db.vacation.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forbids regular users from approving", async () => {
|
||||
const db = {};
|
||||
const caller = createProtectedCaller(db);
|
||||
@@ -974,11 +1095,33 @@ describe("vacation router", () => {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "vac_1", resourceId: "res_1" },
|
||||
{ id: "vac_2", resourceId: "res_2" },
|
||||
]),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 2 }),
|
||||
findMany: vi.fn().mockImplementation(async ({ where }: { where?: { status?: string; type?: string } } = {}) => {
|
||||
if (where?.type === VacationType.PUBLIC_HOLIDAY) {
|
||||
return [];
|
||||
}
|
||||
if (where?.status === VacationStatus.PENDING) {
|
||||
return [
|
||||
{
|
||||
id: "vac_1",
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-06-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-06-05T00:00:00.000Z"),
|
||||
isHalfDay: false,
|
||||
},
|
||||
{
|
||||
id: "vac_2",
|
||||
resourceId: "res_2",
|
||||
type: VacationType.OTHER,
|
||||
startDate: new Date("2026-06-08T00:00:00.000Z"),
|
||||
endDate: new Date("2026-06-08T00:00:00.000Z"),
|
||||
isHalfDay: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
update: vi.fn().mockResolvedValue(sampleVacation),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
@@ -989,11 +1132,12 @@ describe("vacation router", () => {
|
||||
const result = await caller.batchApprove({ ids: ["vac_1", "vac_2"] });
|
||||
|
||||
expect(result.approved).toBe(2);
|
||||
expect(db.vacation.updateMany).toHaveBeenCalledWith(
|
||||
expect(db.vacation.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: { in: ["vac_1", "vac_2"] } },
|
||||
where: { id: "vac_1" },
|
||||
data: expect.objectContaining({
|
||||
status: VacationStatus.APPROVED,
|
||||
deductedDays: 5,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1005,10 +1149,25 @@ describe("vacation router", () => {
|
||||
findUnique: vi.fn().mockResolvedValue({ id: "mgr_1" }),
|
||||
},
|
||||
vacation: {
|
||||
findMany: vi.fn().mockResolvedValue([
|
||||
{ id: "vac_1", resourceId: "res_1" },
|
||||
]),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
findMany: vi.fn().mockImplementation(async ({ where }: { where?: { status?: string; type?: string } } = {}) => {
|
||||
if (where?.type === VacationType.PUBLIC_HOLIDAY) {
|
||||
return [];
|
||||
}
|
||||
if (where?.status === VacationStatus.PENDING) {
|
||||
return [
|
||||
{
|
||||
id: "vac_1",
|
||||
resourceId: "res_1",
|
||||
type: VacationType.ANNUAL,
|
||||
startDate: new Date("2026-06-01T00:00:00.000Z"),
|
||||
endDate: new Date("2026-06-01T00:00:00.000Z"),
|
||||
isHalfDay: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}),
|
||||
update: vi.fn().mockResolvedValue(sampleVacation),
|
||||
},
|
||||
resource: {
|
||||
findUnique: vi.fn().mockResolvedValue(null),
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Prisma, VacationType } from "@capakraken/db";
|
||||
import type { TRPCContext } from "../trpc.js";
|
||||
import { loadResourceHolidayContext } from "./resource-holiday-context.js";
|
||||
import { countVacationChargeableDays } from "./vacation-day-count.js";
|
||||
|
||||
export const VACATION_BALANCE_TYPES = new Set<VacationType>([
|
||||
VacationType.ANNUAL,
|
||||
VacationType.OTHER,
|
||||
]);
|
||||
|
||||
export type VacationChargeableInput = {
|
||||
resourceId: string;
|
||||
type: VacationType;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
isHalfDay: boolean;
|
||||
};
|
||||
|
||||
export type VacationDeductionSnapshot = {
|
||||
deductedDays: number;
|
||||
holidayCountryCode: string | null;
|
||||
holidayCountryName: string | null;
|
||||
holidayFederalState: string | null;
|
||||
holidayMetroCityName: string | null;
|
||||
holidayCalendarDates: string[];
|
||||
holidayLegacyPublicHolidayDates: string[];
|
||||
};
|
||||
|
||||
type VacationSnapshotCarrier = {
|
||||
deductedDays?: number | null;
|
||||
holidayCountryCode?: string | null;
|
||||
holidayFederalState?: string | null;
|
||||
holidayMetroCityName?: string | null;
|
||||
holidayCalendarDates?: Prisma.JsonValue | null;
|
||||
holidayLegacyPublicHolidayDates?: Prisma.JsonValue | null;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
isHalfDay: boolean;
|
||||
};
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
||||
}
|
||||
|
||||
export function parseVacationSnapshotDateList(value: Prisma.JsonValue | null | undefined): string[] {
|
||||
return isStringArray(value) ? [...value].sort() : [];
|
||||
}
|
||||
|
||||
export async function calculateVacationDeductionSnapshot(
|
||||
db: TRPCContext["db"],
|
||||
vacation: VacationChargeableInput,
|
||||
): Promise<VacationDeductionSnapshot> {
|
||||
const holidayContext = await loadResourceHolidayContext(
|
||||
db,
|
||||
vacation.resourceId,
|
||||
vacation.startDate,
|
||||
vacation.endDate,
|
||||
);
|
||||
|
||||
return {
|
||||
deductedDays: countVacationChargeableDays({
|
||||
vacation,
|
||||
countryCode: holidayContext.countryCode,
|
||||
federalState: holidayContext.federalState,
|
||||
metroCityName: holidayContext.metroCityName,
|
||||
calendarHolidayStrings: holidayContext.calendarHolidayStrings,
|
||||
publicHolidayStrings: holidayContext.publicHolidayStrings,
|
||||
}),
|
||||
holidayCountryCode: holidayContext.countryCode ?? null,
|
||||
holidayCountryName: holidayContext.countryName ?? null,
|
||||
holidayFederalState: holidayContext.federalState ?? null,
|
||||
holidayMetroCityName: holidayContext.metroCityName ?? null,
|
||||
holidayCalendarDates: [...holidayContext.calendarHolidayStrings].sort(),
|
||||
holidayLegacyPublicHolidayDates: [...holidayContext.publicHolidayStrings].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildVacationDeductionSnapshotWriteData(
|
||||
snapshot: VacationDeductionSnapshot,
|
||||
): {
|
||||
deductedDays: number;
|
||||
holidayCountryCode: string | null;
|
||||
holidayCountryName: string | null;
|
||||
holidayFederalState: string | null;
|
||||
holidayMetroCityName: string | null;
|
||||
holidayCalendarDates: Prisma.InputJsonValue;
|
||||
holidayLegacyPublicHolidayDates: Prisma.InputJsonValue;
|
||||
} {
|
||||
return {
|
||||
deductedDays: snapshot.deductedDays,
|
||||
holidayCountryCode: snapshot.holidayCountryCode,
|
||||
holidayCountryName: snapshot.holidayCountryName,
|
||||
holidayFederalState: snapshot.holidayFederalState,
|
||||
holidayMetroCityName: snapshot.holidayMetroCityName,
|
||||
holidayCalendarDates: snapshot.holidayCalendarDates as unknown as Prisma.InputJsonValue,
|
||||
holidayLegacyPublicHolidayDates:
|
||||
snapshot.holidayLegacyPublicHolidayDates as unknown as Prisma.InputJsonValue,
|
||||
};
|
||||
}
|
||||
|
||||
export function countVacationChargeableDaysFromSnapshot(
|
||||
vacation: VacationSnapshotCarrier,
|
||||
periodStart?: Date,
|
||||
periodEnd?: Date,
|
||||
): number | null {
|
||||
if (vacation.deductedDays == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return countVacationChargeableDays({
|
||||
vacation,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
countryCode: vacation.holidayCountryCode,
|
||||
federalState: vacation.holidayFederalState,
|
||||
metroCityName: vacation.holidayMetroCityName,
|
||||
calendarHolidayStrings: parseVacationSnapshotDateList(vacation.holidayCalendarDates),
|
||||
publicHolidayStrings: parseVacationSnapshotDateList(vacation.holidayLegacyPublicHolidayDates),
|
||||
});
|
||||
}
|
||||
@@ -5,8 +5,12 @@ import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
|
||||
import { createAuditEntry } from "../lib/audit.js";
|
||||
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
|
||||
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
|
||||
import { countVacationChargeableDaysFromSnapshot } from "../lib/vacation-deduction-snapshot.js";
|
||||
import {
|
||||
countVacationChargeableDaysFromSnapshot,
|
||||
parseVacationSnapshotDateList,
|
||||
} from "../lib/vacation-deduction-snapshot.js";
|
||||
import type { TRPCContext } from "../trpc.js";
|
||||
import { buildVacationPreview } from "./vacation-read-support.js";
|
||||
|
||||
/** Types that consume from annual leave balance */
|
||||
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
|
||||
@@ -21,6 +25,37 @@ type EntitlementSnapshot = {
|
||||
|
||||
type EntitlementReadContext = Pick<TRPCContext, "db" | "dbUser">;
|
||||
type EntitlementWriteContext = Pick<TRPCContext, "db" | "dbUser">;
|
||||
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;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export const EntitlementBalanceInputSchema = z.object({
|
||||
resourceId: z.string(),
|
||||
@@ -64,6 +99,22 @@ function mapBalanceDetail(resource: {
|
||||
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,
|
||||
@@ -75,6 +126,8 @@ function mapBalanceDetail(resource: {
|
||||
pending: balance.pendingDays,
|
||||
remaining: balance.remainingDays,
|
||||
sickDays: balance.sickDays,
|
||||
...(balance.deductionSummary ? { deductionSummary: balance.deductionSummary } : {}),
|
||||
...(balance.vacations ? { vacations: balance.vacations } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,6 +137,10 @@ function mapYearSummaryDetail(
|
||||
displayName: string;
|
||||
eid: string;
|
||||
chapter: string | null;
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
entitledDays: number;
|
||||
carryoverDays: number;
|
||||
usedDays: number;
|
||||
@@ -107,6 +164,10 @@ function mapYearSummaryDetail(
|
||||
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,
|
||||
entitled: summary.entitledDays,
|
||||
carryover: summary.carryoverDays,
|
||||
@@ -175,6 +236,167 @@ async function readBalanceSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
function toIsoDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function buildEntitlementHolidayDateUnion(vacations: EntitlementVacationExplainability[]): string[] {
|
||||
return [...new Set(vacations.flatMap((vacation) => vacation.holidayDetails.map((detail) => detail.date)))].sort();
|
||||
}
|
||||
|
||||
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 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 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 hasPersistedHolidaySnapshot(vacation: {
|
||||
deductedDays: number | null;
|
||||
holidayCountryCode: string | null;
|
||||
holidayCountryName: string | null;
|
||||
holidayFederalState: string | null;
|
||||
holidayMetroCityName: string | null;
|
||||
holidayCalendarDates: import("@capakraken/db").Prisma.JsonValue | null;
|
||||
holidayLegacyPublicHolidayDates: import("@capakraken/db").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}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function readEntitlementVacationExplainability(
|
||||
ctx: EntitlementReadContext,
|
||||
input: z.infer<typeof EntitlementBalanceInputSchema>,
|
||||
): Promise<EntitlementVacationExplainability[]> {
|
||||
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 ctx.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<Awaited<ReturnType<typeof loadResourceHolidayContext>>> | null = null;
|
||||
const getVacationHolidayContext = async () => {
|
||||
if (!vacationHolidayContextPromise) {
|
||||
vacationHolidayContextPromise = loadResourceHolidayContext(
|
||||
ctx.db,
|
||||
input.resourceId,
|
||||
period.startDate,
|
||||
period.endDate,
|
||||
);
|
||||
}
|
||||
return vacationHolidayContextPromise;
|
||||
};
|
||||
const fallbackHolidayContext = await getVacationHolidayContext();
|
||||
const preview = 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(
|
||||
parseVacationSnapshotDateList(vacation.holidayCalendarDates),
|
||||
period.startDate,
|
||||
period.endDate,
|
||||
),
|
||||
publicHolidayStrings: filterIsoDatesToRange(
|
||||
parseVacationSnapshotDateList(vacation.holidayLegacyPublicHolidayDates),
|
||||
period.startDate,
|
||||
period.endDate,
|
||||
),
|
||||
}
|
||||
: fallbackHolidayContext,
|
||||
});
|
||||
const persistedDeductedDays = 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(
|
||||
ctx: Pick<TRPCContext, "db">,
|
||||
input: z.infer<typeof EntitlementYearSummaryInputSchema>,
|
||||
@@ -187,7 +409,13 @@ async function readYearSummarySnapshot(
|
||||
isActive: true,
|
||||
...(input.chapter ? { chapter: input.chapter } : {}),
|
||||
},
|
||||
select: { ...RESOURCE_BRIEF_SELECT, chapter: true },
|
||||
select: {
|
||||
...RESOURCE_BRIEF_SELECT,
|
||||
chapter: true,
|
||||
federalState: true,
|
||||
country: { select: { code: true, name: true } },
|
||||
metroCity: { select: { name: true } },
|
||||
},
|
||||
orderBy: [{ chapter: "asc" }, { displayName: "asc" }],
|
||||
});
|
||||
|
||||
@@ -199,6 +427,10 @@ async function readYearSummarySnapshot(
|
||||
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,
|
||||
@@ -406,7 +638,10 @@ export async function getEntitlementBalanceDetail(
|
||||
ctx: EntitlementReadContext,
|
||||
input: z.infer<typeof EntitlementBalanceInputSchema>,
|
||||
) {
|
||||
const balance = await readBalanceSnapshot(ctx, input);
|
||||
const [balance, vacations] = await Promise.all([
|
||||
readBalanceSnapshot(ctx, input),
|
||||
readEntitlementVacationExplainability(ctx, input),
|
||||
]);
|
||||
const resource = await ctx.db.resource.findUnique({
|
||||
where: { id: input.resourceId },
|
||||
select: { displayName: true, eid: true },
|
||||
@@ -419,7 +654,28 @@ export async function getEntitlementBalanceDetail(
|
||||
});
|
||||
}
|
||||
|
||||
return mapBalanceDetail(resource, balance);
|
||||
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 getEntitlement(
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { VacationStatus, VacationType } from "@capakraken/db";
|
||||
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
|
||||
import { type ResourceHolidayContext } from "../lib/resource-holiday-context.js";
|
||||
import { VACATION_BALANCE_TYPES } from "../lib/vacation-deduction-snapshot.js";
|
||||
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
|
||||
import type { TRPCContext } from "../trpc.js";
|
||||
|
||||
type VacationReadDb = TRPCContext["db"];
|
||||
|
||||
type VacationPreviewInput = {
|
||||
type: VacationType;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
isHalfDay: boolean;
|
||||
holidayContext: ResourceHolidayContext;
|
||||
};
|
||||
|
||||
function resolveHolidayDetailSource(
|
||||
isoDate: string,
|
||||
holidayContext: ResourceHolidayContext,
|
||||
): "CALENDAR" | "LEGACY_PUBLIC_HOLIDAY" | "CALENDAR_AND_LEGACY" {
|
||||
const inCalendar = holidayContext.calendarHolidayStrings.includes(isoDate);
|
||||
const inLegacy = holidayContext.publicHolidayStrings.includes(isoDate);
|
||||
|
||||
if (inCalendar && inLegacy) {
|
||||
return "CALENDAR_AND_LEGACY";
|
||||
}
|
||||
|
||||
return inCalendar ? "CALENDAR" : "LEGACY_PUBLIC_HOLIDAY";
|
||||
}
|
||||
|
||||
export function buildVacationPreview(
|
||||
input: VacationPreviewInput,
|
||||
) {
|
||||
const vacation = {
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
isHalfDay: input.isHalfDay,
|
||||
};
|
||||
const requestedDays = countCalendarDaysInPeriod(vacation);
|
||||
const effectiveDays = VACATION_BALANCE_TYPES.has(input.type)
|
||||
? countVacationChargeableDays({
|
||||
vacation,
|
||||
countryCode: input.holidayContext.countryCode,
|
||||
federalState: input.holidayContext.federalState,
|
||||
metroCityName: input.holidayContext.metroCityName,
|
||||
calendarHolidayStrings: input.holidayContext.calendarHolidayStrings,
|
||||
publicHolidayStrings: input.holidayContext.publicHolidayStrings,
|
||||
})
|
||||
: requestedDays;
|
||||
const publicHolidayDates = [...new Set([
|
||||
...input.holidayContext.calendarHolidayStrings,
|
||||
...input.holidayContext.publicHolidayStrings,
|
||||
])].sort();
|
||||
|
||||
return {
|
||||
requestedDays,
|
||||
effectiveDays,
|
||||
deductedDays: VACATION_BALANCE_TYPES.has(input.type) ? effectiveDays : 0,
|
||||
publicHolidayDates,
|
||||
holidayDetails: publicHolidayDates.map((date) => ({
|
||||
date,
|
||||
source: resolveHolidayDetailSource(date, input.holidayContext),
|
||||
})),
|
||||
holidayContext: {
|
||||
countryCode: input.holidayContext.countryCode ?? null,
|
||||
countryName: input.holidayContext.countryName ?? null,
|
||||
federalState: input.holidayContext.federalState ?? null,
|
||||
metroCityName: input.holidayContext.metroCityName ?? null,
|
||||
sources: {
|
||||
hasCalendarHolidays: input.holidayContext.calendarHolidayStrings.length > 0,
|
||||
hasLegacyPublicHolidayEntries: input.holidayContext.publicHolidayStrings.length > 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function findVacationResourceChapter(
|
||||
db: VacationReadDb,
|
||||
resourceId: string,
|
||||
): Promise<string | null> {
|
||||
const resource = await db.resource.findUnique({
|
||||
where: { id: resourceId },
|
||||
select: { chapter: true },
|
||||
});
|
||||
|
||||
return resource?.chapter ?? null;
|
||||
}
|
||||
|
||||
export async function listChapterVacationOverlaps(
|
||||
db: VacationReadDb,
|
||||
input: {
|
||||
chapter: string;
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
},
|
||||
) {
|
||||
return db.vacation.findMany({
|
||||
where: {
|
||||
resource: { chapter: input.chapter },
|
||||
resourceId: { not: input.resourceId },
|
||||
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
|
||||
startDate: { lte: input.endDate },
|
||||
endDate: { gte: input.startDate },
|
||||
},
|
||||
include: {
|
||||
resource: { select: RESOURCE_BRIEF_SELECT },
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
take: 20,
|
||||
});
|
||||
}
|
||||
@@ -5,12 +5,12 @@ import { findUniqueOrThrow } from "../db/helpers.js";
|
||||
import { RESOURCE_BRIEF_SELECT } from "../db/selects.js";
|
||||
import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||
import { loadResourceHolidayContext } from "../lib/resource-holiday-context.js";
|
||||
import {
|
||||
VACATION_BALANCE_TYPES,
|
||||
type VacationChargeableInput,
|
||||
} from "../lib/vacation-deduction-snapshot.js";
|
||||
import { countCalendarDaysInPeriod, countVacationChargeableDays } from "../lib/vacation-day-count.js";
|
||||
import { protectedProcedure, type TRPCContext } from "../trpc.js";
|
||||
import {
|
||||
buildVacationPreview,
|
||||
findVacationResourceChapter,
|
||||
listChapterVacationOverlaps,
|
||||
} from "./vacation-read-support.js";
|
||||
|
||||
type VacationReadContext = Pick<TRPCContext, "db" | "dbUser">;
|
||||
|
||||
@@ -138,53 +138,13 @@ export const vacationReadProcedures = {
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
);
|
||||
const vacation: Pick<VacationChargeableInput, "startDate" | "endDate" | "isHalfDay"> = {
|
||||
return buildVacationPreview({
|
||||
type: input.type,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
isHalfDay: input.isHalfDay ?? false,
|
||||
};
|
||||
const requestedDays = countCalendarDaysInPeriod(vacation);
|
||||
const effectiveDays = VACATION_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: VACATION_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,
|
||||
},
|
||||
},
|
||||
};
|
||||
holidayContext,
|
||||
});
|
||||
}),
|
||||
|
||||
list: protectedProcedure
|
||||
@@ -316,27 +276,16 @@ export const vacationReadProcedures = {
|
||||
.query(async ({ ctx, input }) => {
|
||||
await assertCanReadVacationResource(ctx, input.resourceId);
|
||||
|
||||
const resource = await ctx.db.resource.findUnique({
|
||||
where: { id: input.resourceId },
|
||||
select: { chapter: true },
|
||||
});
|
||||
if (!resource?.chapter) {
|
||||
const chapter = await findVacationResourceChapter(ctx.db, input.resourceId);
|
||||
if (!chapter) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resource: { chapter: resource.chapter },
|
||||
resourceId: { not: input.resourceId },
|
||||
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
|
||||
startDate: { lte: input.endDate },
|
||||
endDate: { gte: input.startDate },
|
||||
},
|
||||
include: {
|
||||
resource: { select: RESOURCE_BRIEF_SELECT },
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
take: 20,
|
||||
return listChapterVacationOverlaps(ctx.db, {
|
||||
chapter,
|
||||
resourceId: input.resourceId,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
});
|
||||
}),
|
||||
|
||||
@@ -372,19 +321,11 @@ export const vacationReadProcedures = {
|
||||
});
|
||||
}
|
||||
|
||||
const overlaps = await ctx.db.vacation.findMany({
|
||||
where: {
|
||||
resource: { chapter: resource.chapter },
|
||||
resourceId: { not: input.resourceId },
|
||||
status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] },
|
||||
startDate: { lte: input.endDate },
|
||||
endDate: { gte: input.startDate },
|
||||
},
|
||||
include: {
|
||||
resource: { select: RESOURCE_BRIEF_SELECT },
|
||||
},
|
||||
orderBy: { startDate: "asc" },
|
||||
take: 20,
|
||||
const overlaps = await listChapterVacationOverlaps(ctx.db, {
|
||||
chapter: resource.chapter,
|
||||
resourceId: input.resourceId,
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
});
|
||||
|
||||
return mapTeamOverlapDetail({
|
||||
|
||||
Reference in New Issue
Block a user