feat: Sprint 3 — automation, intelligence, skill marketplace
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 <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { SkillMarketplace } from "~/components/analytics/SkillMarketplace.js";
|
||||
|
||||
export default function SkillMarketplacePage() {
|
||||
return <SkillMarketplace />;
|
||||
}
|
||||
@@ -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 <secret>`.
|
||||
*/
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 <secret>`.
|
||||
*/
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: () => <div className="h-80 shimmer-skeleton rounded-xl" /> },
|
||||
);
|
||||
|
||||
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 (
|
||||
<span className={`inline-block px-2 py-0.5 text-xs rounded font-medium border ${proficiencyClasses(value)}`}>
|
||||
{value} {PROFICIENCY_LABELS[value] ?? ""}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function GapIndicator({ gap }: { gap: number }) {
|
||||
if (gap > 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded font-semibold bg-red-100 text-red-700 border border-red-200 dark:bg-red-900/40 dark:text-red-300 dark:border-red-700">
|
||||
-{gap} shortage
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (gap < 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded font-semibold bg-green-100 text-green-700 border border-green-200 dark:bg-green-900/40 dark:text-green-300 dark:border-green-700">
|
||||
+{Math.abs(gap)} surplus
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded font-semibold bg-gray-100 text-gray-500 border border-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600">
|
||||
balanced
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="h-8 shimmer-skeleton rounded w-64" />
|
||||
<div className="h-16 shimmer-skeleton rounded-xl" />
|
||||
<div className="h-64 shimmer-skeleton rounded-xl" />
|
||||
<div className="h-80 shimmer-skeleton rounded-xl" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-sm text-red-700 dark:bg-red-900/20 dark:border-red-800 dark:text-red-300">
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-24 space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Skill Marketplace</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{data?.totalResources ?? 0} active resources · Search skills, identify gaps, plan capacity
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Section 1: Skill Search ──────────────────────────────────────────── */}
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl border border-gray-200 dark:border-slate-700 p-5 space-y-4">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200">Skill Search</h2>
|
||||
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
{/* Search input */}
|
||||
<div className="relative">
|
||||
<svg
|
||||
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by skill name..."
|
||||
value={searchSkill}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Min proficiency */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Min. proficiency:</span>
|
||||
<div className="flex rounded-lg overflow-hidden border border-gray-200 dark:border-slate-600">
|
||||
{[1, 2, 3, 4, 5].map((lvl) => (
|
||||
<button
|
||||
key={lvl}
|
||||
type="button"
|
||||
title={PROFICIENCY_LABELS[lvl]}
|
||||
onClick={() => setMinProficiency(lvl)}
|
||||
className={`px-2 py-1 text-xs font-medium transition-colors ${
|
||||
minProficiency === lvl
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-white dark:bg-slate-800 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-slate-700"
|
||||
}`}
|
||||
>
|
||||
{lvl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Available only */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={availableOnly}
|
||||
onChange={(e) => setAvailableOnly(e.target.checked)}
|
||||
className="rounded border-gray-300 text-brand-600 focus:ring-brand-500"
|
||||
/>
|
||||
Available in next 30 days
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Search results table */}
|
||||
{debouncedSearch && debouncedSearch.trim().length > 0 && (
|
||||
<div className="border-t border-gray-100 dark:border-slate-700 pt-4">
|
||||
{sortedSearch.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 italic">
|
||||
No resources found with "{debouncedSearch}" at proficiency {minProficiency}+.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
{sortedSearch.length} resource{sortedSearch.length !== 1 ? "s" : ""} found
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
|
||||
<tr>
|
||||
<SortableColumnHeader label="Resource" field="displayName" sortField={searchSortField} sortDir={searchSortDir} onSort={searchToggle} />
|
||||
<SortableColumnHeader label="Chapter" field="chapter" sortField={searchSortField} sortDir={searchSortDir} onSort={searchToggle} />
|
||||
<SortableColumnHeader label="Skill" field="skillName" sortField={searchSortField} sortDir={searchSortDir} onSort={searchToggle} />
|
||||
<SortableColumnHeader label="Proficiency" field="skillProficiency" sortField={searchSortField} sortDir={searchSortDir} onSort={searchToggle} align="center" />
|
||||
<SortableColumnHeader label="Utilization" field="utilizationPercent" sortField={searchSortField} sortDir={searchSortDir} onSort={searchToggle} align="right" />
|
||||
<SortableColumnHeader label="Available From" field="availableFrom" sortField={searchSortField} sortDir={searchSortDir} onSort={searchToggle} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
|
||||
{sortedSearch.map((r) => (
|
||||
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-slate-800/50">
|
||||
<td className="px-4 py-2.5">
|
||||
<Link
|
||||
href={`/resources/${r.id}`}
|
||||
className="font-medium text-gray-900 dark:text-gray-100 hover:text-brand-600 transition-colors"
|
||||
>
|
||||
{r.displayName}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-gray-400">{r.chapter ?? "---"}</td>
|
||||
<td className="px-4 py-2.5 text-gray-700 dark:text-gray-300">{r.skillName}</td>
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
<ProficiencyBadge value={r.skillProficiency} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<span className={`text-sm font-medium ${
|
||||
r.utilizationPercent >= 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}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-gray-400 text-sm">
|
||||
{formatDate(r.availableFrom)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Section 2: Skill Gap Heat Map ────────────────────────────────────── */}
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl border border-gray-200 dark:border-slate-700 p-5 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200">Skill Gap Analysis</h2>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
Supply = resources with proficiency 3+ · Demand = unfilled demand requirements · Sorted by largest gap
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{sortedGap.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 italic py-4">
|
||||
No gap data available. Gaps appear when projects have unfilled demand requirements with required skills.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
|
||||
<tr>
|
||||
<SortableColumnHeader label="Skill" field="skill" sortField={gapSortField} sortDir={gapSortDir} onSort={gapToggle} />
|
||||
<SortableColumnHeader label="Supply" field="supply" sortField={gapSortField} sortDir={gapSortDir} onSort={gapToggle} align="right" />
|
||||
<SortableColumnHeader label="Demand" field="demand" sortField={gapSortField} sortDir={gapSortDir} onSort={gapToggle} align="right" />
|
||||
<SortableColumnHeader label="Gap" field="gap" sortField={gapSortField} sortDir={gapSortDir} onSort={gapToggle} align="center" />
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Visual</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700">
|
||||
{sortedGap.map((row) => {
|
||||
const maxBar = Math.max(row.supply, row.demand, 1);
|
||||
return (
|
||||
<tr key={row.skill} className="hover:bg-gray-50 dark:hover:bg-slate-800/50">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-brand-600 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSearchSkill(row.skill);
|
||||
setMinProficiency(3);
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}}
|
||||
title={`Search for "${row.skill}"`}
|
||||
>
|
||||
{row.skill}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{row.supply}</td>
|
||||
<td className="px-4 py-2.5 text-right text-gray-700 dark:text-gray-300">{row.demand}</td>
|
||||
<td className="px-4 py-2.5 text-center">
|
||||
<GapIndicator gap={row.gap} />
|
||||
</td>
|
||||
<td className="px-4 py-2.5 w-48">
|
||||
<div className="flex items-center gap-1 h-4">
|
||||
<div
|
||||
className="h-3 rounded-sm bg-green-400 dark:bg-green-500 transition-all"
|
||||
style={{ width: `${(row.supply / maxBar) * 100}%`, minWidth: row.supply > 0 ? 4 : 0 }}
|
||||
title={`Supply: ${row.supply}`}
|
||||
/>
|
||||
<div
|
||||
className="h-3 rounded-sm bg-red-400 dark:bg-red-500 transition-all"
|
||||
style={{ width: `${(row.demand / maxBar) * 100}%`, minWidth: row.demand > 0 ? 4 : 0 }}
|
||||
title={`Demand: ${row.demand}`}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex items-center gap-4 mt-3 px-4">
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="w-3 h-3 rounded-sm bg-green-400 dark:bg-green-500 inline-block" /> Supply (prof. 3+)
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="w-3 h-3 rounded-sm bg-red-400 dark:bg-red-500 inline-block" /> Demand (unfilled)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Section 3: Skill Distribution ────────────────────────────────────── */}
|
||||
{(data?.distribution ?? []).length > 0 && (
|
||||
<div className="bg-white dark:bg-slate-900 rounded-xl border border-gray-200 dark:border-slate-700 p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-4">
|
||||
Top 20 Skills by Resource Count
|
||||
</h2>
|
||||
<SkillDistributionChart data={data?.distribution ?? []} />
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">
|
||||
Bar color = average proficiency (light to dark = low to high)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,9 @@ function RolesIcon() {
|
||||
function SkillsIcon() {
|
||||
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 3l2.8 5.7 6.2.9-4.5 4.4 1 6.2L12 17.2 6.5 20.2l1-6.2L3 9.6l6.2-.9L12 3z" /></svg>;
|
||||
}
|
||||
function MarketplaceIcon() {
|
||||
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M3 3h18l-2 9H5L3 3zm0 0l-1-1m6 16a1 1 0 102 0 1 1 0 00-2 0zm10 0a1 1 0 102 0 1 1 0 00-2 0zM5 12h14" /></svg>;
|
||||
}
|
||||
function ChargeabilityIcon() {
|
||||
return <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M5 17l4-4 3 3 7-8M19 19H5V5" /></svg>;
|
||||
}
|
||||
@@ -136,6 +139,7 @@ const navSections: NavSection[] = [
|
||||
label: "Analytics",
|
||||
items: [
|
||||
{ href: "/analytics/skills", label: "Skills Analytics", icon: <SkillsIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER", "VIEWER"] },
|
||||
{ href: "/analytics/skill-marketplace", label: "Skill Marketplace", icon: <MarketplaceIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
{ href: "/reports/chargeability", label: "Chargeability", icon: <ChargeabilityIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
{ href: "/analytics/computation-graph", label: "Computation Graph", icon: <GraphIcon />, roles: ["ADMIN", "MANAGER", "CONTROLLER"] },
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<typeof listAssignmentBookings>[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<Array<{
|
||||
id: string;
|
||||
displayName: string;
|
||||
eid: string | null;
|
||||
skills: unknown;
|
||||
lcrCents: number;
|
||||
chargeabilityTarget: number;
|
||||
availability: unknown;
|
||||
valueScore: number | null;
|
||||
}>>;
|
||||
};
|
||||
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<Array<{ id: string }>>;
|
||||
};
|
||||
};
|
||||
|
||||
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<void> {
|
||||
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<typeof rankResources>[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.
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
select: Record<string, unknown>;
|
||||
}) => 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<string, unknown>;
|
||||
select: Record<string, unknown>;
|
||||
}) => Promise<
|
||||
Array<{
|
||||
resourceId: string;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
type: string;
|
||||
isHalfDay: boolean;
|
||||
}>
|
||||
>;
|
||||
};
|
||||
notification: {
|
||||
findFirst: (args: {
|
||||
where: Record<string, unknown>;
|
||||
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<Array<{ id: string }>>;
|
||||
};
|
||||
};
|
||||
|
||||
/** 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<number> {
|
||||
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;
|
||||
}
|
||||
@@ -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<Array<{ id: string; federalState: string | null }>>;
|
||||
};
|
||||
vacation: {
|
||||
findMany: (args: unknown) => Promise<MinimalVacation[]>;
|
||||
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<AutoImportResult> {
|
||||
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<string | null, string[]>();
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<RateCardLookupResult | null> {
|
||||
const effectiveDate = params.effectiveDate ?? new Date();
|
||||
|
||||
// Build rate card filter: active cards, within effective date range
|
||||
const rateCardWhere: Record<string, unknown> = {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<number>;
|
||||
};
|
||||
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<VacationConflictResult> {
|
||||
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<string>();
|
||||
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<Map<string, string[]>> {
|
||||
const results = new Map<string, string[]>();
|
||||
|
||||
for (const id of vacationIds) {
|
||||
const result = await checkVacationConflicts(db, id, approverUserId);
|
||||
if (result.warnings.length > 0) {
|
||||
results.set(id, result.warnings);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
|
||||
@@ -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<typeof CreateEstimateSchema>["demandLines"],
|
||||
projectId?: string | null,
|
||||
): Promise<{
|
||||
demandLines: z.infer<typeof CreateEstimateSchema>["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<string, unknown>;
|
||||
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<typeof createEstimate>[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<typeof updateEstimateDraft>[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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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<string, { utilizationPercent: number; earliestAvailableDate: Date | null }>();
|
||||
for (const r of resources) {
|
||||
const avail = r.availability as Record<string, number>;
|
||||
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<string, number>();
|
||||
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<string, number>();
|
||||
const allSkillCounts = new Map<string, number>();
|
||||
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<string, { skill: string; count: number; totalProficiency: number }>();
|
||||
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,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user