b41c1d2501
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61) Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com> Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
567 lines
18 KiB
TypeScript
567 lines
18 KiB
TypeScript
import { VacationType, VacationStatus } from "@nexus/db";
|
|
import type { Prisma, PrismaClient } from "@nexus/db";
|
|
import { toIsoDate } from "@nexus/shared";
|
|
import { TRPCError } from "@trpc/server";
|
|
import {
|
|
type EntitlementSnapshot,
|
|
type ResourceHolidayContext,
|
|
type SyncEntitlementDeps,
|
|
syncEntitlement,
|
|
} from "./sync-entitlement.js";
|
|
|
|
/** Types that consume from annual leave balance */
|
|
const BALANCE_TYPES: VacationType[] = [VacationType.ANNUAL, VacationType.OTHER];
|
|
|
|
type DbClient = Pick<
|
|
PrismaClient,
|
|
"vacation" | "vacationEntitlement" | "systemSettings" | "resource"
|
|
>;
|
|
|
|
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;
|
|
};
|
|
};
|
|
};
|
|
|
|
type VacationPreviewInput = {
|
|
type: VacationType;
|
|
startDate: Date;
|
|
endDate: Date;
|
|
isHalfDay: boolean;
|
|
holidayContext: ResourceHolidayContext;
|
|
};
|
|
|
|
type VacationPreviewResult = {
|
|
requestedDays: number;
|
|
deductedDays: number;
|
|
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 type ReadEntitlementBalanceDeps = SyncEntitlementDeps & {
|
|
buildVacationPreview: (input: VacationPreviewInput) => VacationPreviewResult;
|
|
parseVacationSnapshotDateList: (value: Prisma.JsonValue | null | undefined) => string[];
|
|
};
|
|
|
|
export type EntitlementBalanceInput = {
|
|
resourceId: string;
|
|
year: number;
|
|
};
|
|
|
|
export type EntitlementYearSummaryInput = {
|
|
year: number;
|
|
chapter?: string | undefined;
|
|
};
|
|
|
|
export type EntitlementYearSummaryDetailInput = EntitlementYearSummaryInput & {
|
|
resourceName?: string | undefined;
|
|
};
|
|
|
|
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 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 hasPersistedHolidaySnapshot(vacation: {
|
|
deductedDays: number | null;
|
|
holidayCountryCode: string | null;
|
|
holidayCountryName: string | null;
|
|
holidayFederalState: string | null;
|
|
holidayMetroCityName: string | null;
|
|
holidayCalendarDates: Prisma.JsonValue | null;
|
|
holidayLegacyPublicHolidayDates: 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}`,
|
|
});
|
|
}
|
|
|
|
function buildEntitlementHolidayDateUnion(
|
|
vacations: EntitlementVacationExplainability[],
|
|
): string[] {
|
|
return [
|
|
...new Set(
|
|
vacations.flatMap((vacation) => vacation.holidayDetails.map((detail) => detail.date)),
|
|
),
|
|
].sort();
|
|
}
|
|
|
|
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 mapBalanceDetail(
|
|
resource: {
|
|
displayName: string;
|
|
eid: string;
|
|
},
|
|
balance: {
|
|
year: number;
|
|
entitledDays: number;
|
|
carryoverDays: number;
|
|
usedDays: number;
|
|
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,
|
|
eid: resource.eid,
|
|
year: balance.year,
|
|
entitlement: balance.entitledDays,
|
|
carryOver: balance.carryoverDays,
|
|
taken: balance.usedDays,
|
|
pending: balance.pendingDays,
|
|
remaining: balance.remainingDays,
|
|
sickDays: balance.sickDays,
|
|
...(balance.deductionSummary ? { deductionSummary: balance.deductionSummary } : {}),
|
|
...(balance.vacations ? { vacations: balance.vacations } : {}),
|
|
};
|
|
}
|
|
|
|
async function readBalanceSnapshot(
|
|
db: DbClient,
|
|
input: EntitlementBalanceInput,
|
|
deps: ReadEntitlementBalanceDeps,
|
|
) {
|
|
const settings = await db.systemSettings.findUnique({ where: { id: "singleton" } });
|
|
const defaultDays = settings?.vacationDefaultDays ?? 28;
|
|
const entitlement = await syncEntitlement(db, input.resourceId, input.year, defaultDays, deps);
|
|
|
|
const sickVacationsResult = await 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 +
|
|
deps.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 readEntitlementVacationExplainability(
|
|
db: DbClient,
|
|
input: EntitlementBalanceInput,
|
|
deps: ReadEntitlementBalanceDeps,
|
|
): 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 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<ResourceHolidayContext> | null = null;
|
|
const getVacationHolidayContext = async () => {
|
|
if (!vacationHolidayContextPromise) {
|
|
vacationHolidayContextPromise = deps.loadResourceHolidayContext(
|
|
db,
|
|
input.resourceId,
|
|
period.startDate,
|
|
period.endDate,
|
|
);
|
|
}
|
|
return vacationHolidayContextPromise;
|
|
};
|
|
const fallbackHolidayContext = await getVacationHolidayContext();
|
|
const preview = deps.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(
|
|
deps.parseVacationSnapshotDateList(vacation.holidayCalendarDates),
|
|
period.startDate,
|
|
period.endDate,
|
|
),
|
|
publicHolidayStrings: filterIsoDatesToRange(
|
|
deps.parseVacationSnapshotDateList(vacation.holidayLegacyPublicHolidayDates),
|
|
period.startDate,
|
|
period.endDate,
|
|
),
|
|
}
|
|
: fallbackHolidayContext,
|
|
});
|
|
const persistedDeductedDays = deps.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(
|
|
db: DbClient,
|
|
input: EntitlementYearSummaryInput,
|
|
deps: ReadEntitlementBalanceDeps,
|
|
) {
|
|
const settings = await db.systemSettings.findUnique({ where: { id: "singleton" } });
|
|
const defaultDays = settings?.vacationDefaultDays ?? 28;
|
|
|
|
const resources = await db.resource.findMany({
|
|
where: {
|
|
isActive: true,
|
|
...(input.chapter ? { chapter: input.chapter } : {}),
|
|
},
|
|
select: {
|
|
id: true,
|
|
displayName: true,
|
|
eid: true,
|
|
lcrCents: true,
|
|
chapter: true,
|
|
federalState: true,
|
|
country: { select: { code: true, name: true } },
|
|
metroCity: { select: { name: true } },
|
|
},
|
|
orderBy: [{ chapter: "asc" }, { displayName: "asc" }],
|
|
});
|
|
|
|
return Promise.all(
|
|
resources.map(async (resource) => {
|
|
const entitlement = await syncEntitlement(db, resource.id, input.year, defaultDays, deps);
|
|
return {
|
|
resourceId: resource.id,
|
|
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,
|
|
pendingDays: entitlement.pendingDays,
|
|
remainingDays: Math.max(
|
|
0,
|
|
entitlement.entitledDays - entitlement.usedDays - entitlement.pendingDays,
|
|
),
|
|
};
|
|
}),
|
|
);
|
|
}
|
|
|
|
export type EntitlementBalanceResult = Awaited<ReturnType<typeof readBalanceSnapshot>>;
|
|
export type EntitlementYearSummaryRow = Awaited<ReturnType<typeof readYearSummarySnapshot>>[number];
|
|
|
|
export async function getEntitlementBalance(
|
|
db: DbClient,
|
|
input: EntitlementBalanceInput,
|
|
deps: ReadEntitlementBalanceDeps,
|
|
) {
|
|
return readBalanceSnapshot(db, input, deps);
|
|
}
|
|
|
|
export async function getEntitlementBalanceDetail(
|
|
db: DbClient,
|
|
input: EntitlementBalanceInput,
|
|
deps: ReadEntitlementBalanceDeps,
|
|
) {
|
|
const [balance, vacations] = await Promise.all([
|
|
readBalanceSnapshot(db, input, deps),
|
|
readEntitlementVacationExplainability(db, input, deps),
|
|
]);
|
|
const resource = await db.resource.findUnique({
|
|
where: { id: input.resourceId },
|
|
select: { displayName: true, eid: true },
|
|
});
|
|
|
|
if (!resource) {
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: "Resource not found",
|
|
});
|
|
}
|
|
|
|
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 getEntitlementSync(
|
|
db: DbClient,
|
|
input: EntitlementBalanceInput,
|
|
deps: ReadEntitlementBalanceDeps,
|
|
): Promise<EntitlementSnapshot> {
|
|
const settings = await db.systemSettings.findUnique({ where: { id: "singleton" } });
|
|
const defaultDays = settings?.vacationDefaultDays ?? 28;
|
|
return syncEntitlement(db, input.resourceId, input.year, defaultDays, deps);
|
|
}
|
|
|
|
export async function getEntitlementYearSummary(
|
|
db: DbClient,
|
|
input: EntitlementYearSummaryInput,
|
|
deps: ReadEntitlementBalanceDeps,
|
|
) {
|
|
return readYearSummarySnapshot(db, input, deps);
|
|
}
|
|
|
|
export async function getEntitlementYearSummaryDetail(
|
|
db: DbClient,
|
|
input: EntitlementYearSummaryDetailInput,
|
|
deps: ReadEntitlementBalanceDeps,
|
|
) {
|
|
const summaries = await readYearSummarySnapshot(
|
|
db,
|
|
{
|
|
year: input.year,
|
|
...(input.chapter ? { chapter: input.chapter } : {}),
|
|
},
|
|
deps,
|
|
);
|
|
|
|
const needle = input.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,
|
|
countryCode: summary.countryCode ?? null,
|
|
countryName: summary.countryName ?? null,
|
|
federalState: summary.federalState ?? null,
|
|
metroCityName: summary.metroCityName ?? null,
|
|
year: input.year,
|
|
entitled: summary.entitledDays,
|
|
carryover: summary.carryoverDays,
|
|
used: summary.usedDays,
|
|
pending: summary.pendingDays,
|
|
remaining: summary.remainingDays,
|
|
}));
|
|
}
|