import type { PrismaClient } from "@capakraken/db"; import { toIsoDate, DAY_KEYS, type WeekdayAvailability } from "@capakraken/shared"; import { isChargeabilityActualBooking, isChargeabilityRelevantProject, } from "../allocation/chargeability-bookings.js"; import { listAssignmentBookings } from "../allocation/list-assignment-bookings.js"; import { calculateEffectiveAllocationHours, calculateEffectiveAvailableHours, loadDailyAvailabilityContexts, type DailyAvailabilityContext, } from "./holiday-capacity.js"; export interface GetDashboardChargeabilityOverviewInput { includeProposed?: boolean; topN: number; watchlistThreshold: number; countryIds?: string[]; departed?: boolean; now?: Date; } export interface DashboardChargeabilityDerivation { weeklyAvailabilityHours: number; baseWorkingDays: number; effectiveWorkingDayEquivalent: number; baseAvailableHours: number; effectiveAvailableHours: number; publicHolidayCount: number; publicHolidayWorkdayCount: number; publicHolidayHoursDeduction: number; absenceDayEquivalent: number; absenceHoursDeduction: number; actualBookedHours: number; expectedBookedHours: number; targetBookedHours: number; unassignedHours: number; } export interface DashboardChargeabilityRow { id: string; eid: string; displayName: string; chapter: string | null; countryId?: string | null; countryCode?: string | null; countryName?: string | null; federalState?: string | null; metroCityName?: string | null; departed: boolean | null; chargeabilityTarget: number; actualChargeability: number; expectedChargeability: number; derivation?: DashboardChargeabilityDerivation; } export interface DashboardChargeabilityOverview { rows: DashboardChargeabilityRow[]; top: DashboardChargeabilityRow[]; watchlist: DashboardChargeabilityRow[]; month: string; } function getDailyAvailabilityHours( availability: WeekdayAvailability, date: Date, ): number { const dayKey = DAY_KEYS[date.getUTCDay()]; return dayKey ? (availability[dayKey] ?? 0) : 0; } function summarizeDerivation( availability: WeekdayAvailability, periodStart: Date, periodEnd: Date, context: DailyAvailabilityContext | undefined, actualBookedHours: number, expectedBookedHours: number, chargeabilityTarget: number, ): DashboardChargeabilityDerivation { let baseWorkingDays = 0; let effectiveWorkingDayEquivalent = 0; let publicHolidayWorkdayCount = 0; let publicHolidayHoursDeduction = 0; let absenceDayEquivalent = 0; let absenceHoursDeduction = 0; const weeklyAvailabilityHours = Object.values(availability).reduce( (sum, hours) => sum + (hours ?? 0), 0, ); const baseAvailableHours = calculateEffectiveAvailableHours({ availability, periodStart, periodEnd, context: undefined, }); const effectiveAvailableHours = calculateEffectiveAvailableHours({ availability, periodStart, periodEnd, context, }); const cursor = new Date(periodStart); cursor.setUTCHours(0, 0, 0, 0); const end = new Date(periodEnd); end.setUTCHours(0, 0, 0, 0); while (cursor <= end) { const isoDate = toIsoDate(cursor); const baseHours = getDailyAvailabilityHours(availability, cursor); const absenceFraction = Math.min( 1, Math.max(0, context?.absenceFractionsByDate.get(isoDate) ?? 0), ); const isHoliday = context?.holidayDates.has(isoDate) ?? false; if (baseHours > 0) { baseWorkingDays += 1; if (isHoliday) { publicHolidayWorkdayCount += 1; publicHolidayHoursDeduction += baseHours; } else { absenceDayEquivalent += absenceFraction; absenceHoursDeduction += baseHours * absenceFraction; effectiveWorkingDayEquivalent += Math.max(0, 1 - absenceFraction); } } cursor.setUTCDate(cursor.getUTCDate() + 1); } return { weeklyAvailabilityHours, baseWorkingDays, effectiveWorkingDayEquivalent, baseAvailableHours, effectiveAvailableHours, publicHolidayCount: context?.holidayDates.size ?? 0, publicHolidayWorkdayCount, publicHolidayHoursDeduction, absenceDayEquivalent, absenceHoursDeduction, actualBookedHours, expectedBookedHours, targetBookedHours: Math.round((effectiveAvailableHours * chargeabilityTarget) / 10) / 10, unassignedHours: Math.max(0, effectiveAvailableHours - expectedBookedHours), }; } export async function getDashboardChargeabilityOverview( db: PrismaClient, input: GetDashboardChargeabilityOverviewInput, ): Promise { const now = input.now ?? new Date(); const start = new Date(now.getFullYear(), now.getMonth(), 1); const end = new Date(now.getFullYear(), now.getMonth() + 1, 0); const resources = await db.resource.findMany({ where: { isActive: true, ...(input.countryIds && input.countryIds.length > 0 ? { countryId: { in: input.countryIds } } : {}), ...(input.departed !== undefined ? { departed: input.departed } : {}), }, select: { id: true, eid: true, displayName: true, chapter: true, countryId: true, federalState: true, metroCityId: true, departed: true, chargeabilityTarget: true, country: { select: { id: true, code: true, name: true, }, }, metroCity: { select: { id: true, name: true, }, }, availability: true, }, }); const bookings = await listAssignmentBookings(db, { startDate: start, endDate: end, resourceIds: resources.map((resource) => resource.id), }); const contexts = await loadDailyAvailabilityContexts( db, resources.map((resource) => ({ id: resource.id, availability: resource.availability as unknown as WeekdayAvailability, countryId: resource.countryId, countryCode: resource.country?.code, federalState: resource.federalState, metroCityId: resource.metroCityId, metroCityName: resource.metroCity?.name, })), start, end, ); const stats: DashboardChargeabilityRow[] = resources.map((resource) => { const availability = resource.availability as unknown as WeekdayAvailability; const context = contexts.get(resource.id); const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id); const actualAllocations = resourceBookings.filter((booking) => isChargeabilityActualBooking(booking, input.includeProposed === true), ); const expectedAllocations = resourceBookings.filter( (booking) => isChargeabilityRelevantProject(booking.project, true), ); const availableHours = calculateEffectiveAvailableHours({ availability, periodStart: start, periodEnd: end, context, }); const actualBookedHours = actualAllocations.reduce( (sum, allocation) => sum + calculateEffectiveAllocationHours({ availability, startDate: allocation.startDate, endDate: allocation.endDate, hoursPerDay: allocation.hoursPerDay, periodStart: start, periodEnd: end, context, }), 0, ); const expectedBookedHours = expectedAllocations.reduce( (sum, allocation) => sum + calculateEffectiveAllocationHours({ availability, startDate: allocation.startDate, endDate: allocation.endDate, hoursPerDay: allocation.hoursPerDay, periodStart: start, periodEnd: end, context, }), 0, ); const actualChargeability = availableHours > 0 ? Math.min(100, Math.round((actualBookedHours / availableHours) * 100)) : 0; const expectedChargeability = availableHours > 0 ? Math.min(100, Math.round((expectedBookedHours / availableHours) * 100)) : 0; const chargeabilityTarget = resource.chargeabilityTarget ?? 0; return { id: resource.id, eid: resource.eid, displayName: resource.displayName, chapter: resource.chapter, countryId: resource.countryId, countryCode: resource.country?.code ?? null, countryName: resource.country?.name ?? null, federalState: resource.federalState ?? null, metroCityName: resource.metroCity?.name ?? null, departed: resource.departed, chargeabilityTarget, actualChargeability, expectedChargeability, derivation: summarizeDerivation( availability, start, end, context, actualBookedHours, expectedBookedHours, chargeabilityTarget, ), }; }); return { rows: stats, top: [...stats] .sort((left, right) => right.actualChargeability - left.actualChargeability), watchlist: [...stats] .filter( (resource) => resource.actualChargeability < resource.chargeabilityTarget - input.watchlistThreshold, ) .sort((left, right) => left.actualChargeability - right.actualChargeability), month: `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`, }; }