diff --git a/apps/web/src/components/admin/DispoImportClient.tsx b/apps/web/src/components/admin/DispoImportClient.tsx index 997616b..65b6999 100644 --- a/apps/web/src/components/admin/DispoImportClient.tsx +++ b/apps/web/src/components/admin/DispoImportClient.tsx @@ -175,7 +175,7 @@ export function DispoImportClient() { Dispo Import
- Manage V2 data imports from Dispo workbooks + Manage Dispo imports
diff --git a/packages/api/src/router/computation-graph.ts b/packages/api/src/router/computation-graph.ts index c91352a..7942048 100644 --- a/packages/api/src/router/computation-graph.ts +++ b/packages/api/src/router/computation-graph.ts @@ -6,6 +6,9 @@ import { getMonthRange, countWorkingDaysInOverlap, DEFAULT_CALCULATION_RULES, + summarizeEstimateDemandLines, + computeEvenSpread, + distributeHoursToWeeks, type AssignmentSlice, } from "@planarchy/engine"; import type { CalculationRule, AbsenceDay, SpainScheduleRule, WeekdayAvailability } from "@planarchy/shared"; @@ -141,9 +144,14 @@ export const computationGraphRouter = createTRPCRouter({ select: { startDate: true, endDate: true, type: true, isHalfDay: true }, }); - // Build absence dates for SAH (ISO strings) + // Build absence dates for SAH (ISO strings), separating public holidays + const publicHolidayStrings: string[] = []; const absenceDateStrings: string[] = []; const absenceDays: AbsenceDay[] = []; + let halfDayCount = 0; + let vacationDayCount = 0; + let sickDayCount = 0; + let publicHolidayCount = 0; for (const v of vacations) { const vStart = new Date(Math.max(v.startDate.getTime(), monthStart.getTime())); const vEnd = new Date(Math.min(v.endDate.getTime(), monthEnd.getTime())); @@ -156,12 +164,21 @@ export const computationGraphRouter = createTRPCRouter({ : v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const : "VACATION" as const; while (cursor <= endNorm) { - absenceDateStrings.push(cursor.toISOString().slice(0, 10)); + const isoDate = cursor.toISOString().slice(0, 10); + if (triggerType === "PUBLIC_HOLIDAY") { + publicHolidayStrings.push(isoDate); + publicHolidayCount++; + } else { + absenceDateStrings.push(isoDate); + if (triggerType === "VACATION") vacationDayCount++; + if (triggerType === "SICK") sickDayCount++; + } absenceDays.push({ date: new Date(cursor), type: triggerType, ...(v.isHalfDay ? { isHalfDay: true } : {}), }); + if (v.isHalfDay) halfDayCount++; cursor.setUTCDate(cursor.getUTCDate() + 1); } } @@ -187,7 +204,7 @@ export const computationGraphRouter = createTRPCRouter({ fte: resource.fte, periodStart: monthStart, periodEnd: monthEnd, - publicHolidays: [], + publicHolidays: publicHolidayStrings, absenceDays: absenceDateStrings, }); @@ -301,6 +318,9 @@ export const computationGraphRouter = createTRPCRouter({ const avgHoursPerDay = assignments.length > 0 ? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length : 0; + const totalWorkingDaysInMonth = assignments.reduce((sum, a) => { + return sum + countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate); + }, 0); // Format weekly availability for display const weekdayLabels = ["Mo", "Tu", "We", "Th", "Fr"]; @@ -311,30 +331,46 @@ export const computationGraphRouter = createTRPCRouter({ ? `${weekdayValues[0]}h/day` : weekdayLabels.map((d, i) => `${d}:${weekdayValues[i]}`).join(" "); + // Derived utilization ratio + const utilizationPct = sahResult.standardAvailableHours > 0 + ? (totalAllocHours / sahResult.standardAvailableHours) * 100 + : 0; + + // Has schedule rules (Spain variable hours)? + const hasScheduleRules = !!scheduleRules; + const nodes: GraphNode[] = [ // INPUT n("input.fte", "FTE", fmtNum(resource.fte, 2), "ratio", "INPUT", `Resource FTE factor`, 0), n("input.dailyHours", "Country Hours", `${dailyHours} h`, "hours", "INPUT", `Base daily working hours (${resource.country?.code ?? "?"})`, 0), + ...(hasScheduleRules ? [ + n("input.scheduleRules", "Schedule Rules", "Spain", "—", "INPUT", "Variable daily hours (regular/friday/summer)", 0), + ] : []), n("input.weeklyAvail", "Weekly Avail.", `${weeklyTotalHours}h`, "h/week", "INPUT", `Resource availability: ${availabilityLabel}`, 0), n("input.lcrCents", "LCR", fmtEur(resource.lcrCents), "cents/h", "INPUT", "Loaded Cost Rate per hour", 0), n("input.hoursPerDay", "Hours/Day", fmtNum(avgHoursPerDay), "hours", "INPUT", "Average hours/day across assignments", 0), - n("input.absences", "Absences", `${absenceDays.length}`, "count", "INPUT", `Absence days in ${input.month}`, 0), + n("input.absences", "Absences", `${absenceDays.length}`, "count", "INPUT", `Absence days in ${input.month} (${vacationDayCount} vacation, ${sickDayCount} sick${halfDayCount > 0 ? `, ${halfDayCount} half-day` : ""})`, 0), + n("input.publicHolidays", "Public Holidays", `${publicHolidayCount}`, "count", "INPUT", `Public holidays in ${input.month}`, 0), n("input.calcRules", "Active Rules", `${calcRules.length}`, "count", "INPUT", "Active calculation rules", 0), n("input.targetPct", "Target", fmtPct(targetPct), "%", "INPUT", `Chargeability target (${resource.managementLevelGroup?.name ?? "legacy"})`, 0), + n("input.assignmentCount", "Assignments", `${assignments.length}`, "count", "INPUT", `Active assignments in ${input.month}`, 0), // SAH n("sah.calendarDays", "Calendar Days", `${sahResult.calendarDays}`, "days", "SAH", "Total calendar days in period", 1), n("sah.weekendDays", "Weekend Days", `${sahResult.weekendDays}`, "days", "SAH", "Saturday + Sunday count", 1), n("sah.grossWorkingDays", "Gross Work Days", `${sahResult.grossWorkingDays}`, "days", "SAH", "Calendar days minus weekends", 1, "calendarDays - weekendDays"), - n("sah.absenceDays", "Absence Ded.", `${sahResult.absenceDays}`, "days", "SAH", "Absences falling on working days", 1), - n("sah.netWorkingDays", "Net Work Days", `${sahResult.netWorkingDays}`, "days", "SAH", "Working days after deductions", 2, "gross - absences"), - n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(sahResult.effectiveHoursPerDay), "hours", "SAH", "Average effective hours per net working day", 2, "Σ(dailyHours × FTE) / netDays"), + n("sah.publicHolidayDays", "Holiday Ded.", `${sahResult.publicHolidayDays}`, "days", "SAH", "Public holidays falling on working days", 1), + n("sah.absenceDays", "Absence Ded.", `${sahResult.absenceDays}`, "days", "SAH", "Absences (vacation/sick) falling on working days", 1), + n("sah.netWorkingDays", "Net Work Days", `${sahResult.netWorkingDays}`, "days", "SAH", "Working days after deductions", 2, "gross - holidays - absences"), + n("sah.effectiveHoursPerDay", "Eff. Hrs/Day", fmtNum(sahResult.effectiveHoursPerDay), "hours", "SAH", "Average effective hours per net working day (FTE-scaled)", 2, "Σ(dailyHours × FTE) / netDays"), n("sah.sah", "SAH", fmtNum(sahResult.standardAvailableHours), "hours", "SAH", "Standard Available Hours — chargeability denominator", 2, "Σ(dailyHours × FTE) per net day"), // ALLOCATION - n("alloc.totalHours", "Total Hours", fmtNum(totalAllocHours), "hours", "ALLOCATION", "Sum of effective hours across assignments", 2, "Σ(effectiveHours/day)"), + n("alloc.workingDays", "Work Days", `${totalWorkingDaysInMonth}`, "days", "ALLOCATION", "Working days covered by assignments in period", 1, "Σ(overlap workdays)"), + n("alloc.totalHours", "Total Hours", fmtNum(totalAllocHours), "hours", "ALLOCATION", "Sum of effective hours across assignments", 2, "Σ(min(h/day, avail) × workdays)"), n("alloc.dailyCostCents", "Daily Cost", fmtEur(dailyCostCents), "EUR", "ALLOCATION", "Cost per working day", 1, "hoursPerDay × LCR"), - n("alloc.totalCostCents", "Total Cost", fmtEur(totalAllocCostCents), "EUR", "ALLOCATION", "Sum of daily costs", 2, "Σ(dailyCost)"), + n("alloc.totalCostCents", "Total Cost", fmtEur(totalAllocCostCents), "EUR", "ALLOCATION", "Sum of daily costs", 2, "Σ(dailyCost × workdays)"), + n("alloc.utilizationPct", "Utilization", `${utilizationPct.toFixed(1)}%`, "%", "ALLOCATION", "Allocation utilization: allocated hours / SAH", 3, "totalHours / SAH × 100"), ...(hasRulesEffect ? [ n("alloc.chargeableHours", "Chargeable Hrs", fmtNum(totalChargeableHours), "hours", "ALLOCATION", "Rules-adjusted chargeable hours", 2, "rules-adjusted"), n("alloc.projectCostCents", "Project Cost", fmtEur(totalProjectCostCents), "EUR", "ALLOCATION", "Rules-adjusted project cost", 2, "rules-adjusted"), @@ -345,11 +381,29 @@ export const computationGraphRouter = createTRPCRouter({ n("rules.activeRules", "Matched Rules", `${calcRules.length} rules`, "—", "RULES", "Rules evaluated for absence days", 1), n("rules.costEffect", "Cost Effect", hasRulesEffect ? "ZERO" : "—", "—", "RULES", "How absent days affect project cost", 1, "CHARGE / ZERO / REDUCE"), n("rules.chgEffect", "Chg Effect", hasRulesEffect ? "COUNT" : "—", "—", "RULES", "How absent days affect chargeability", 1, "COUNT / SKIP"), + ...(hasRulesEffect ? [ + n("rules.costReduction", "Cost Reduction", "per rule", "—", "RULES", "Cost reduction percentage applied to absent hours", 2, "normalCost × (100 - reductionPct) / 100"), + ] : []), ] : []), - // CHARGEABILITY + // CHARGEABILITY — full breakdown from deriveResourceForecast n("chg.chgHours", "Chg Hours", fmtNum(forecast.chg * sahResult.standardAvailableHours), "hours", "CHARGEABILITY", "Total chargeable hours", 2, "Σ(Chg-category slices)"), n("chg.chg", "Chargeability", fmtPct(forecast.chg), "%", "CHARGEABILITY", "Chargeability ratio", 3, "chgHours / SAH"), + ...(forecast.bd > 0 ? [ + n("chg.bd", "BD Ratio", fmtPct(forecast.bd), "%", "CHARGEABILITY", `Business development: ${fmtNum(forecast.bd * sahResult.standardAvailableHours)}h`, 3, "bdHours / SAH"), + ] : []), + ...(forecast.mdi > 0 ? [ + n("chg.mdi", "MD&I Ratio", fmtPct(forecast.mdi), "%", "CHARGEABILITY", `MD&I hours: ${fmtNum(forecast.mdi * sahResult.standardAvailableHours)}h`, 3, "mdiHours / SAH"), + ] : []), + ...(forecast.mo > 0 ? [ + n("chg.mo", "M&O Ratio", fmtPct(forecast.mo), "%", "CHARGEABILITY", `M&O hours: ${fmtNum(forecast.mo * sahResult.standardAvailableHours)}h`, 3, "moHours / SAH"), + ] : []), + ...(forecast.pdr > 0 ? [ + n("chg.pdr", "PD&R Ratio", fmtPct(forecast.pdr), "%", "CHARGEABILITY", `PD&R hours: ${fmtNum(forecast.pdr * sahResult.standardAvailableHours)}h`, 3, "pdrHours / SAH"), + ] : []), + ...(forecast.absence > 0 ? [ + n("chg.absence", "Absence Ratio", fmtPct(forecast.absence), "%", "CHARGEABILITY", `Absence hours: ${fmtNum(forecast.absence * sahResult.standardAvailableHours)}h`, 3, "absenceHours / SAH"), + ] : []), n("chg.unassigned", "Unassigned", fmtPct(forecast.unassigned), "%", "CHARGEABILITY", `${fmtNum(forecast.unassigned * sahResult.standardAvailableHours)}h of ${fmtNum(sahResult.standardAvailableHours)}h SAH not assigned`, 3, "max(0, SAH - assigned) / SAH"), n("chg.target", "Target", fmtPct(targetPct), "%", "CHARGEABILITY", "Chargeability target from management level", 3), n("chg.gap", "Gap to Target", `${forecast.chg - targetPct >= 0 ? "+" : ""}${((forecast.chg - targetPct) * 100).toFixed(1)} pp`, "pp", "CHARGEABILITY", `Chargeability (${fmtPct(forecast.chg)}) vs. target (${fmtPct(targetPct)})`, 3, "chargeability − target"), @@ -361,10 +415,15 @@ export const computationGraphRouter = createTRPCRouter({ const links: GraphLink[] = [ // INPUT → SAH l("input.dailyHours", "sah.grossWorkingDays", "base hours", 1), + ...(hasScheduleRules ? [ + l("input.scheduleRules", "sah.effectiveHoursPerDay", "variable h/day", 1), + ] : []), l("sah.calendarDays", "sah.grossWorkingDays", "− weekends", 2), l("sah.weekendDays", "sah.grossWorkingDays", "−", 1), + l("input.publicHolidays", "sah.publicHolidayDays", "∩ workdays", 1), l("input.absences", "sah.absenceDays", "∩ workdays", 1), l("sah.grossWorkingDays", "sah.netWorkingDays", "−", 2), + l("sah.publicHolidayDays", "sah.netWorkingDays", "−", 1), l("sah.absenceDays", "sah.netWorkingDays", "−", 1), l("input.dailyHours", "sah.effectiveHoursPerDay", "×", 1), l("input.fte", "sah.effectiveHoursPerDay", "× FTE", 2), @@ -376,8 +435,14 @@ export const computationGraphRouter = createTRPCRouter({ l("input.weeklyAvail", "alloc.totalHours", "caps h/day", 2), l("input.hoursPerDay", "alloc.dailyCostCents", "×", 1), l("input.lcrCents", "alloc.dailyCostCents", "× LCR", 2), + l("input.hoursPerDay", "alloc.workingDays", "per assignment", 1), + l("input.assignmentCount", "alloc.workingDays", "× overlap", 1), + l("alloc.workingDays", "alloc.totalHours", "× h/day", 2), l("input.hoursPerDay", "alloc.totalHours", "× workdays", 1), - l("alloc.dailyCostCents", "alloc.totalCostCents", "Σ", 2), + l("alloc.dailyCostCents", "alloc.totalCostCents", "× workdays", 2), + l("alloc.workingDays", "alloc.totalCostCents", "×", 1), + l("alloc.totalHours", "alloc.utilizationPct", "÷ SAH × 100", 2), + l("sah.sah", "alloc.utilizationPct", "÷", 1), // RULES → ALLOCATION (if absences) ...(absenceDays.length > 0 ? [ @@ -392,12 +457,20 @@ export const computationGraphRouter = createTRPCRouter({ l("alloc.totalCostCents", "alloc.projectCostCents", "adjust", 1), l("rules.chgEffect", "alloc.chargeableHours", "apply", 2), l("alloc.totalHours", "alloc.chargeableHours", "adjust", 1), + ...(absenceDays.length > 0 ? [ + l("rules.costEffect", "rules.costReduction", "reduce %", 1), + ] : []), ] : []), // ALLOCATION + SAH → CHARGEABILITY l(hasRulesEffect ? "alloc.chargeableHours" : "alloc.totalHours", "chg.chgHours", "Σ Chg", 2), l("chg.chgHours", "chg.chg", "÷ SAH", 2), l("sah.sah", "chg.chg", "÷", 2), + ...(forecast.bd > 0 ? [l("sah.sah", "chg.bd", "÷", 1)] : []), + ...(forecast.mdi > 0 ? [l("sah.sah", "chg.mdi", "÷", 1)] : []), + ...(forecast.mo > 0 ? [l("sah.sah", "chg.mo", "÷", 1)] : []), + ...(forecast.pdr > 0 ? [l("sah.sah", "chg.pdr", "÷", 1)] : []), + ...(forecast.absence > 0 ? [l("sah.sah", "chg.absence", "÷", 1)] : []), l("sah.sah", "chg.unassigned", "− assigned ÷ SAH", 1), l("chg.chgHours", "chg.unassigned", "SAH − Σ", 1), l("input.targetPct", "chg.target", "=", 1), @@ -421,7 +494,7 @@ export const computationGraphRouter = createTRPCRouter({ }), /** - * Project View: Estimate, Commercial, Budget + * Project View: Estimate, Commercial, Experience, Effort, Spread, Budget */ getProjectData: controllerProcedure .input(z.object({ @@ -441,7 +514,7 @@ export const computationGraphRouter = createTRPCRouter({ }, }); - // Load latest estimate version with demand lines + // Load latest estimate version with demand lines + scope items const estimate = await ctx.db.estimate.findFirst({ where: { projectId: input.projectId }, select: { @@ -454,11 +527,38 @@ export const computationGraphRouter = createTRPCRouter({ commercialTerms: true, demandLines: { select: { + id: true, hours: true, costRateCents: true, billRateCents: true, costTotalCents: true, priceTotalCents: true, + chapter: true, + monthlySpread: true, + scopeItemId: true, + resourceId: true, + }, + }, + scopeItems: { + select: { + id: true, + name: true, + scopeType: true, + frameCount: true, + itemCount: true, + unitMode: true, + }, + }, + resourceSnapshots: { + select: { + id: true, + resourceId: true, + displayName: true, + chapter: true, + lcrCents: true, + ucrCents: true, + location: true, + level: true, }, }, }, @@ -468,38 +568,66 @@ export const computationGraphRouter = createTRPCRouter({ }); const latestVersion = estimate?.versions[0]; + // Load effort rule sets and experience multiplier sets for this project + let effortRuleCount = 0; + let experienceRuleCount = 0; + try { + effortRuleCount = await ctx.db.effortRule.count(); + experienceRuleCount = await ctx.db.experienceMultiplierRule.count(); + } catch { + // tables may not exist yet + } + const nodes: GraphNode[] = []; const links: GraphLink[] = []; - // Budget inputs + // Budget + project inputs const hasBudget = project.budgetCents > 0; + const hasDateRange = !!(project.startDate && project.endDate); nodes.push( n("input.budgetCents", "Project Budget", hasBudget ? fmtEur(project.budgetCents) : "Not set", hasBudget ? "EUR" : "—", "INPUT", hasBudget ? `Budget for ${project.name}` : `No budget defined for ${project.name}`, 0), n("input.winProbability", "Win Probability", `${project.winProbability}%`, "%", "INPUT", "Project win probability", 0), + ...(hasDateRange ? [ + n("input.projectStart", "Project Start", project.startDate!.toISOString().slice(0, 10), "date", "INPUT", "Project start date", 0), + n("input.projectEnd", "Project End", project.endDate!.toISOString().slice(0, 10), "date", "INPUT", "Project end date", 0), + ] : []), ); - if (latestVersion && latestVersion.demandLines.length > 0) { const lines = latestVersion.demandLines; - const totalHours = lines.reduce((s, dl) => s + dl.hours, 0); - const totalCostCents = lines.reduce((s, dl) => s + dl.costTotalCents, 0); - const totalPriceCents = lines.reduce((s, dl) => s + dl.priceTotalCents, 0); - const marginCents = totalPriceCents - totalCostCents; - const marginPct = totalPriceCents > 0 ? (marginCents / totalPriceCents) * 100 : 0; + const summary = summarizeEstimateDemandLines(lines); + const { totalHours, totalCostCents, totalPriceCents, marginCents, marginPercent: marginPct } = summary; // Average rates const avgCostRate = totalHours > 0 ? Math.round(totalCostCents / totalHours) : 0; const avgBillRate = totalHours > 0 ? Math.round(totalPriceCents / totalHours) : 0; + // Chapters + const chapterMap = new Map