e7b74f13bd
- Extract shared render helpers (vacation blocks, range overlay, overbooking blink) into renderHelpers.tsx - Centralize status badge styles and vacation color maps into status-styles.ts - Extract dragMath.ts utility from useTimelineDrag for reuse - Split useInvalidatePlanningViews into useInvalidateTimeline (4 queries) + useInvalidatePlanningViews (8 queries) - Adopt findUniqueOrThrow() and Prisma select constants across API routers - Add shared fmtEur() helper for API-side money formatting - Wrap TimelineResourcePanel and TimelineProjectPanel with React.memo - Fix pre-existing TS2589 deep type errors in TeamCalendar and VacationModal - 38 files changed, reducing ~400 lines of duplicated code Co-Authored-By: claude-flow <ruv@ruv.net>
617 lines
30 KiB
TypeScript
617 lines
30 KiB
TypeScript
import {
|
||
calculateSAH,
|
||
calculateAllocation,
|
||
deriveResourceForecast,
|
||
computeBudgetStatus,
|
||
getMonthRange,
|
||
countWorkingDaysInOverlap,
|
||
DEFAULT_CALCULATION_RULES,
|
||
type AssignmentSlice,
|
||
} from "@planarchy/engine";
|
||
import type { CalculationRule, AbsenceDay, SpainScheduleRule, WeekdayAvailability } from "@planarchy/shared";
|
||
import { VacationStatus } from "@planarchy/db";
|
||
import { z } from "zod";
|
||
import { createTRPCRouter, controllerProcedure } from "../trpc.js";
|
||
import { fmtEur } from "../lib/format-utils.js";
|
||
|
||
// ─── Graph Types (mirrored from client for API response) ────────────────────
|
||
|
||
type Domain =
|
||
| "INPUT" | "SAH" | "ALLOCATION" | "RULES" | "CHARGEABILITY" | "BUDGET"
|
||
| "ESTIMATE" | "COMMERCIAL" | "EXPERIENCE" | "EFFORT" | "SPREAD";
|
||
|
||
export interface GraphNode {
|
||
id: string;
|
||
label: string;
|
||
value: number | string;
|
||
unit: string;
|
||
domain: Domain;
|
||
description: string;
|
||
formula?: string;
|
||
level: number;
|
||
}
|
||
|
||
export interface GraphLink {
|
||
source: string;
|
||
target: string;
|
||
formula: string;
|
||
weight: number;
|
||
}
|
||
|
||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||
|
||
function n(
|
||
id: string, label: string, value: number | string, unit: string,
|
||
domain: Domain, description: string, level: number, formula?: string,
|
||
): GraphNode {
|
||
return { id, label, value, unit, domain, description, level, ...(formula ? { formula } : {}) };
|
||
}
|
||
|
||
function l(source: string, target: string, formula: string, weight = 1): GraphLink {
|
||
return { source, target, formula, weight };
|
||
}
|
||
|
||
function fmtPct(ratio: number): string {
|
||
return `${(ratio * 100).toFixed(1)}%`;
|
||
}
|
||
|
||
function fmtNum(v: number, decimals = 1): string {
|
||
return v.toFixed(decimals);
|
||
}
|
||
|
||
// ─── Router ─────────────────────────────────────────────────────────────────
|
||
|
||
export const computationGraphRouter = createTRPCRouter({
|
||
/**
|
||
* Resource View: SAH, Allocation, Rules, Chargeability, Budget
|
||
* for a single resource in a single month.
|
||
*/
|
||
getResourceData: controllerProcedure
|
||
.input(z.object({
|
||
resourceId: z.string(),
|
||
month: z.string().regex(/^\d{4}-\d{2}$/),
|
||
}))
|
||
.query(async ({ ctx, input }) => {
|
||
const [year, month] = input.month.split("-").map(Number) as [number, number];
|
||
const { start: monthStart, end: monthEnd } = getMonthRange(year, month);
|
||
|
||
// ── 1. Load resource ──
|
||
const resource = await ctx.db.resource.findUniqueOrThrow({
|
||
where: { id: input.resourceId },
|
||
select: {
|
||
id: true,
|
||
displayName: true,
|
||
eid: true,
|
||
fte: true,
|
||
lcrCents: true,
|
||
chargeabilityTarget: true,
|
||
availability: true,
|
||
country: { select: { id: true, code: true, dailyWorkingHours: true, scheduleRules: true } },
|
||
managementLevelGroup: { select: { id: true, name: true, targetPercentage: true } },
|
||
},
|
||
});
|
||
|
||
const dailyHours = resource.country?.dailyWorkingHours ?? 8;
|
||
const scheduleRules = resource.country?.scheduleRules as SpainScheduleRule | null;
|
||
const targetPct = resource.managementLevelGroup?.targetPercentage ?? (resource.chargeabilityTarget / 100);
|
||
|
||
// Resource weekly availability (per-day hours)
|
||
const avail = resource.availability as WeekdayAvailability | null;
|
||
const weeklyAvailability: WeekdayAvailability = avail ?? {
|
||
monday: dailyHours, tuesday: dailyHours, wednesday: dailyHours,
|
||
thursday: dailyHours, friday: dailyHours, saturday: 0, sunday: 0,
|
||
};
|
||
|
||
// ── 2. Load assignments in month ──
|
||
const assignments = await ctx.db.assignment.findMany({
|
||
where: {
|
||
resourceId: input.resourceId,
|
||
startDate: { lte: monthEnd },
|
||
endDate: { gte: monthStart },
|
||
status: { in: ["CONFIRMED", "ACTIVE", "PROPOSED"] },
|
||
},
|
||
select: {
|
||
id: true,
|
||
hoursPerDay: true,
|
||
startDate: true,
|
||
endDate: true,
|
||
dailyCostCents: true,
|
||
status: true,
|
||
project: {
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
shortCode: true,
|
||
budgetCents: true,
|
||
winProbability: true,
|
||
utilizationCategory: { select: { code: true } },
|
||
},
|
||
},
|
||
},
|
||
});
|
||
|
||
// ── 3. Load absences ──
|
||
const vacations = await ctx.db.vacation.findMany({
|
||
where: {
|
||
resourceId: input.resourceId,
|
||
status: VacationStatus.APPROVED,
|
||
startDate: { lte: monthEnd },
|
||
endDate: { gte: monthStart },
|
||
},
|
||
select: { startDate: true, endDate: true, type: true, isHalfDay: true },
|
||
});
|
||
|
||
// Build absence dates for SAH (ISO strings)
|
||
const absenceDateStrings: string[] = [];
|
||
const absenceDays: AbsenceDay[] = [];
|
||
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()));
|
||
if (vStart > vEnd) continue;
|
||
const cursor = new Date(vStart);
|
||
cursor.setUTCHours(0, 0, 0, 0);
|
||
const endNorm = new Date(vEnd);
|
||
endNorm.setUTCHours(0, 0, 0, 0);
|
||
const triggerType = v.type === "SICK" ? "SICK" as const
|
||
: v.type === "PUBLIC_HOLIDAY" ? "PUBLIC_HOLIDAY" as const
|
||
: "VACATION" as const;
|
||
while (cursor <= endNorm) {
|
||
absenceDateStrings.push(cursor.toISOString().slice(0, 10));
|
||
absenceDays.push({
|
||
date: new Date(cursor),
|
||
type: triggerType,
|
||
...(v.isHalfDay ? { isHalfDay: true } : {}),
|
||
});
|
||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||
}
|
||
}
|
||
|
||
// ── 4. Load calculation rules ──
|
||
let calcRules: CalculationRule[] = DEFAULT_CALCULATION_RULES;
|
||
try {
|
||
const dbRules = await ctx.db.calculationRule.findMany({
|
||
where: { isActive: true },
|
||
orderBy: [{ priority: "desc" }],
|
||
});
|
||
if (dbRules.length > 0) {
|
||
calcRules = dbRules as unknown as CalculationRule[];
|
||
}
|
||
} catch {
|
||
// table may not exist yet
|
||
}
|
||
|
||
// ── 5. Calculate SAH ──
|
||
const sahResult = calculateSAH({
|
||
dailyWorkingHours: dailyHours,
|
||
scheduleRules,
|
||
fte: resource.fte,
|
||
periodStart: monthStart,
|
||
periodEnd: monthEnd,
|
||
publicHolidays: [],
|
||
absenceDays: absenceDateStrings,
|
||
});
|
||
|
||
// ── 6. Calculate allocations + chargeability slices ──
|
||
const slices: AssignmentSlice[] = [];
|
||
let totalAllocHours = 0;
|
||
let totalAllocCostCents = 0;
|
||
let totalChargeableHours = 0;
|
||
let totalProjectCostCents = 0;
|
||
let hasRulesEffect = false;
|
||
|
||
for (const a of assignments) {
|
||
const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, a.startDate, a.endDate);
|
||
if (workingDays <= 0) continue;
|
||
|
||
const overlapStart = new Date(Math.max(monthStart.getTime(), a.startDate.getTime()));
|
||
const overlapEnd = new Date(Math.min(monthEnd.getTime(), a.endDate.getTime()));
|
||
const categoryCode = a.project.utilizationCategory?.code ?? "Chg";
|
||
|
||
const calcResult = calculateAllocation({
|
||
lcrCents: resource.lcrCents,
|
||
hoursPerDay: a.hoursPerDay,
|
||
startDate: overlapStart,
|
||
endDate: overlapEnd,
|
||
availability: weeklyAvailability,
|
||
absenceDays,
|
||
calculationRules: calcRules,
|
||
});
|
||
|
||
totalAllocHours += calcResult.totalHours;
|
||
totalAllocCostCents += calcResult.totalCostCents;
|
||
if (calcResult.totalChargeableHours !== undefined) {
|
||
totalChargeableHours += calcResult.totalChargeableHours;
|
||
totalProjectCostCents += calcResult.totalProjectCostCents ?? calcResult.totalCostCents;
|
||
hasRulesEffect = true;
|
||
} else {
|
||
totalChargeableHours += calcResult.totalHours;
|
||
totalProjectCostCents += calcResult.totalCostCents;
|
||
}
|
||
|
||
slices.push({
|
||
hoursPerDay: a.hoursPerDay,
|
||
workingDays,
|
||
categoryCode,
|
||
...(calcResult.totalChargeableHours !== undefined
|
||
? { totalChargeableHours: calcResult.totalChargeableHours }
|
||
: {}),
|
||
});
|
||
}
|
||
|
||
// ── 7. Calculate chargeability forecast ──
|
||
const forecast = deriveResourceForecast({
|
||
fte: resource.fte,
|
||
targetPercentage: targetPct,
|
||
assignments: slices,
|
||
sah: sahResult.standardAvailableHours,
|
||
});
|
||
|
||
// ── 8. Build budget status for first project with budget ──
|
||
const budgetProject = assignments.find((a) => a.project.budgetCents != null && a.project.budgetCents > 0)?.project;
|
||
let budgetNodes: GraphNode[] = [];
|
||
let budgetLinks: GraphLink[] = [];
|
||
if (budgetProject && budgetProject.budgetCents != null) {
|
||
// Load all allocations for this project to compute budget
|
||
const projectAllocs = await ctx.db.assignment.findMany({
|
||
where: { projectId: budgetProject.id },
|
||
select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true },
|
||
});
|
||
const budgetStatus = computeBudgetStatus(
|
||
budgetProject.budgetCents,
|
||
budgetProject.winProbability,
|
||
projectAllocs.map((pa) => ({
|
||
status: pa.status as unknown as string,
|
||
dailyCostCents: pa.dailyCostCents,
|
||
startDate: pa.startDate,
|
||
endDate: pa.endDate,
|
||
hoursPerDay: pa.hoursPerDay,
|
||
})) as Parameters<typeof computeBudgetStatus>[2],
|
||
monthStart,
|
||
monthEnd,
|
||
);
|
||
|
||
budgetNodes = [
|
||
n("input.budgetCents", "Project Budget", fmtEur(budgetProject.budgetCents), "EUR", "INPUT", `Budget for ${budgetProject.name}`, 0),
|
||
n("input.winProbability", "Win Probability", `${budgetProject.winProbability}%`, "%", "INPUT", "Project win probability", 0),
|
||
n("budget.confirmedCents", "Confirmed", fmtEur(budgetStatus.confirmedCents), "EUR", "BUDGET", "Sum of CONFIRMED/ACTIVE allocation costs", 2, "Σ(confirmed allocs)"),
|
||
n("budget.proposedCents", "Proposed", fmtEur(budgetStatus.proposedCents), "EUR", "BUDGET", "Sum of PROPOSED allocation costs", 2, "Σ(proposed allocs)"),
|
||
n("budget.allocatedCents", "Allocated", fmtEur(budgetStatus.allocatedCents), "EUR", "BUDGET", "Total allocated budget", 2, "confirmed + proposed"),
|
||
n("budget.remainingCents", "Remaining", fmtEur(budgetStatus.remainingCents), "EUR", "BUDGET", "Remaining budget", 3, "budget - allocated"),
|
||
n("budget.utilizationPct", "Utilization", `${budgetStatus.utilizationPercent.toFixed(1)}%`, "%", "BUDGET", "Budget utilization percentage", 3, "allocated / budget × 100"),
|
||
n("budget.weightedCents", "Win-Weighted", fmtEur(budgetStatus.winProbabilityWeightedCents), "EUR", "BUDGET", "Win-probability-weighted cost", 3, "allocated × winProb / 100"),
|
||
];
|
||
budgetLinks = [
|
||
l("alloc.totalCostCents", "budget.confirmedCents", "per assignment", 1),
|
||
l("budget.confirmedCents", "budget.allocatedCents", "+", 2),
|
||
l("budget.proposedCents", "budget.allocatedCents", "+", 2),
|
||
l("input.budgetCents", "budget.remainingCents", "−", 2),
|
||
l("budget.allocatedCents", "budget.remainingCents", "−", 2),
|
||
l("budget.allocatedCents", "budget.utilizationPct", "÷ budget × 100", 2),
|
||
l("input.budgetCents", "budget.utilizationPct", "÷", 1),
|
||
l("budget.allocatedCents", "budget.weightedCents", "× winProb / 100", 1),
|
||
l("input.winProbability", "budget.weightedCents", "×", 1),
|
||
];
|
||
}
|
||
|
||
// ── 9. Build graph nodes + links ──
|
||
|
||
const dailyCostCents = assignments.length > 0
|
||
? Math.round(assignments[0]!.hoursPerDay * resource.lcrCents)
|
||
: 0;
|
||
const avgHoursPerDay = assignments.length > 0
|
||
? assignments.reduce((sum, a) => sum + a.hoursPerDay, 0) / assignments.length
|
||
: 0;
|
||
|
||
// Format weekly availability for display
|
||
const weekdayLabels = ["Mo", "Tu", "We", "Th", "Fr"];
|
||
const weekdayValues = [weeklyAvailability.monday, weeklyAvailability.tuesday, weeklyAvailability.wednesday, weeklyAvailability.thursday, weeklyAvailability.friday];
|
||
const weeklyTotalHours = weekdayValues.reduce((s, v) => s + v, 0);
|
||
const allSame = weekdayValues.every((v) => v === weekdayValues[0]);
|
||
const availabilityLabel = allSame
|
||
? `${weekdayValues[0]}h/day`
|
||
: weekdayLabels.map((d, i) => `${d}:${weekdayValues[i]}`).join(" ");
|
||
|
||
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),
|
||
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.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),
|
||
|
||
// 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.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.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)"),
|
||
...(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"),
|
||
] : []),
|
||
|
||
// RULES (only if absences exist)
|
||
...(absenceDays.length > 0 ? [
|
||
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"),
|
||
] : []),
|
||
|
||
// CHARGEABILITY
|
||
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"),
|
||
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"),
|
||
|
||
// Budget nodes (conditionally added above)
|
||
...budgetNodes,
|
||
];
|
||
|
||
const links: GraphLink[] = [
|
||
// INPUT → SAH
|
||
l("input.dailyHours", "sah.grossWorkingDays", "base hours", 1),
|
||
l("sah.calendarDays", "sah.grossWorkingDays", "− weekends", 2),
|
||
l("sah.weekendDays", "sah.grossWorkingDays", "−", 1),
|
||
l("input.absences", "sah.absenceDays", "∩ workdays", 1),
|
||
l("sah.grossWorkingDays", "sah.netWorkingDays", "−", 2),
|
||
l("sah.absenceDays", "sah.netWorkingDays", "−", 1),
|
||
l("input.dailyHours", "sah.effectiveHoursPerDay", "×", 1),
|
||
l("input.fte", "sah.effectiveHoursPerDay", "× FTE", 2),
|
||
l("sah.netWorkingDays", "sah.effectiveHoursPerDay", "÷", 1),
|
||
l("sah.effectiveHoursPerDay", "sah.sah", "× netDays", 2),
|
||
l("sah.netWorkingDays", "sah.sah", "×", 2),
|
||
|
||
// INPUT → ALLOCATION
|
||
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.totalHours", "× workdays", 1),
|
||
l("alloc.dailyCostCents", "alloc.totalCostCents", "Σ", 2),
|
||
|
||
// RULES → ALLOCATION (if absences)
|
||
...(absenceDays.length > 0 ? [
|
||
l("input.calcRules", "rules.activeRules", "filter active", 1),
|
||
l("input.absences", "rules.activeRules", "match trigger", 1),
|
||
l("rules.activeRules", "rules.costEffect", "→ effect", 1),
|
||
l("rules.activeRules", "rules.chgEffect", "→ effect", 1),
|
||
] : []),
|
||
|
||
...(hasRulesEffect ? [
|
||
l("rules.costEffect", "alloc.projectCostCents", "apply", 2),
|
||
l("alloc.totalCostCents", "alloc.projectCostCents", "adjust", 1),
|
||
l("rules.chgEffect", "alloc.chargeableHours", "apply", 2),
|
||
l("alloc.totalHours", "alloc.chargeableHours", "adjust", 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),
|
||
l("sah.sah", "chg.unassigned", "− assigned ÷ SAH", 1),
|
||
l("chg.chgHours", "chg.unassigned", "SAH − Σ", 1),
|
||
l("input.targetPct", "chg.target", "=", 1),
|
||
l("chg.chg", "chg.gap", "−", 2),
|
||
l("chg.target", "chg.gap", "−", 1),
|
||
|
||
// Budget links (conditionally added above)
|
||
...budgetLinks,
|
||
];
|
||
|
||
return {
|
||
nodes,
|
||
links,
|
||
meta: {
|
||
resourceName: resource.displayName,
|
||
resourceEid: resource.eid,
|
||
month: input.month,
|
||
assignmentCount: assignments.length,
|
||
},
|
||
};
|
||
}),
|
||
|
||
/**
|
||
* Project View: Estimate, Commercial, Budget
|
||
*/
|
||
getProjectData: controllerProcedure
|
||
.input(z.object({
|
||
projectId: z.string(),
|
||
}))
|
||
.query(async ({ ctx, input }) => {
|
||
const project = await ctx.db.project.findUniqueOrThrow({
|
||
where: { id: input.projectId },
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
shortCode: true,
|
||
budgetCents: true,
|
||
winProbability: true,
|
||
startDate: true,
|
||
endDate: true,
|
||
},
|
||
});
|
||
|
||
// Load latest estimate version with demand lines
|
||
const estimate = await ctx.db.estimate.findFirst({
|
||
where: { projectId: input.projectId },
|
||
select: {
|
||
id: true,
|
||
versions: {
|
||
orderBy: { versionNumber: "desc" },
|
||
take: 1,
|
||
select: {
|
||
id: true,
|
||
commercialTerms: true,
|
||
demandLines: {
|
||
select: {
|
||
hours: true,
|
||
costRateCents: true,
|
||
billRateCents: true,
|
||
costTotalCents: true,
|
||
priceTotalCents: true,
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
orderBy: { updatedAt: "desc" },
|
||
});
|
||
const latestVersion = estimate?.versions[0];
|
||
|
||
const nodes: GraphNode[] = [];
|
||
const links: GraphLink[] = [];
|
||
|
||
// Budget inputs
|
||
const hasBudget = project.budgetCents > 0;
|
||
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),
|
||
);
|
||
|
||
|
||
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;
|
||
|
||
// Average rates
|
||
const avgCostRate = totalHours > 0 ? Math.round(totalCostCents / totalHours) : 0;
|
||
const avgBillRate = totalHours > 0 ? Math.round(totalPriceCents / totalHours) : 0;
|
||
|
||
nodes.push(
|
||
n("input.estLines", "Demand Lines", `${lines.length}`, "count", "INPUT", "Estimate demand line count", 0),
|
||
n("input.avgCostRate", "Avg Cost Rate", fmtEur(avgCostRate), "cents/h", "INPUT", "Average cost rate across demand lines", 0),
|
||
n("input.avgBillRate", "Avg Bill Rate", fmtEur(avgBillRate), "cents/h", "INPUT", "Average bill rate across demand lines", 0),
|
||
n("est.totalHours", "Est. Hours", fmtNum(totalHours), "hours", "ESTIMATE", "Total estimated hours", 2, "Σ(line.hours)"),
|
||
n("est.totalCostCents", "Est. Cost", fmtEur(totalCostCents), "EUR", "ESTIMATE", "Total estimated cost", 2, "Σ(hours × costRate)"),
|
||
n("est.totalPriceCents", "Est. Price", fmtEur(totalPriceCents), "EUR", "ESTIMATE", "Total estimated price", 2, "Σ(hours × billRate)"),
|
||
n("est.marginCents", "Margin", fmtEur(marginCents), "EUR", "ESTIMATE", "Price minus cost", 3, "price - cost"),
|
||
n("est.marginPercent", "Margin %", `${marginPct.toFixed(1)}%`, "%", "ESTIMATE", "Margin as percentage of price", 3, "margin / price × 100"),
|
||
);
|
||
|
||
links.push(
|
||
l("input.estLines", "est.totalHours", "Σ hours", 1),
|
||
l("input.avgCostRate", "est.totalCostCents", "× hours", 2),
|
||
l("est.totalHours", "est.totalCostCents", "× costRate", 2),
|
||
l("input.avgBillRate", "est.totalPriceCents", "× hours", 2),
|
||
l("est.totalHours", "est.totalPriceCents", "× billRate", 2),
|
||
l("est.totalPriceCents", "est.marginCents", "−", 2),
|
||
l("est.totalCostCents", "est.marginCents", "−", 2),
|
||
l("est.marginCents", "est.marginPercent", "÷ price × 100", 2),
|
||
l("est.totalPriceCents", "est.marginPercent", "÷", 1),
|
||
);
|
||
|
||
// Commercial terms
|
||
const terms = latestVersion.commercialTerms as { contingencyPercent?: number; discountPercent?: number } | null;
|
||
if (terms && (terms.contingencyPercent || terms.discountPercent)) {
|
||
const contingencyPct = terms.contingencyPercent ?? 0;
|
||
const discountPct = terms.discountPercent ?? 0;
|
||
const contingencyCents = Math.round(totalCostCents * contingencyPct / 100);
|
||
const discountCents = Math.round(totalPriceCents * discountPct / 100);
|
||
const adjCost = totalCostCents + contingencyCents;
|
||
const adjPrice = totalPriceCents - discountCents;
|
||
const adjMargin = adjPrice - adjCost;
|
||
const adjMarginPct = adjPrice > 0 ? (adjMargin / adjPrice) * 100 : 0;
|
||
|
||
nodes.push(
|
||
n("input.contingencyPct", "Contingency %", `${contingencyPct}%`, "%", "INPUT", "Contingency percentage", 0),
|
||
n("input.discountPct", "Discount %", `${discountPct}%`, "%", "INPUT", "Discount percentage", 0),
|
||
n("comm.contingencyCents", "Contingency", fmtEur(contingencyCents), "EUR", "COMMERCIAL", "Contingency surcharge", 2, "baseCost × contingency%"),
|
||
n("comm.discountCents", "Discount", fmtEur(discountCents), "EUR", "COMMERCIAL", "Discount deduction", 2, "basePrice × discount%"),
|
||
n("comm.adjustedCost", "Adj. Cost", fmtEur(adjCost), "EUR", "COMMERCIAL", "Cost plus contingency", 3, "baseCost + contingency"),
|
||
n("comm.adjustedPrice", "Adj. Price", fmtEur(adjPrice), "EUR", "COMMERCIAL", "Price minus discount", 3, "basePrice - discount"),
|
||
n("comm.adjustedMargin", "Adj. Margin", fmtEur(adjMargin), "EUR", "COMMERCIAL", "Adjusted margin", 3, "adjPrice - adjCost"),
|
||
n("comm.adjustedMarginPct", "Adj. Margin %", `${adjMarginPct.toFixed(1)}%`, "%", "COMMERCIAL", "Adjusted margin percentage", 3, "adjMargin / adjPrice × 100"),
|
||
);
|
||
|
||
links.push(
|
||
l("est.totalCostCents", "comm.contingencyCents", "×", 1),
|
||
l("input.contingencyPct", "comm.contingencyCents", "× %", 1),
|
||
l("est.totalPriceCents", "comm.discountCents", "×", 1),
|
||
l("input.discountPct", "comm.discountCents", "× %", 1),
|
||
l("est.totalCostCents", "comm.adjustedCost", "+", 2),
|
||
l("comm.contingencyCents", "comm.adjustedCost", "+", 2),
|
||
l("est.totalPriceCents", "comm.adjustedPrice", "−", 2),
|
||
l("comm.discountCents", "comm.adjustedPrice", "−", 2),
|
||
l("comm.adjustedPrice", "comm.adjustedMargin", "−", 2),
|
||
l("comm.adjustedCost", "comm.adjustedMargin", "−", 2),
|
||
l("comm.adjustedMargin", "comm.adjustedMarginPct", "÷ price × 100", 2),
|
||
l("comm.adjustedPrice", "comm.adjustedMarginPct", "÷", 1),
|
||
);
|
||
}
|
||
}
|
||
|
||
// Budget status — always show allocation totals; remaining/utilization only when budget > 0
|
||
const projectAllocs = await ctx.db.assignment.findMany({
|
||
where: { projectId: input.projectId },
|
||
select: { status: true, dailyCostCents: true, startDate: true, endDate: true, hoursPerDay: true },
|
||
});
|
||
|
||
if (projectAllocs.length > 0) {
|
||
const budgetStatus = computeBudgetStatus(
|
||
project.budgetCents,
|
||
project.winProbability,
|
||
projectAllocs.map((pa) => ({
|
||
status: pa.status as unknown as string,
|
||
dailyCostCents: pa.dailyCostCents,
|
||
startDate: pa.startDate,
|
||
endDate: pa.endDate,
|
||
hoursPerDay: pa.hoursPerDay,
|
||
})) as Parameters<typeof computeBudgetStatus>[2],
|
||
project.startDate ?? new Date(),
|
||
project.endDate ?? new Date(),
|
||
);
|
||
|
||
nodes.push(
|
||
n("budget.confirmedCents", "Confirmed", fmtEur(budgetStatus.confirmedCents), "EUR", "BUDGET", "Confirmed allocation costs", 2, "Σ(CONFIRMED allocs)"),
|
||
n("budget.proposedCents", "Proposed", fmtEur(budgetStatus.proposedCents), "EUR", "BUDGET", "Proposed allocation costs", 2, "Σ(PROPOSED allocs)"),
|
||
n("budget.allocatedCents", "Allocated", fmtEur(budgetStatus.allocatedCents), "EUR", "BUDGET", "Total allocated", 2, "confirmed + proposed"),
|
||
n("budget.remainingCents", "Remaining",
|
||
hasBudget ? fmtEur(budgetStatus.remainingCents) : "N/A",
|
||
hasBudget ? "EUR" : "—", "BUDGET",
|
||
hasBudget ? "Remaining budget" : "Cannot compute — no budget set",
|
||
3, hasBudget ? "budget - allocated" : "needs budget"),
|
||
n("budget.utilizationPct", "Utilization",
|
||
hasBudget ? `${budgetStatus.utilizationPercent.toFixed(1)}%` : "N/A",
|
||
hasBudget ? "%" : "—", "BUDGET",
|
||
hasBudget ? "Budget utilization" : "Cannot compute — no budget set",
|
||
3, hasBudget ? "allocated / budget × 100" : "needs budget"),
|
||
n("budget.weightedCents", "Win-Weighted", fmtEur(budgetStatus.winProbabilityWeightedCents), "EUR", "BUDGET", "Win-weighted cost", 3, "allocated × winProb / 100"),
|
||
);
|
||
|
||
links.push(
|
||
l("budget.confirmedCents", "budget.allocatedCents", "+", 2),
|
||
l("budget.proposedCents", "budget.allocatedCents", "+", 2),
|
||
l("input.budgetCents", "budget.remainingCents", "−", 2),
|
||
l("budget.allocatedCents", "budget.remainingCents", "−", 2),
|
||
l("budget.allocatedCents", "budget.utilizationPct", "÷ budget × 100", 2),
|
||
l("input.budgetCents", "budget.utilizationPct", "÷", 1),
|
||
l("budget.allocatedCents", "budget.weightedCents", "× winProb / 100", 1),
|
||
l("input.winProbability", "budget.weightedCents", "×", 1),
|
||
);
|
||
}
|
||
|
||
return {
|
||
nodes,
|
||
links,
|
||
meta: {
|
||
projectName: project.name,
|
||
projectCode: project.shortCode,
|
||
},
|
||
};
|
||
}),
|
||
});
|