From 6f34659587b7cdaacac8acda5f53119ac5d8db0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Thu, 19 Mar 2026 21:39:05 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=203=20=E2=80=94=20automation,=20?= =?UTF-8?q?intelligence,=20skill=20marketplace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-Staffing Suggestions (A6): - generateAutoSuggestions() ranks top-3 resources on demand creation - Uses existing staffing engine (skill 40%, availability 30%, cost 20%, util 10%) - Creates in-app notification with match scores for managers - Triggered after createDemandRequirement and partial fillDemandRequirement Vacation Conflict Detection (A7): - checkVacationConflicts() warns when >50% chapter absent on same days - Returns warnings array in approve/batchApprove responses (advisory, non-blocking) - Creates VACATION_CONFLICT_WARNING notification for approver Weekly Chargeability Alerts (A10): - checkChargeabilityAlerts() finds resources >15pp below target - Cron endpoint: GET /api/cron/chargeability-alerts - Duplicate-safe by resourceId + month composite key Rate Card Auto-Apply (A11): - lookupRate() finds best matching rate card line (weighted scoring) - Auto-fills demand line rates in estimate create/updateDraft when rates are 0 - Marks auto-filled lines with metadata.autoAppliedRateCard - New lookupDemandLineRate query for on-demand UI lookups Public Holiday Auto-Import (A12): - autoImportPublicHolidays() generates holidays by resource federal state - Cron endpoint: GET /api/cron/public-holidays?year=2027 - Duplicate-safe, uses existing getPublicHolidays() from shared Skill Marketplace MVP (G6): - New page: /analytics/skill-marketplace with 3 sections - Skill Search: filter by name, proficiency, availability, sortable results - Skill Gap Heat Map: supply vs demand per skill, shortage/surplus indicators - Skill Distribution: top-20 horizontal bar chart (reuses SkillDistributionChart) - New getSkillMarketplace query in resource router - Sidebar nav link under Analytics for ADMIN/MANAGER/CONTROLLER Co-Authored-By: claude-flow --- .../analytics/skill-marketplace/page.tsx | 5 + .../api/cron/chargeability-alerts/route.ts | 44 +++ .../src/app/api/cron/public-holidays/route.ts | 58 +++ .../components/analytics/SkillMarketplace.tsx | 346 ++++++++++++++++++ apps/web/src/components/layout/AppShell.tsx | 4 + .../api/src/__tests__/estimate-router.test.ts | 4 + packages/api/src/index.ts | 4 + packages/api/src/lib/auto-staffing.ts | 251 +++++++++++++ packages/api/src/lib/chargeability-alerts.ts | 263 +++++++++++++ packages/api/src/lib/holiday-auto-import.ts | 121 ++++++ packages/api/src/lib/rate-card-lookup.ts | 178 +++++++++ packages/api/src/lib/vacation-conflicts.ts | 232 ++++++++++++ packages/api/src/router/allocation.ts | 11 + packages/api/src/router/estimate.ts | 142 ++++++- packages/api/src/router/resource.ts | 224 ++++++++++++ packages/api/src/router/vacation.ts | 23 +- 16 files changed, 1906 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/app/(app)/analytics/skill-marketplace/page.tsx create mode 100644 apps/web/src/app/api/cron/chargeability-alerts/route.ts create mode 100644 apps/web/src/app/api/cron/public-holidays/route.ts create mode 100644 apps/web/src/components/analytics/SkillMarketplace.tsx create mode 100644 packages/api/src/lib/auto-staffing.ts create mode 100644 packages/api/src/lib/chargeability-alerts.ts create mode 100644 packages/api/src/lib/holiday-auto-import.ts create mode 100644 packages/api/src/lib/rate-card-lookup.ts create mode 100644 packages/api/src/lib/vacation-conflicts.ts diff --git a/apps/web/src/app/(app)/analytics/skill-marketplace/page.tsx b/apps/web/src/app/(app)/analytics/skill-marketplace/page.tsx new file mode 100644 index 0000000..57f5ef0 --- /dev/null +++ b/apps/web/src/app/(app)/analytics/skill-marketplace/page.tsx @@ -0,0 +1,5 @@ +import { SkillMarketplace } from "~/components/analytics/SkillMarketplace.js"; + +export default function SkillMarketplacePage() { + return ; +} diff --git a/apps/web/src/app/api/cron/chargeability-alerts/route.ts b/apps/web/src/app/api/cron/chargeability-alerts/route.ts new file mode 100644 index 0000000..d5c8ac5 --- /dev/null +++ b/apps/web/src/app/api/cron/chargeability-alerts/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@planarchy/db"; +import { checkChargeabilityAlerts } from "@planarchy/api"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** + * GET /api/cron/chargeability-alerts + * + * Finds resources whose current-month chargeability is >15 percentage points + * below their target and creates in-app notifications for managers. + * + * Duplicate-safe: only one alert per resource per month. + * + * Optionally protect with CRON_SECRET environment variable. + * When set, requests must include `Authorization: Bearer `. + */ +export async function GET(request: Request) { + const cronSecret = process.env["CRON_SECRET"]; + if (cronSecret) { + const auth = request.headers.get("authorization"); + if (auth !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } + + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const alertsSent = await checkChargeabilityAlerts(prisma as any); + + return NextResponse.json({ + ok: true, + alertsSent, + checkedAt: new Date().toISOString(), + }); + } catch (error) { + console.error("[cron/chargeability-alerts] Error:", error); + return NextResponse.json( + { ok: false, error: "Internal error" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/cron/public-holidays/route.ts b/apps/web/src/app/api/cron/public-holidays/route.ts new file mode 100644 index 0000000..1a6ffef --- /dev/null +++ b/apps/web/src/app/api/cron/public-holidays/route.ts @@ -0,0 +1,58 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@planarchy/db"; +import { autoImportPublicHolidays } from "@planarchy/api"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +/** + * GET /api/cron/public-holidays?year=2027 + * + * Auto-imports public holidays for all active resources for a given year. + * Each resource's federal state determines which state-specific holidays apply. + * Duplicate-safe: existing holidays are skipped. + * + * Query params: + * - year (optional): defaults to next year + * + * Optionally protected with CRON_SECRET environment variable. + * When set, requests must include `Authorization: Bearer `. + */ +export async function GET(request: Request) { + const cronSecret = process.env["CRON_SECRET"]; + if (cronSecret) { + const auth = request.headers.get("authorization"); + if (auth !== `Bearer ${cronSecret}`) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } + + const { searchParams } = new URL(request.url); + const yearParam = searchParams.get("year"); + const year = yearParam ? parseInt(yearParam, 10) : new Date().getFullYear() + 1; + + if (isNaN(year) || year < 2000 || year > 2100) { + return NextResponse.json( + { error: "Invalid year parameter. Must be between 2000 and 2100." }, + { status: 400 }, + ); + } + + try { + const result = await autoImportPublicHolidays(prisma, year); + + return NextResponse.json({ + ok: true, + year: result.year, + holidaysCreated: result.holidaysCreated, + resourcesProcessed: result.resourcesProcessed, + skippedExisting: result.skippedExisting, + }); + } catch (error) { + console.error("[cron/public-holidays] Error:", error); + return NextResponse.json( + { ok: false, error: "Internal error" }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/components/analytics/SkillMarketplace.tsx b/apps/web/src/components/analytics/SkillMarketplace.tsx new file mode 100644 index 0000000..cea6537 --- /dev/null +++ b/apps/web/src/components/analytics/SkillMarketplace.tsx @@ -0,0 +1,346 @@ +"use client"; + +import { useState, useMemo } from "react"; +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { trpc } from "~/lib/trpc/client.js"; +import { useDebounce } from "~/hooks/useDebounce.js"; +import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js"; +import { useTableSort } from "~/hooks/useTableSort.js"; + +const SkillDistributionChart = dynamic( + () => import("~/components/analytics/SkillDistributionChart.js"), + { ssr: false, loading: () =>
}, +); + +const PROFICIENCY_LABELS = ["", "Aware", "Basic", "Intermediate", "Advanced", "Expert"]; + +const PROFICIENCY_CLASSES = [ + "bg-gray-100 text-gray-700 border-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-500", + "bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/60 dark:text-blue-200 dark:border-blue-600", + "bg-indigo-100 text-indigo-800 border-indigo-300 dark:bg-indigo-900/60 dark:text-indigo-200 dark:border-indigo-500", + "bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/60 dark:text-amber-200 dark:border-amber-500", + "bg-green-100 text-green-800 border-green-300 dark:bg-green-900/60 dark:text-green-200 dark:border-green-500", +]; + +function proficiencyClasses(level: number): string { + const idx = Math.max(0, Math.min(4, Math.round(level) - 1)); + return PROFICIENCY_CLASSES[idx] ?? PROFICIENCY_CLASSES[0]!; +} + +function ProficiencyBadge({ value }: { value: number }) { + return ( + + {value} {PROFICIENCY_LABELS[value] ?? ""} + + ); +} + +function GapIndicator({ gap }: { gap: number }) { + if (gap > 0) { + return ( + + -{gap} shortage + + ); + } + if (gap < 0) { + return ( + + +{Math.abs(gap)} surplus + + ); + } + return ( + + balanced + + ); +} + +function formatDate(iso: string | null): string { + if (!iso) return "Not within 30d"; + const d = new Date(iso); + return d.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" }); +} + +export function SkillMarketplace() { + const [searchSkill, setSearchSkill] = useState(""); + const [minProficiency, setMinProficiency] = useState(1); + const [availableOnly, setAvailableOnly] = useState(false); + + const debouncedSearch = useDebounce(searchSkill, 300); + + const { data, isLoading, error } = trpc.resource.getSkillMarketplace.useQuery( + { + searchSkill: debouncedSearch || undefined, + minProficiency, + availableOnly, + }, + { staleTime: 30_000 }, + ); + + const { + sorted: sortedSearch, + sortField: searchSortField, + sortDir: searchSortDir, + toggle: searchToggle, + } = useTableSort(data?.searchResults ?? []); + + const gapData = useMemo(() => data?.gapData ?? [], [data?.gapData]); + const { + sorted: sortedGap, + sortField: gapSortField, + sortDir: gapSortDir, + toggle: gapToggle, + } = useTableSort(gapData); + + if (isLoading) { + return ( +
+
+
+
+
+
+ ); + } + + if (error) { + return ( +
+
+ {error.message} +
+
+ ); + } + + return ( +
+ {/* Header */} +
+

Skill Marketplace

+

+ {data?.totalResources ?? 0} active resources · Search skills, identify gaps, plan capacity +

+
+ + {/* ── Section 1: Skill Search ──────────────────────────────────────────── */} +
+

Skill Search

+ +
+ {/* Search input */} +
+ + + + setSearchSkill(e.target.value)} + className="pl-8 pr-3 py-2 border border-gray-300 dark:border-slate-600 rounded-lg text-sm bg-white dark:bg-slate-800 dark:text-gray-200 focus:outline-none focus:ring-2 focus:ring-brand-500 w-60" + /> +
+ + {/* Min proficiency */} +
+ Min. proficiency: +
+ {[1, 2, 3, 4, 5].map((lvl) => ( + + ))} +
+
+ + {/* Available only */} + +
+ + {/* Search results table */} + {debouncedSearch && debouncedSearch.trim().length > 0 && ( +
+ {sortedSearch.length === 0 ? ( +

+ No resources found with "{debouncedSearch}" at proficiency {minProficiency}+. +

+ ) : ( + <> +

+ {sortedSearch.length} resource{sortedSearch.length !== 1 ? "s" : ""} found +

+
+ + + + + + + + + + + + + {sortedSearch.map((r) => ( + + + + + + + + + ))} + +
+ + {r.displayName} + + {r.chapter ?? "---"}{r.skillName} + + + = 90 + ? "text-red-600 dark:text-red-400" + : r.utilizationPercent >= 70 + ? "text-amber-600 dark:text-amber-400" + : "text-green-600 dark:text-green-400" + }`}> + {r.utilizationPercent}% + + + {formatDate(r.availableFrom)} +
+
+ + )} +
+ )} +
+ + {/* ── Section 2: Skill Gap Heat Map ────────────────────────────────────── */} +
+
+

Skill Gap Analysis

+

+ Supply = resources with proficiency 3+ · Demand = unfilled demand requirements · Sorted by largest gap +

+
+ + {sortedGap.length === 0 ? ( +

+ No gap data available. Gaps appear when projects have unfilled demand requirements with required skills. +

+ ) : ( +
+ + + + + + + + + + + + {sortedGap.map((row) => { + const maxBar = Math.max(row.supply, row.demand, 1); + return ( + + + + + + + + ); + })} + +
Visual
+ + {row.supply}{row.demand} + + +
+
0 ? 4 : 0 }} + title={`Supply: ${row.supply}`} + /> +
0 ? 4 : 0 }} + title={`Demand: ${row.demand}`} + /> +
+
+
+
+ Supply (prof. 3+) +
+
+ Demand (unfilled) +
+
+
+ )} +
+ + {/* ── Section 3: Skill Distribution ────────────────────────────────────── */} + {(data?.distribution ?? []).length > 0 && ( +
+

+ Top 20 Skills by Resource Count +

+ +

+ Bar color = average proficiency (light to dark = low to high) +

+
+ )} +
+ ); +} diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index 01f07b4..e12d88f 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -55,6 +55,9 @@ function RolesIcon() { function SkillsIcon() { return ; } +function MarketplaceIcon() { + return ; +} function ChargeabilityIcon() { return ; } @@ -136,6 +139,7 @@ const navSections: NavSection[] = [ label: "Analytics", items: [ { href: "/analytics/skills", label: "Skills Analytics", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] }, + { href: "/analytics/skill-marketplace", label: "Skill Marketplace", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/reports/chargeability", label: "Chargeability", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, { href: "/analytics/computation-graph", label: "Computation Graph", icon: , roles: ["ADMIN", "MANAGER", "CONTROLLER"] }, ], diff --git a/packages/api/src/__tests__/estimate-router.test.ts b/packages/api/src/__tests__/estimate-router.test.ts index 6788efd..c11e41b 100644 --- a/packages/api/src/__tests__/estimate-router.test.ts +++ b/packages/api/src/__tests__/estimate-router.test.ts @@ -322,7 +322,11 @@ describe("estimate router", () => { estimate: { findUnique: vi .fn() + // 1st call: resolve effectiveProjectId (rate card auto-fill) + .mockResolvedValueOnce({ projectId: null }) + // 2nd call: application layer initial fetch .mockResolvedValueOnce(baseEstimate) + // 3rd call: application layer post-update refetch .mockResolvedValueOnce(updated), update: updateEstimate, }, diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 0baac46..acc7607 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -4,3 +4,7 @@ export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationD export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js"; export { checkBudgetThresholds } from "./lib/budget-alerts.js"; export { checkPendingEstimateReminders } from "./lib/estimate-reminders.js"; +export { checkChargeabilityAlerts } from "./lib/chargeability-alerts.js"; +export { checkVacationConflicts, checkBatchVacationConflicts } from "./lib/vacation-conflicts.js"; +export { lookupRate, type RateCardLookupParams, type RateCardLookupResult } from "./lib/rate-card-lookup.js"; +export { autoImportPublicHolidays, type AutoImportResult } from "./lib/holiday-auto-import.js"; diff --git a/packages/api/src/lib/auto-staffing.ts b/packages/api/src/lib/auto-staffing.ts new file mode 100644 index 0000000..501b46f --- /dev/null +++ b/packages/api/src/lib/auto-staffing.ts @@ -0,0 +1,251 @@ +import { listAssignmentBookings } from "@planarchy/application"; +import { rankResources } from "@planarchy/staffing"; +import type { SkillEntry } from "@planarchy/shared"; +import { emitNotificationCreated } from "../sse/event-bus.js"; + +/** + * Minimal DB interface for auto-staffing — avoids importing the full PrismaClient. + * Follows the same pattern as budget-alerts.ts. + */ +type DbClient = Parameters[0] & { + demandRequirement: { + findUnique: (args: { + where: { id: string }; + select: { + id: true; + projectId: true; + startDate: true; + endDate: true; + hoursPerDay: true; + role: true; + roleId: true; + headcount: true; + budgetCents: true; + }; + }) => Promise<{ + id: string; + projectId: string; + startDate: Date; + endDate: Date; + hoursPerDay: number; + role: string | null; + roleId: string | null; + headcount: number; + budgetCents: number; + } | null>; + }; + project: { + findUnique: (args: { + where: { id: string }; + select: { id: true; name: true }; + }) => Promise<{ id: string; name: string } | null>; + }; + role: { + findUnique: (args: { + where: { id: string }; + select: { id: true; name: true }; + }) => Promise<{ id: string; name: string } | null>; + }; + resource: { + findMany: (args: { + where: { isActive: true }; + }) => Promise>; + }; + notification: { + create: (args: { + data: { + userId: string; + type: string; + category: string; + priority: string; + title: string; + body: string; + entityId: string; + entityType: string; + link: string; + channel: string; + }; + }) => Promise<{ id: string; userId: string }>; + }; + user: { + findMany: (args: { + where: { systemRole: { in: string[] } }; + select: { id: true }; + }) => Promise>; + }; +}; + +const TOP_N = 3; + +/** + * Generate automatic staffing suggestions for a demand requirement. + * + * Fetches the demand's role/dates/hours, runs the staffing ranking algorithm + * for the top 3 matches, and creates a notification for project managers + * with a summary of the suggestions. + * + * This function is designed to be called fire-and-forget (non-blocking). + * It swallows all errors to avoid disrupting the caller. + */ +export async function generateAutoSuggestions( + db: DbClient, + demandRequirementId: string, +): Promise { + try { + // 1. Load the demand requirement + const demand = await db.demandRequirement.findUnique({ + where: { id: demandRequirementId }, + select: { + id: true, + projectId: true, + startDate: true, + endDate: true, + hoursPerDay: true, + role: true, + roleId: true, + headcount: true, + budgetCents: true, + }, + }); + + if (!demand) return; + + // 2. Resolve project and role names + const [project, roleEntity] = await Promise.all([ + db.project.findUnique({ + where: { id: demand.projectId }, + select: { id: true, name: true }, + }), + demand.roleId + ? db.role.findUnique({ + where: { id: demand.roleId }, + select: { id: true, name: true }, + }) + : Promise.resolve(null), + ]); + + if (!project) return; + + const roleName = roleEntity?.name ?? demand.role ?? "Unspecified role"; + + // 3. Derive required skills from role name + // The role name itself is treated as the primary required skill. + // Resources with matching skill names in their skill matrix will rank highest. + const requiredSkills = [roleName]; + + // 4. Fetch all active resources and their current bookings + const resources = await db.resource.findMany({ + where: { isActive: true }, + }); + + if (resources.length === 0) return; + + const bookings = await listAssignmentBookings(db, { + startDate: demand.startDate, + endDate: demand.endDate, + resourceIds: resources.map((r) => r.id), + }); + + // 5. Enrich resources with utilization data for the demand's date range + const enrichedResources = resources.map((resource) => { + const avail = resource.availability as + | { monday?: number; tuesday?: number; wednesday?: number; thursday?: number; friday?: number } + | null; + const totalAvailableHours = avail?.monday ?? 8; + const resourceBookings = bookings.filter((b) => b.resourceId === resource.id); + + const allocatedHoursPerDay = resourceBookings.reduce( + (sum, b) => sum + b.hoursPerDay, + 0, + ); + + const utilizationPercent = + totalAvailableHours > 0 + ? Math.min(100, (allocatedHoursPerDay / totalAvailableHours) * 100) + : 0; + + const wouldExceedCapacity = + allocatedHoursPerDay + demand.hoursPerDay > totalAvailableHours; + + return { + id: resource.id, + displayName: resource.displayName, + eid: resource.eid, + skills: resource.skills as unknown as SkillEntry[], + lcrCents: resource.lcrCents, + chargeabilityTarget: resource.chargeabilityTarget, + currentUtilizationPercent: utilizationPercent, + hasAvailabilityConflicts: wouldExceedCapacity, + conflictDays: wouldExceedCapacity ? ["(multiple days)"] : [], + valueScore: resource.valueScore ?? 0, + }; + }); + + // 6. Rank resources using the staffing algorithm + const budgetLcrCentsPerHour = + demand.budgetCents > 0 ? demand.budgetCents : undefined; + + const ranked = rankResources({ + requiredSkills, + resources: enrichedResources, + budgetLcrCentsPerHour, + } as unknown as Parameters[0]); + + // Value-score tiebreaker (same logic as staffing router) + ranked.sort((a, b) => { + if (Math.abs(a.score - b.score) <= 2) { + const aVal = enrichedResources.find((r) => r.id === a.resourceId)?.valueScore ?? 0; + const bVal = enrichedResources.find((r) => r.id === b.resourceId)?.valueScore ?? 0; + return bVal - aVal; + } + return 0; + }); + + const topSuggestions = ranked.slice(0, TOP_N); + if (topSuggestions.length === 0) return; + + // 7. Build notification message + const suggestionSummary = topSuggestions + .map((s) => `${s.resourceName} (${s.score}%)`) + .join(", "); + + const title = `Staffing suggestions for ${roleName} on ${project.name}`; + const body = `${topSuggestions.length} matching resources found for ${roleName} on ${project.name}: ${suggestionSummary}`; + + // 8. Notify all managers and admins + const managers = await db.user.findMany({ + where: { systemRole: { in: ["ADMIN", "MANAGER"] } }, + select: { id: true }, + }); + + for (const manager of managers) { + const notification = await db.notification.create({ + data: { + userId: manager.id, + type: "AUTO_STAFFING_SUGGESTION", + category: "NOTIFICATION", + priority: "NORMAL", + title, + body, + entityId: demandRequirementId, + entityType: "demand", + link: `/staffing?demandId=${demandRequirementId}`, + channel: "in_app", + }, + }); + + emitNotificationCreated(manager.id, notification.id); + } + } catch { + // Fire-and-forget: swallow all errors to avoid disrupting the caller. + } +} diff --git a/packages/api/src/lib/chargeability-alerts.ts b/packages/api/src/lib/chargeability-alerts.ts new file mode 100644 index 0000000..5ebef5c --- /dev/null +++ b/packages/api/src/lib/chargeability-alerts.ts @@ -0,0 +1,263 @@ +import { + deriveResourceForecast, + getMonthRange, + countWorkingDaysInOverlap, + calculateSAH, + type AssignmentSlice, +} from "@planarchy/engine"; +import type { SpainScheduleRule } from "@planarchy/shared"; +import { isChargeabilityActualBooking, listAssignmentBookings } from "@planarchy/application"; +import { VacationStatus } from "@planarchy/db"; +import { emitNotificationCreated } from "../sse/event-bus.js"; + +/** + * Minimal DB client type for chargeability alerts. + * Uses structural typing so we can pass in `prisma as any` from the cron route. + */ +type DbClient = { + resource: { + findMany: (args: { + where: Record; + select: Record; + }) => Promise< + Array<{ + id: string; + displayName: string; + fte: number; + chargeabilityTarget: number; + country: { dailyWorkingHours: number | null; scheduleRules: unknown } | null; + managementLevelGroup: { targetPercentage: number | null } | null; + }> + >; + }; + vacation: { + findMany: (args: { + where: Record; + select: Record; + }) => Promise< + Array<{ + resourceId: string; + startDate: Date; + endDate: Date; + type: string; + isHalfDay: boolean; + }> + >; + }; + notification: { + findFirst: (args: { + where: Record; + select: { id: true }; + }) => Promise<{ id: string } | null>; + create: (args: { + data: { + userId: string; + type: string; + category: string; + priority: string; + title: string; + body: string; + entityId: string; + entityType: string; + link: string; + channel: string; + }; + }) => Promise<{ id: string; userId: string }>; + }; + user: { + findMany: (args: { + where: { systemRole: { in: string[] } }; + select: { id: true }; + }) => Promise>; + }; +}; + +/** Alert when chargeability is more than 15pp below target */ +const GAP_THRESHOLD_PP = 15; + +/** + * Find resources whose current-month chargeability is >15 percentage points + * below their target, and create a notification for all managers. + * + * Duplicate-safe: skips resources that already have an alert this month. + * + * Returns the number of new alerts created. + */ +export async function checkChargeabilityAlerts( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: any, +): Promise { + const now = new Date(); + const year = now.getUTCFullYear(); + const month = now.getUTCMonth() + 1; + const { start: monthStart, end: monthEnd } = getMonthRange(year, month); + const monthKey = `${year}-${String(month).padStart(2, "0")}`; + + // Fetch active, chg-responsible resources + const resources = await (db as DbClient).resource.findMany({ + where: { + isActive: true, + chgResponsibility: true, + departed: false, + rolledOff: false, + }, + select: { + id: true, + displayName: true, + fte: true, + chargeabilityTarget: true, + country: { select: { dailyWorkingHours: true, scheduleRules: true } }, + managementLevelGroup: { select: { targetPercentage: true } }, + }, + }); + + if (resources.length === 0) return 0; + + const resourceIds = resources.map((r) => r.id); + + // Fetch bookings for the current month + const allBookings = await listAssignmentBookings(db, { + startDate: monthStart, + endDate: monthEnd, + resourceIds, + }); + + // Fetch vacations for the current month + const vacations = await (db as DbClient).vacation.findMany({ + where: { + resourceId: { in: resourceIds }, + status: VacationStatus.APPROVED, + startDate: { lte: monthEnd }, + endDate: { gte: monthStart }, + }, + select: { + resourceId: true, + startDate: true, + endDate: true, + type: true, + isHalfDay: true, + }, + }); + + // Compute chargeability per resource + const underperformers: Array<{ resource: typeof resources[0]; chg: number; target: number; gap: number }> = []; + + for (const resource of resources) { + const dailyHours = resource.country?.dailyWorkingHours ?? 8; + + // Compute absence dates for SAH + const resourceVacations = vacations.filter((v) => v.resourceId === resource.id); + const absenceDates: string[] = []; + for (const v of resourceVacations) { + 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); + while (cursor <= endNorm) { + absenceDates.push(cursor.toISOString().slice(0, 10)); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + } + + const scheduleRules = (resource.country?.scheduleRules ?? null) as SpainScheduleRule | null; + const sahResult = calculateSAH({ + dailyWorkingHours: dailyHours, + scheduleRules, + fte: resource.fte, + periodStart: monthStart, + periodEnd: monthEnd, + publicHolidays: [], + absenceDays: absenceDates, + }); + + // Build assignment slices + const resourceBookings = allBookings.filter( + (b) => b.resourceId === resource.id && isChargeabilityActualBooking(b, false), + ); + + const slices: AssignmentSlice[] = resourceBookings.map((b) => { + const workingDays = countWorkingDaysInOverlap(monthStart, monthEnd, b.startDate, b.endDate); + return { + hoursPerDay: b.hoursPerDay, + workingDays, + categoryCode: "Chg", // simplified — treat all actual bookings as chargeable + }; + }); + + const targetPct = resource.managementLevelGroup?.targetPercentage + ?? (resource.chargeabilityTarget / 100); + + const forecast = deriveResourceForecast({ + fte: resource.fte, + targetPercentage: targetPct, + assignments: slices, + sah: sahResult.standardAvailableHours, + }); + + const chgPct = forecast.chg * 100; + const targetPctVal = targetPct * 100; + const gap = targetPctVal - chgPct; + + if (gap > GAP_THRESHOLD_PP) { + underperformers.push({ + resource, + chg: Math.round(chgPct), + target: Math.round(targetPctVal), + gap: Math.round(gap), + }); + } + } + + if (underperformers.length === 0) return 0; + + // Fetch managers to notify + const managers = await (db as DbClient).user.findMany({ + where: { systemRole: { in: ["ADMIN", "MANAGER"] } }, + select: { id: true }, + }); + + if (managers.length === 0) return 0; + + let alertCount = 0; + + for (const { resource, chg, target, gap } of underperformers) { + // Duplicate check: one alert per resource per month + const entityId = `chg-alert-${resource.id}-${monthKey}`; + const existing = await (db as DbClient).notification.findFirst({ + where: { + entityId, + entityType: "chargeability_alert", + type: "CHARGEABILITY_ALERT", + }, + select: { id: true }, + }); + + if (existing) continue; + + for (const manager of managers) { + const notification = await (db as DbClient).notification.create({ + data: { + userId: manager.id, + type: "CHARGEABILITY_ALERT", + category: "NOTIFICATION", + priority: "HIGH", + title: `Low chargeability: ${resource.displayName}`, + body: `${resource.displayName} is at ${chg}% chargeability this month (target: ${target}%, gap: ${gap}pp).`, + entityId, + entityType: "chargeability_alert", + link: "/chargeability", + channel: "in_app", + }, + }); + + emitNotificationCreated(manager.id, notification.id); + } + + alertCount++; + } + + return alertCount; +} diff --git a/packages/api/src/lib/holiday-auto-import.ts b/packages/api/src/lib/holiday-auto-import.ts new file mode 100644 index 0000000..9114b83 --- /dev/null +++ b/packages/api/src/lib/holiday-auto-import.ts @@ -0,0 +1,121 @@ +/** + * Auto-import public holidays for all active resources. + * + * For each resource, determines the applicable federal state from: + * 1. resource.federalState (explicit, e.g. "BY") + * 2. Falls back to federal-only holidays when no state is set + * + * Creates Vacation entries with type PUBLIC_HOLIDAY and status APPROVED. + * Duplicate-safe: skips holidays that already exist (by date + type + resourceId). + */ + +import { getPublicHolidays } from "@planarchy/shared"; + +interface MinimalVacation { + resourceId: string; + startDate: Date; + endDate: Date; +} + +interface AutoImportDb { + resource: { + findMany: (args: { + where: { isActive: boolean }; + select: { id: string; federalState: string }; + }) => Promise>; + }; + vacation: { + findMany: (args: unknown) => Promise; + createMany: (args: { data: unknown[]; skipDuplicates?: boolean }) => Promise<{ count: number }>; + }; +} + +export interface AutoImportResult { + year: number; + holidaysCreated: number; + resourcesProcessed: number; + skippedExisting: number; +} + +/** + * Import public holidays for all active resources in a given year. + * Returns the number of holiday vacation records created. + */ +export async function autoImportPublicHolidays( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: any, + year: number, +): Promise { + const resources: Array<{ id: string; federalState: string | null }> = await db.resource.findMany({ + where: { isActive: true }, + select: { id: true, federalState: true }, + }); + + if (resources.length === 0) { + return { year, holidaysCreated: 0, resourcesProcessed: 0, skippedExisting: 0 }; + } + + // Group resources by federal state (null = federal-only holidays) + const byState = new Map(); + for (const resource of resources) { + const state = resource.federalState ?? null; + const group = byState.get(state) ?? []; + group.push(resource.id); + byState.set(state, group); + } + + let totalCreated = 0; + let totalSkipped = 0; + + for (const [state, resourceIds] of byState) { + const holidays = getPublicHolidays(year, state ?? undefined); + if (holidays.length === 0) continue; + + for (const holiday of holidays) { + const holidayDate = new Date(holiday.date); + + // Find existing records for this date + type to skip duplicates + const existing: MinimalVacation[] = await db.vacation.findMany({ + where: { + resourceId: { in: resourceIds }, + type: "PUBLIC_HOLIDAY", + startDate: holidayDate, + endDate: holidayDate, + }, + select: { resourceId: true, startDate: true, endDate: true }, + }); + + const existingResourceIds = new Set(existing.map((v: MinimalVacation) => v.resourceId)); + const newResourceIds = resourceIds.filter((id) => !existingResourceIds.has(id)); + + totalSkipped += existingResourceIds.size; + + if (newResourceIds.length === 0) continue; + + const records = newResourceIds.map((resourceId) => ({ + resourceId, + type: "PUBLIC_HOLIDAY", + status: "APPROVED", + startDate: holidayDate, + endDate: holidayDate, + note: holiday.name, + isHalfDay: false, + approvedAt: new Date(), + })); + + const result = await db.vacation.createMany({ + data: records, + skipDuplicates: true, + }); + + totalCreated += result.count; + } + } + + return { + year, + holidaysCreated: totalCreated, + resourcesProcessed: resources.length, + skippedExisting: totalSkipped, + }; +} diff --git a/packages/api/src/lib/rate-card-lookup.ts b/packages/api/src/lib/rate-card-lookup.ts new file mode 100644 index 0000000..a3f568a --- /dev/null +++ b/packages/api/src/lib/rate-card-lookup.ts @@ -0,0 +1,178 @@ +/** + * Rate card lookup logic for auto-filling demand line rates. + * + * Match priority (highest specificity wins): + * 1. Exact client + chapter + role + * 2. Client + chapter (any role) + * 3. Client + role (any chapter) + * 4. Client only (fallback) + * 5. Default rate card (no client) + best match + * + * Within each priority tier, additional criteria (seniority, location, + * workType) increase the score. + */ + +export interface RateCardLookupParams { + clientId?: string | null; + chapter?: string | null; + roleId?: string | null; + seniority?: string | null; + location?: string | null; + workType?: string | null; + effectiveDate?: Date | null; +} + +export interface RateCardLookupResult { + costRateCents: number; + billRateCents: number; + currency: string; + rateCardId: string; + rateCardLineId: string; + rateCardName: string; +} + +interface RateCardLineRow { + id: string; + rateCardId: string; + roleId: string | null; + chapter: string | null; + location: string | null; + seniority: string | null; + workType: string | null; + costRateCents: number; + billRateCents: number | null; + rateCard: { + id: string; + name: string; + currency: string; + clientId: string | null; + }; +} + +/** + * Look up the best-matching rate card line for a given set of criteria. + * Returns null when no active rate card line matches. + */ +export async function lookupRate( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: any, + params: RateCardLookupParams, +): Promise { + const effectiveDate = params.effectiveDate ?? new Date(); + + // Build rate card filter: active cards, within effective date range + const rateCardWhere: Record = { + isActive: true, + OR: [ + { effectiveFrom: null }, + { effectiveFrom: { lte: effectiveDate } }, + ], + AND: [ + { + OR: [ + { effectiveTo: null }, + { effectiveTo: { gte: effectiveDate } }, + ], + }, + ], + }; + + // If we have a clientId, look for both client-specific and default (null client) cards + if (params.clientId) { + rateCardWhere.clientId = { in: [params.clientId, null] }; + } + // If no clientId, only look at default (null client) cards + // (don't pass clientId filter at all to keep the OR above valid) + + const lines = (await db.rateCardLine.findMany({ + where: { + rateCard: rateCardWhere, + }, + select: { + id: true, + rateCardId: true, + roleId: true, + chapter: true, + location: true, + seniority: true, + workType: true, + costRateCents: true, + billRateCents: true, + rateCard: { + select: { + id: true, + name: true, + currency: true, + clientId: true, + }, + }, + }, + })) as RateCardLineRow[]; + + if (lines.length === 0) return null; + + // Score each line. Higher = better match. + type ScoredLine = { line: RateCardLineRow; score: number; mismatch: boolean }; + const scored: ScoredLine[] = lines.map((line) => { + let score = 0; + let mismatch = false; + + // Client specificity: client-specific cards get a large bonus + if (params.clientId && line.rateCard.clientId === params.clientId) { + score += 100; + } else if (params.clientId && line.rateCard.clientId != null) { + // Different client entirely => disqualify + mismatch = true; + } + // Default card (null client) gets no bonus but is a valid fallback + + // Role match + if (params.roleId && line.roleId) { + if (line.roleId === params.roleId) score += 16; + else mismatch = true; + } + + // Chapter match + if (params.chapter && line.chapter) { + if (line.chapter === params.chapter) score += 8; + else mismatch = true; + } + + // Seniority match + if (params.seniority && line.seniority) { + if (line.seniority === params.seniority) score += 4; + else mismatch = true; + } + + // Location match + if (params.location && line.location) { + if (line.location === params.location) score += 2; + else mismatch = true; + } + + // Work type match + if (params.workType && line.workType) { + if (line.workType === params.workType) score += 1; + else mismatch = true; + } + + return { line, score, mismatch }; + }); + + // Filter out mismatched lines and sort by score descending + const candidates = scored + .filter((s) => !s.mismatch) + .sort((a, b) => b.score - a.score); + + const best = candidates[0]; + if (!best) return null; + + return { + costRateCents: best.line.costRateCents, + billRateCents: best.line.billRateCents ?? 0, + currency: best.line.rateCard.currency, + rateCardId: best.line.rateCard.id, + rateCardLineId: best.line.id, + rateCardName: best.line.rateCard.name, + }; +} diff --git a/packages/api/src/lib/vacation-conflicts.ts b/packages/api/src/lib/vacation-conflicts.ts new file mode 100644 index 0000000..44e87b8 --- /dev/null +++ b/packages/api/src/lib/vacation-conflicts.ts @@ -0,0 +1,232 @@ +import { VacationStatus } from "@planarchy/db"; +import { emitNotificationCreated } from "../sse/event-bus.js"; + +type DbClient = { + vacation: { + findUnique: (args: { + where: { id: string }; + select: { + id: true; + resourceId: true; + startDate: true; + endDate: true; + resource: { select: { chapter: true; displayName: true } }; + }; + }) => Promise<{ + id: string; + resourceId: string; + startDate: Date; + endDate: Date; + resource: { chapter: string | null; displayName: string } | null; + } | null>; + findMany: (args: { + where: { + resource: { chapter: string }; + resourceId: { not: string }; + status: { in: string[] }; + startDate: { lte: Date }; + endDate: { gte: Date }; + }; + select: { + id: true; + resourceId: true; + startDate: true; + endDate: true; + resource: { select: { displayName: true } }; + }; + }) => Promise< + Array<{ + id: string; + resourceId: string; + startDate: Date; + endDate: Date; + resource: { displayName: string } | null; + }> + >; + }; + resource: { + count: (args: { + where: { chapter: string; isActive: true }; + }) => Promise; + }; + notification: { + create: (args: { + data: { + userId: string; + type: string; + category: string; + priority: string; + title: string; + body: string; + entityId: string; + entityType: string; + link: string; + channel: string; + }; + }) => Promise<{ id: string; userId: string }>; + }; +}; + +/** Threshold: warn when more than 50% of a chapter is absent on any single day */ +const OVERLAP_THRESHOLD = 0.5; + +export interface VacationConflictResult { + warnings: string[]; +} + +/** + * Check if approving a vacation would cause >50% of a chapter to be absent + * on any single day within the vacation period. + * + * Returns a list of warning strings (empty if no conflicts). + * Does NOT block the approval — warnings are advisory only. + */ +export async function checkVacationConflicts( + db: DbClient, + vacationId: string, + approverUserId?: string, +): Promise { + const warnings: string[] = []; + + const vacation = await db.vacation.findUnique({ + where: { id: vacationId }, + select: { + id: true, + resourceId: true, + startDate: true, + endDate: true, + resource: { select: { chapter: true, displayName: true } }, + }, + }); + + if (!vacation?.resource?.chapter) { + return { warnings }; + } + + const chapter = vacation.resource.chapter; + + // Count active resources in the same chapter + const totalInChapter = await db.resource.count({ + where: { chapter, isActive: true }, + }); + + if (totalInChapter <= 1) { + return { warnings }; + } + + // Find overlapping approved/pending vacations from other resources in the same chapter + const overlapping = await db.vacation.findMany({ + where: { + resource: { chapter }, + resourceId: { not: vacation.resourceId }, + status: { in: [VacationStatus.APPROVED, VacationStatus.PENDING] }, + startDate: { lte: vacation.endDate }, + endDate: { gte: vacation.startDate }, + }, + select: { + id: true, + resourceId: true, + startDate: true, + endDate: true, + resource: { select: { displayName: true } }, + }, + }); + + if (overlapping.length === 0) { + return { warnings }; + } + + // Check each day of the vacation to find the worst overlap + const start = new Date(vacation.startDate); + start.setUTCHours(0, 0, 0, 0); + const end = new Date(vacation.endDate); + end.setUTCHours(0, 0, 0, 0); + + let worstDay: string | null = null; + let worstCount = 0; + + const cursor = new Date(start); + while (cursor <= end) { + // Skip weekends + const dow = cursor.getUTCDay(); + if (dow === 0 || dow === 6) { + cursor.setUTCDate(cursor.getUTCDate() + 1); + continue; + } + + // Count unique resources absent on this day (excluding the current resource) + const absentResourceIds = new Set(); + for (const ov of overlapping) { + const ovStart = new Date(ov.startDate); + ovStart.setUTCHours(0, 0, 0, 0); + const ovEnd = new Date(ov.endDate); + ovEnd.setUTCHours(0, 0, 0, 0); + if (cursor >= ovStart && cursor <= ovEnd) { + absentResourceIds.add(ov.resourceId); + } + } + + // +1 because the resource being approved would also be absent + const totalAbsent = absentResourceIds.size + 1; + if (totalAbsent > worstCount) { + worstCount = totalAbsent; + worstDay = cursor.toISOString().slice(0, 10); + } + + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + + if (worstCount > 0 && worstCount / totalInChapter > OVERLAP_THRESHOLD) { + const pct = Math.round((worstCount / totalInChapter) * 100); + const absentNames = overlapping + .map((ov) => ov.resource?.displayName ?? "Unknown") + .slice(0, 5); + const nameList = absentNames.join(", "); + const suffix = overlapping.length > 5 ? ` and ${overlapping.length - 5} more` : ""; + + const warning = `High absence in chapter "${chapter}" on ${worstDay}: ${worstCount}/${totalInChapter} resources (${pct}%) would be absent. Also off: ${nameList}${suffix}`; + warnings.push(warning); + + // Create a notification for the approver if provided + if (approverUserId) { + const notification = await db.notification.create({ + data: { + userId: approverUserId, + type: "VACATION_CONFLICT_WARNING", + category: "NOTIFICATION", + priority: "HIGH", + title: `Vacation conflict warning: ${vacation.resource.displayName}`, + body: warning, + entityId: vacationId, + entityType: "vacation", + link: "/vacations", + channel: "in_app", + }, + }); + emitNotificationCreated(approverUserId, notification.id); + } + } + + return { warnings }; +} + +/** + * Check conflicts for multiple vacations at once (used by batchApprove). + * Returns a map of vacationId -> warnings. + */ +export async function checkBatchVacationConflicts( + db: DbClient, + vacationIds: string[], + approverUserId?: string, +): Promise> { + const results = new Map(); + + for (const id of vacationIds) { + const result = await checkVacationConflicts(db, id, approverUserId); + if (result.warnings.length > 0) { + results.set(id, result.warnings); + } + } + + return results; +} diff --git a/packages/api/src/router/allocation.ts b/packages/api/src/router/allocation.ts index f35fe6e..81c3d35 100644 --- a/packages/api/src/router/allocation.ts +++ b/packages/api/src/router/allocation.ts @@ -31,6 +31,7 @@ import { findUniqueOrThrow } from "../db/helpers.js"; import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js"; import { checkBudgetThresholds } from "../lib/budget-alerts.js"; import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated, emitNotificationCreated } from "../sse/event-bus.js"; +import { generateAutoSuggestions } from "../lib/auto-staffing.js"; import { invalidateDashboardCache } from "../lib/cache.js"; import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js"; import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js"; @@ -495,6 +496,9 @@ export const allocationRouter = createTRPCRouter({ // eslint-disable-next-line @typescript-eslint/no-explicit-any void checkBudgetThresholds(ctx.db as any, demandRequirement.projectId); + // Fire-and-forget: compute and notify top-3 staffing suggestions + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void generateAutoSuggestions(ctx.db as any, demandRequirement.id); return demandRequirement; }), @@ -631,6 +635,13 @@ export const allocationRouter = createTRPCRouter({ // eslint-disable-next-line @typescript-eslint/no-explicit-any void checkBudgetThresholds(ctx.db as any, result.assignment.projectId); + // If there are still unfilled slots, refresh suggestions for remaining demand + if (result.updatedDemandRequirement.headcount > 0 + && result.updatedDemandRequirement.status !== "COMPLETED") { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void generateAutoSuggestions(ctx.db as any, result.updatedDemandRequirement.id); + } + return result; }), diff --git a/packages/api/src/router/estimate.ts b/packages/api/src/router/estimate.ts index 349285e..284e16a 100644 --- a/packages/api/src/router/estimate.ts +++ b/packages/api/src/router/estimate.ts @@ -37,6 +37,7 @@ import { import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; +import { lookupRate } from "../lib/rate-card-lookup.js"; import { controllerProcedure, createTRPCRouter, @@ -142,6 +143,75 @@ function withComputedMetrics< }; } +/** + * Auto-fill rate card rates into demand lines that have default (zero) rates. + * A line is eligible for auto-fill when both costRateCents and billRateCents + * are 0 (the Zod default) and rateSource is not explicitly set. + * + * Returns the enriched demand lines and a list of line indices that were auto-filled. + */ +async function autoFillDemandLineRates( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + db: any, + demandLines: z.infer["demandLines"], + projectId?: string | null, +): Promise<{ + demandLines: z.infer["demandLines"]; + autoFilledIndices: number[]; +}> { + // Resolve clientId from the linked project + let clientId: string | null = null; + if (projectId) { + const project = await db.project.findUnique({ + where: { id: projectId }, + select: { clientId: true }, + }); + clientId = project?.clientId ?? null; + } + + const autoFilledIndices: number[] = []; + + const enriched = await Promise.all( + demandLines.map(async (line, index) => { + // Only auto-fill if both rates are at default (0) and no explicit rateSource + const isDefaultRate = line.costRateCents === 0 && line.billRateCents === 0; + const hasExplicitSource = line.rateSource != null && line.rateSource.length > 0; + + if (!isDefaultRate || hasExplicitSource) return line; + + const result = await lookupRate(db, { + clientId, + chapter: line.chapter ?? null, + roleId: line.roleId ?? null, + }); + + if (!result) return line; + + autoFilledIndices.push(index); + + const existingMetadata = (line.metadata ?? {}) as Record; + return { + ...line, + costRateCents: result.costRateCents, + billRateCents: result.billRateCents, + currency: result.currency, + rateSource: `rate-card:${result.rateCardId}`, + metadata: { + ...existingMetadata, + autoAppliedRateCard: { + rateCardId: result.rateCardId, + rateCardLineId: result.rateCardLineId, + rateCardName: result.rateCardName, + appliedAt: new Date().toISOString(), + }, + }, + }; + }), + ); + + return { demandLines: enriched, autoFilledIndices }; +} + export const estimateRouter = createTRPCRouter({ list: protectedProcedure .input(EstimateListFiltersSchema.default({})) @@ -180,9 +250,14 @@ export const estimateRouter = createTRPCRouter({ ); } + // Auto-fill rates from rate cards for demand lines with default (zero) rates + const { demandLines: enrichedLines, autoFilledIndices } = + await autoFillDemandLineRates(ctx.db, input.demandLines, input.projectId); + const enrichedInput = { ...input, demandLines: enrichedLines }; + const estimate = await createEstimate( ctx.db as unknown as Parameters[0], - withComputedMetrics(input, input.baseCurrency), + withComputedMetrics(enrichedInput, input.baseCurrency), ); await ctx.db.auditLog.create({ @@ -198,6 +273,7 @@ export const estimateRouter = createTRPCRouter({ status: estimate.status, projectId: estimate.projectId, latestVersionNumber: estimate.latestVersionNumber, + autoFilledRateCardLines: autoFilledIndices.length, }, } as Prisma.InputJsonValue, }, @@ -263,11 +339,25 @@ export const estimateRouter = createTRPCRouter({ ); } + // Auto-fill rates from rate cards for demand lines with default (zero) rates + // Resolve projectId: explicit input or existing estimate's projectId + let effectiveProjectId = input.projectId; + if (!effectiveProjectId) { + const existing = await ctx.db.estimate.findUnique({ + where: { id: input.id }, + select: { projectId: true }, + }); + effectiveProjectId = existing?.projectId ?? undefined; + } + const { demandLines: enrichedLines, autoFilledIndices } = + await autoFillDemandLineRates(ctx.db, input.demandLines, effectiveProjectId); + const enrichedInput = { ...input, demandLines: enrichedLines }; + let estimate; try { estimate = await updateEstimateDraft( ctx.db as unknown as Parameters[0], - withComputedMetrics(input, input.baseCurrency ?? "EUR"), + withComputedMetrics(enrichedInput, input.baseCurrency ?? "EUR"), ); } catch (error) { if (error instanceof Error && error.message === "Estimate not found") { @@ -300,6 +390,7 @@ export const estimateRouter = createTRPCRouter({ workingVersionId: estimate.versions.find( (version) => version.status === "WORKING", )?.id, + autoFilledRateCardLines: autoFilledIndices.length, }, } as Prisma.InputJsonValue, }, @@ -837,4 +928,51 @@ export const estimateRouter = createTRPCRouter({ return { versionId: version.id, terms: validated }; }), + + // ─── Rate Card Lookup for Demand Lines ────────────────────────────────── + + lookupDemandLineRate: controllerProcedure + .input(z.object({ + projectId: z.string().optional(), + clientId: z.string().optional(), + roleId: z.string().optional(), + chapter: z.string().optional(), + seniority: z.string().optional(), + location: z.string().optional(), + workType: z.string().optional(), + effectiveDate: z.coerce.date().optional(), + })) + .query(async ({ ctx, input }) => { + // Resolve clientId from project if not provided directly + let clientId = input.clientId ?? null; + if (!clientId && input.projectId) { + const project = await ctx.db.project.findUnique({ + where: { id: input.projectId }, + select: { clientId: true }, + }); + clientId = project?.clientId ?? null; + } + + const result = await lookupRate(ctx.db, { + clientId, + chapter: input.chapter ?? null, + roleId: input.roleId ?? null, + seniority: input.seniority ?? null, + location: input.location ?? null, + workType: input.workType ?? null, + effectiveDate: input.effectiveDate ?? null, + }); + + if (!result) return { found: false as const }; + + return { + found: true as const, + costRateCents: result.costRateCents, + billRateCents: result.billRateCents, + currency: result.currency, + rateCardId: result.rateCardId, + rateCardLineId: result.rateCardLineId, + rateCardName: result.rateCardName, + }; + }), }); diff --git a/packages/api/src/router/resource.ts b/packages/api/src/router/resource.ts index 4a6d6f6..d2d14a7 100644 --- a/packages/api/src/router/resource.ts +++ b/packages/api/src/router/resource.ts @@ -1191,4 +1191,228 @@ export const resourceRouter = createTRPCRouter({ return { updated: input.ids.length }; }), + + // ─── Skill Marketplace ──────────────────────────────────────────────────── + + getSkillMarketplace: controllerProcedure + .input( + z.object({ + // Section 1: Skill search + searchSkill: z.string().optional(), + minProficiency: z.number().int().min(1).max(5).optional().default(1), + availableOnly: z.boolean().optional().default(false), + }), + ) + .query(async ({ ctx, input }) => { + const now = new Date(); + const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); + + type SkillRow = { skill: string; category?: string; proficiency: number; isMainSkill?: boolean }; + + // ── Fetch all active resources with skills ── + const resources = await ctx.db.resource.findMany({ + where: { isActive: true }, + select: { + id: true, + displayName: true, + eid: true, + chapter: true, + skills: true, + availability: true, + chargeabilityTarget: true, + }, + }); + + // ── Fetch current assignments for utilization calc ── + const allResourceIds = resources.map((r) => r.id); + const assignments = await ctx.db.assignment.findMany({ + where: { + resourceId: { in: allResourceIds }, + status: { in: ["PROPOSED", "CONFIRMED", "ACTIVE", "COMPLETED"] }, + endDate: { gte: now }, + startDate: { lte: thirtyDaysFromNow }, + }, + select: { + resourceId: true, + startDate: true, + endDate: true, + hoursPerDay: true, + }, + }); + + // Build utilization map (simple: booked hours per day / available hours per day) + const utilizationMap = new Map(); + for (const r of resources) { + const avail = r.availability as Record; + const dailyAvailHours = Object.values(avail).reduce((s, h) => s + (h ?? 0), 0) / 5; + const resourceAssignments = assignments.filter((a) => a.resourceId === r.id); + + // Current daily booked hours (assignments overlapping today) + let todayBooked = 0; + for (const a of resourceAssignments) { + if (a.startDate <= now && a.endDate >= now) { + todayBooked += a.hoursPerDay; + } + } + const utilizationPercent = dailyAvailHours > 0 ? Math.round((todayBooked / dailyAvailHours) * 100) : 0; + + // Find earliest date when resource has capacity (within 30 days) + let earliestAvailableDate: Date | null = null; + const checkDate = new Date(now); + for (let i = 0; i < 30; i++) { + const day = checkDate.getDay(); + if (day !== 0 && day !== 6) { + let dayBooked = 0; + for (const a of resourceAssignments) { + if (a.startDate <= checkDate && a.endDate >= checkDate) { + dayBooked += a.hoursPerDay; + } + } + if (dayBooked < dailyAvailHours * 0.8) { + earliestAvailableDate = new Date(checkDate); + break; + } + } + checkDate.setDate(checkDate.getDate() + 1); + } + + utilizationMap.set(r.id, { utilizationPercent, earliestAvailableDate }); + } + + // ── Section 1: Skill Search ── + let searchResults: Array<{ + id: string; + displayName: string; + chapter: string | null; + skillProficiency: number; + skillName: string; + utilizationPercent: number; + availableFrom: string | null; + }> = []; + + if (input.searchSkill && input.searchSkill.trim().length > 0) { + const needle = input.searchSkill.toLowerCase(); + for (const r of resources) { + const skills = (r.skills as unknown as SkillRow[]) ?? []; + const match = skills.find( + (s) => s.skill.toLowerCase().includes(needle) && s.proficiency >= input.minProficiency, + ); + if (!match) continue; + + const util = utilizationMap.get(r.id); + if (input.availableOnly && !util?.earliestAvailableDate) continue; + + searchResults.push({ + id: r.id, + displayName: r.displayName, + chapter: r.chapter, + skillProficiency: match.proficiency, + skillName: match.skill, + utilizationPercent: util?.utilizationPercent ?? 0, + availableFrom: util?.earliestAvailableDate?.toISOString() ?? null, + }); + } + searchResults.sort((a, b) => b.skillProficiency - a.skillProficiency || a.utilizationPercent - b.utilizationPercent); + } + + // ── Section 2: Skill Gap Heat Map ── + // Demand: from unfilled DemandRequirements + project staffingReqs skills + const unfilled = await ctx.db.demandRequirement.findMany({ + where: { + endDate: { gte: now }, + assignments: { none: {} }, + }, + select: { + id: true, + role: true, + roleId: true, + headcount: true, + project: { + select: { staffingReqs: true }, + }, + }, + }); + + // Collect demanded skills from project staffingReqs + const demandSkillCounts = new Map(); + for (const demand of unfilled) { + const staffingReqs = (demand.project.staffingReqs as unknown as Array<{ + role?: string; + roleId?: string; + requiredSkills?: string[]; + }>) ?? []; + + // Match demand to staffing req by role or roleId + const matchedReq = staffingReqs.find( + (sr) => + (demand.roleId && sr.roleId === demand.roleId) || + (demand.role && sr.role === demand.role), + ); + + if (matchedReq?.requiredSkills) { + for (const skill of matchedReq.requiredSkills) { + demandSkillCounts.set(skill, (demandSkillCounts.get(skill) ?? 0) + demand.headcount); + } + } + } + + // Supply: count resources with skill at proficiency >= 3 + const supplySkillCounts = new Map(); + const allSkillCounts = new Map(); + for (const r of resources) { + const skills = (r.skills as unknown as SkillRow[]) ?? []; + for (const s of skills) { + allSkillCounts.set(s.skill, (allSkillCounts.get(s.skill) ?? 0) + 1); + if (s.proficiency >= 3) { + supplySkillCounts.set(s.skill, (supplySkillCounts.get(s.skill) ?? 0) + 1); + } + } + } + + // Merge all skill names from both demand and supply + const allGapSkills = new Set([...demandSkillCounts.keys(), ...supplySkillCounts.keys()]); + const gapData = Array.from(allGapSkills) + .map((skill) => { + const supply = supplySkillCounts.get(skill) ?? 0; + const demand = demandSkillCounts.get(skill) ?? 0; + return { skill, supply, demand, gap: demand - supply }; + }) + .sort((a, b) => b.gap - a.gap); + + // ── Section 3: Distribution (top 20 by resource count) ── + const aggregated = Array.from( + (() => { + const map = new Map(); + for (const r of resources) { + const skills = (r.skills as unknown as SkillRow[]) ?? []; + for (const s of skills) { + const entry = map.get(s.skill); + if (entry) { + entry.count++; + entry.totalProficiency += s.proficiency; + } else { + map.set(s.skill, { skill: s.skill, count: 1, totalProficiency: s.proficiency }); + } + } + } + return map; + })().values(), + ) + .map((e) => ({ + skill: e.skill, + count: e.count, + avgProficiency: Math.round((e.totalProficiency / e.count) * 10) / 10, + })) + .sort((a, b) => b.count - a.count) + .slice(0, 20); + + const directory = await getAnonymizationDirectory(ctx.db); + + return { + searchResults: anonymizeResources(searchResults, directory), + gapData, + distribution: aggregated, + totalResources: resources.length, + }; + }), }); diff --git a/packages/api/src/router/vacation.ts b/packages/api/src/router/vacation.ts index 3b340ae..b62a9b9 100644 --- a/packages/api/src/router/vacation.ts +++ b/packages/api/src/router/vacation.ts @@ -8,6 +8,7 @@ import { emitVacationCreated, emitVacationUpdated, emitNotificationCreated, emit import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js"; import { sendEmail } from "../lib/email.js"; import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js"; +import { checkVacationConflicts, checkBatchVacationConflicts } from "../lib/vacation-conflicts.js"; /** Types that consume from annual leave balance */ const BALANCE_TYPES = [VacationType.ANNUAL, VacationType.OTHER]; @@ -277,6 +278,10 @@ export const vacationRouter = createTRPCRouter({ select: { id: true }, }); + // Check for team conflicts before approving (non-blocking) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const conflictResult = await checkVacationConflicts(ctx.db as any, input.id, userRecord?.id); + const updated = await ctx.db.vacation.update({ where: { id: input.id }, data: { @@ -307,7 +312,7 @@ export const vacationRouter = createTRPCRouter({ void notifyVacationStatus(ctx.db, updated.id, updated.resourceId, VacationStatus.APPROVED); } - return updated; + return { ...updated, warnings: conflictResult.warnings }; }), /** @@ -373,6 +378,14 @@ export const vacationRouter = createTRPCRouter({ select: { id: true, resourceId: true }, }); + // Check for team conflicts before approving (non-blocking) + const conflictMap = await checkBatchVacationConflicts( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ctx.db as any, + vacations.map((v) => v.id), + userRecord?.id, + ); + await ctx.db.vacation.updateMany({ where: { id: { in: vacations.map((v) => v.id) } }, data: { @@ -402,7 +415,13 @@ export const vacationRouter = createTRPCRouter({ }); } - return { approved: vacations.length }; + // Flatten all warnings into a single array + const warnings: string[] = []; + for (const [, w] of conflictMap) { + warnings.push(...w); + } + + return { approved: vacations.length, warnings }; }), /**