refactor: complete v2 refactoring plan (Phases 1-5)
Phase 1 — Quick Wins: centralize formatMoney/formatCents, extract findUniqueOrThrow helper (19 routers), shared Prisma select constants, useInvalidatePlanningViews hook, status badge consolidation, composite DB indexes. Phase 2 — Timeline Split: extract TimelineContext, TimelineResourcePanel, TimelineProjectPanel; split 28-dep useMemo into 3 focused memos. TimelineView.tsx reduced from 1,903 to 538 lines. Phase 3 — Query Performance: server-side filtering for getEntriesView, remove availability from timeline resource select, SSE event debouncing (50ms batch window). Phase 4 — Estimate Workspace: extract 7 tab components and 3 editor components. EstimateWorkspaceClient 1,298→306 lines, EstimateWorkspaceDraftEditor 1,205→581 lines. Phase 5 — Package Cleanup: split commit-dispo-import-batch (1,112→573 lines), extract shared pagination helper with 11 tests. All tests pass: 209 API, 254 engine, 67 application. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -8,7 +8,7 @@ import type { inferRouterOutputs } from "@trpc/server";
|
|||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { EstimateWizard } from "~/components/estimates/EstimateWizard.js";
|
import { EstimateWizard } from "~/components/estimates/EstimateWizard.js";
|
||||||
import { usePermissions } from "~/hooks/usePermissions.js";
|
import { usePermissions } from "~/hooks/usePermissions.js";
|
||||||
import { formatDateLong } from "~/lib/format.js";
|
import { formatDateLong, formatMoney } from "~/lib/format.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
type RouterOutput = inferRouterOutputs<AppRouter>;
|
type RouterOutput = inferRouterOutputs<AppRouter>;
|
||||||
@@ -30,14 +30,6 @@ const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
|
|||||||
SUPERSEDED: "bg-zinc-200 text-zinc-700",
|
SUPERSEDED: "bg-zinc-200 text-zinc-700",
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatMoney(cents: number | null | undefined, currency = "EUR") {
|
|
||||||
return new Intl.NumberFormat("de-DE", {
|
|
||||||
style: "currency",
|
|
||||||
currency,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format((cents ?? 0) / 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMetricValue(metric: EstimateDetail["versions"][number]["metrics"][number]) {
|
function formatMetricValue(metric: EstimateDetail["versions"][number]["metrics"][number]) {
|
||||||
if (metric.valueCents != null) {
|
if (metric.valueCents != null) {
|
||||||
return formatMoney(metric.valueCents, metric.currency ?? "EUR");
|
return formatMoney(metric.valueCents, metric.currency ?? "EUR");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { formatCents } from "~/lib/format.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
// ─── Local types ────────────────────────────────────────────────────────────
|
// ─── Local types ────────────────────────────────────────────────────────────
|
||||||
@@ -86,11 +87,6 @@ const emptyLine: EditingLine = {
|
|||||||
machineRateCents: 0,
|
machineRateCents: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatCents(cents: number | null | undefined): string {
|
|
||||||
if (cents == null) return "-";
|
|
||||||
return (cents / 100).toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(d: string | null | undefined): string {
|
function formatDate(d: string | null | undefined): string {
|
||||||
if (!d) return "-";
|
if (!d) return "-";
|
||||||
return new Date(d).toLocaleDateString("de-DE");
|
return new Date(d).toLocaleDateString("de-DE");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||||
|
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
|
||||||
import { AllocationStatus } from "@planarchy/shared";
|
import { AllocationStatus } from "@planarchy/shared";
|
||||||
import type { AllocationWithDetails, RecurrencePattern } from "@planarchy/shared";
|
import type { AllocationWithDetails, RecurrencePattern } from "@planarchy/shared";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
@@ -64,17 +65,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
|||||||
{ staleTime: 60_000 },
|
{ staleTime: 60_000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const utils = trpc.useUtils();
|
const invalidatePlanningViews = useInvalidatePlanningViews();
|
||||||
const invalidatePlanningViews = () => {
|
|
||||||
void utils.allocation.list.invalidate();
|
|
||||||
void (utils as { allocation: { listView: { invalidate: () => Promise<unknown> } } }).allocation.listView.invalidate();
|
|
||||||
void utils.allocation.listDemands.invalidate();
|
|
||||||
void utils.allocation.listAssignments.invalidate();
|
|
||||||
void utils.timeline.getEntries.invalidate();
|
|
||||||
void utils.timeline.getEntriesView.invalidate();
|
|
||||||
void utils.timeline.getProjectContext.invalidate();
|
|
||||||
void utils.timeline.getBudgetStatus.invalidate();
|
|
||||||
};
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const createDemandMutation = (trpc.allocation.createDemandRequirement.useMutation as any)({
|
const createDemandMutation = (trpc.allocation.createDemandRequirement.useMutation as any)({
|
||||||
|
|||||||
@@ -20,14 +20,7 @@ import { usePermissions } from "~/hooks/usePermissions.js";
|
|||||||
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
|
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
|
||||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||||
|
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
|
||||||
const STATUS_BADGE: Record<string, string> = {
|
|
||||||
ACTIVE: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
|
||||||
PROPOSED: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
|
|
||||||
CONFIRMED: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
|
||||||
COMPLETED: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400",
|
|
||||||
CANCELLED: "bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
const ALL_ALLOC_STATUSES = [
|
const ALL_ALLOC_STATUSES = [
|
||||||
{ value: "PROPOSED", label: "Proposed" },
|
{ value: "PROPOSED", label: "Proposed" },
|
||||||
@@ -221,12 +214,11 @@ export function AllocationsClient() {
|
|||||||
const singleDeletePending = deleteDemandMutation.isPending || deleteAssignmentMutation.isPending;
|
const singleDeletePending = deleteDemandMutation.isPending || deleteAssignmentMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 pb-24">
|
<div className="app-page space-y-5 pb-24">
|
||||||
{/* Page header */}
|
<div className="app-page-header gap-4">
|
||||||
<div className="mb-6 flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Allocations</h1>
|
<h1 className="app-page-title">Allocations</h1>
|
||||||
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
<p className="app-page-subtitle mt-1">
|
||||||
{isLoading
|
{isLoading
|
||||||
? "Loading…"
|
? "Loading…"
|
||||||
: `${filteredAllocations.length} assignment${filteredAllocations.length !== 1 ? "s" : ""}${filteredDemands.length > 0 ? ` · ${filteredDemands.length} open demand${filteredDemands.length !== 1 ? "s" : ""}` : ""}`}
|
: `${filteredAllocations.length} assignment${filteredAllocations.length !== 1 ? "s" : ""}${filteredDemands.length > 0 ? ` · ${filteredDemands.length} open demand${filteredDemands.length !== 1 ? "s" : ""}` : ""}`}
|
||||||
@@ -237,28 +229,27 @@ export function AllocationsClient() {
|
|||||||
href="/api/reports/allocations"
|
href="/api/reports/allocations"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors flex items-center gap-2"
|
className="inline-flex items-center gap-2 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
↓ PDF
|
↓ PDF
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="/api/reports/allocations?format=xlsx"
|
href="/api/reports/allocations?format=xlsx"
|
||||||
download
|
download
|
||||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors flex items-center gap-2"
|
className="inline-flex items-center gap-2 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
↓ XLS
|
↓ XLS
|
||||||
</a>
|
</a>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={openCreate}
|
onClick={openCreate}
|
||||||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
className="rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-brand-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
New Planning Entry
|
New Planning Entry
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<FilterBar>
|
<FilterBar>
|
||||||
<ProjectCombobox
|
<ProjectCombobox
|
||||||
value={filterProjectId || null}
|
value={filterProjectId || null}
|
||||||
@@ -277,7 +268,7 @@ export function AllocationsClient() {
|
|||||||
<select
|
<select
|
||||||
value={filterStatus}
|
value={filterStatus}
|
||||||
onChange={(e) => setFilterStatus(e.target.value)}
|
onChange={(e) => setFilterStatus(e.target.value)}
|
||||||
className="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm bg-white dark:bg-gray-900 dark:text-gray-100"
|
className="app-select"
|
||||||
>
|
>
|
||||||
<option value="">All Statuses</option>
|
<option value="">All Statuses</option>
|
||||||
{ALL_ALLOC_STATUSES.map((s) => (
|
{ALL_ALLOC_STATUSES.map((s) => (
|
||||||
@@ -285,7 +276,7 @@ export function AllocationsClient() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 cursor-pointer whitespace-nowrap">
|
<label className="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={hidePastProjects}
|
checked={hidePastProjects}
|
||||||
@@ -295,7 +286,7 @@ export function AllocationsClient() {
|
|||||||
Hide past
|
Hide past
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 cursor-pointer whitespace-nowrap">
|
<label className="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={hideCompletedProjects}
|
checked={hideCompletedProjects}
|
||||||
@@ -305,7 +296,7 @@ export function AllocationsClient() {
|
|||||||
Hide completed
|
Hide completed
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400 cursor-pointer whitespace-nowrap">
|
<label className="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm text-gray-600 dark:text-gray-300">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={hideDraftProjects}
|
checked={hideDraftProjects}
|
||||||
@@ -329,10 +320,9 @@ export function AllocationsClient() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
<div className="app-data-table">
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-gray-50 dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700">
|
<thead className="border-b border-gray-200 dark:border-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 w-10">
|
<th className="px-4 py-3 w-10">
|
||||||
<input
|
<input
|
||||||
@@ -370,16 +360,16 @@ export function AllocationsClient() {
|
|||||||
<th className="px-4 py-3" />
|
<th className="px-4 py-3" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={9} className="text-center py-12 text-gray-400 dark:text-gray-500 text-sm">Loading allocations…</td>
|
<td colSpan={9} className="py-12 text-center text-sm text-gray-500 dark:text-gray-400">Loading allocations…</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isLoading && sorted.length === 0 && (
|
{!isLoading && sorted.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={9} className="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">No assignments found.</td>
|
<td colSpan={9} className="py-12 text-center text-sm text-gray-500 dark:text-gray-400">No assignments found.</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -387,7 +377,7 @@ export function AllocationsClient() {
|
|||||||
sorted.map((alloc) => {
|
sorted.map((alloc) => {
|
||||||
const isSelected = selection.selectedIds.has(alloc.id);
|
const isSelected = selection.selectedIds.has(alloc.id);
|
||||||
return (
|
return (
|
||||||
<tr key={alloc.id} className={`hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`}>
|
<tr key={alloc.id} className={`transition-colors hover:bg-gray-50/80 dark:hover:bg-gray-900/60 ${isSelected ? "bg-brand-50 dark:bg-brand-900/20" : ""}`}>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -402,16 +392,16 @@ export function AllocationsClient() {
|
|||||||
return <td key={col.key} className="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">{alloc.resource?.displayName ?? "—"}</td>;
|
return <td key={col.key} className="px-4 py-3 text-sm font-medium text-gray-900 dark:text-gray-100">{alloc.resource?.displayName ?? "—"}</td>;
|
||||||
case "project":
|
case "project":
|
||||||
return (
|
return (
|
||||||
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">
|
<td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">
|
||||||
{alloc.project ? (
|
{alloc.project ? (
|
||||||
<><span className="font-mono text-xs">{alloc.project.shortCode}</span> {alloc.project.name}</>
|
<><span className="font-mono text-xs">{alloc.project.shortCode}</span> {alloc.project.name}</>
|
||||||
) : "—"}
|
) : "—"}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
case "role":
|
case "role":
|
||||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-400">{alloc.role}</td>;
|
return <td key={col.key} className="px-4 py-3 text-sm text-gray-600 dark:text-gray-300">{alloc.role}</td>;
|
||||||
case "dates":
|
case "dates":
|
||||||
return <td key={col.key} className="px-4 py-3 text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">{formatPeriod(alloc)}</td>;
|
return <td key={col.key} className="whitespace-nowrap px-4 py-3 text-xs text-gray-500 dark:text-gray-400">{formatPeriod(alloc)}</td>;
|
||||||
case "hoursPerDay":
|
case "hoursPerDay":
|
||||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alloc.hoursPerDay}h</td>;
|
return <td key={col.key} className="px-4 py-3 text-sm text-gray-900 dark:text-gray-100">{alloc.hoursPerDay}h</td>;
|
||||||
case "cost":
|
case "cost":
|
||||||
@@ -425,17 +415,17 @@ export function AllocationsClient() {
|
|||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500">—</td>;
|
return <td key={col.key} className="px-4 py-3 text-sm text-gray-500 dark:text-gray-400">—</td>;
|
||||||
}
|
}
|
||||||
})}
|
})}
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<button type="button" onClick={() => openEdit(alloc)} className="text-xs text-blue-600 hover:text-blue-800 font-medium hover:underline">Edit</button>
|
<button type="button" onClick={() => openEdit(alloc)} className="text-xs font-medium text-blue-600 hover:text-blue-800 hover:underline dark:text-blue-300 dark:hover:text-blue-200">Edit</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirmDelete({ single: alloc })}
|
onClick={() => setConfirmDelete({ single: alloc })}
|
||||||
disabled={singleDeletePending}
|
disabled={singleDeletePending}
|
||||||
className="text-xs text-red-500 hover:text-red-700 font-medium hover:underline disabled:opacity-50"
|
className="text-xs font-medium text-red-500 hover:text-red-700 hover:underline disabled:opacity-50 dark:text-red-300 dark:hover:text-red-200"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -449,8 +439,8 @@ export function AllocationsClient() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isLoading && filteredDemands.length > 0 && (
|
{!isLoading && filteredDemands.length > 0 && (
|
||||||
<div className="mt-6 bg-white dark:bg-gray-800 rounded-xl border border-amber-200 dark:border-amber-800/60 overflow-hidden">
|
<div className="app-surface mt-6 overflow-hidden border-amber-200 dark:border-amber-900/70">
|
||||||
<div className="px-4 py-3 border-b border-amber-200 dark:border-amber-800/60 bg-amber-50/70 dark:bg-amber-950/20 flex items-center justify-between">
|
<div className="flex items-center justify-between border-b border-amber-200 bg-amber-50/70 px-4 py-3 dark:border-amber-900/70 dark:bg-amber-950/20">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-amber-900 dark:text-amber-200">Open Demands</h2>
|
<h2 className="text-sm font-semibold text-amber-900 dark:text-amber-200">Open Demands</h2>
|
||||||
<p className="text-xs text-amber-700 dark:text-amber-300/80">
|
<p className="text-xs text-amber-700 dark:text-amber-300/80">
|
||||||
@@ -465,7 +455,7 @@ export function AllocationsClient() {
|
|||||||
{filteredDemands.map((demand) => (
|
{filteredDemands.map((demand) => (
|
||||||
<div
|
<div
|
||||||
key={demand.id}
|
key={demand.id}
|
||||||
className="px-4 py-3 flex items-center justify-between gap-4 hover:bg-amber-50/40 dark:hover:bg-amber-950/10"
|
className="flex items-center justify-between gap-4 px-4 py-3 hover:bg-amber-50/40 dark:hover:bg-amber-950/10"
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
@@ -473,11 +463,11 @@ export function AllocationsClient() {
|
|||||||
<><span className="font-mono text-xs">{demand.project.shortCode}</span> {demand.project.name}</>
|
<><span className="font-mono text-xs">{demand.project.shortCode}</span> {demand.project.name}</>
|
||||||
) : "Unknown project"}
|
) : "Unknown project"}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
{(demand.role ?? "Placeholder role")} · {formatPeriod(demand)} · {demand.hoursPerDay}h/day
|
{(demand.role ?? "Placeholder role")} · {formatPeriod(demand)} · {demand.hoursPerDay}h/day
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 flex-shrink-0">
|
<div className="flex flex-shrink-0 items-center gap-4">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-xs uppercase tracking-wide text-amber-700 dark:text-amber-300">Unfilled</div>
|
<div className="text-xs uppercase tracking-wide text-amber-700 dark:text-amber-300">Unfilled</div>
|
||||||
<div className="text-sm font-semibold text-amber-900 dark:text-amber-200">
|
<div className="text-sm font-semibold text-amber-900 dark:text-amber-200">
|
||||||
@@ -488,7 +478,7 @@ export function AllocationsClient() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => openEdit(demand as AllocationWithDetails)}
|
onClick={() => openEdit(demand as AllocationWithDetails)}
|
||||||
className="text-xs text-blue-600 hover:text-blue-800 font-medium hover:underline"
|
className="text-xs font-medium text-blue-600 hover:text-blue-800 hover:underline dark:text-blue-300 dark:hover:text-blue-200"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -496,7 +486,7 @@ export function AllocationsClient() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirmDelete({ single: demand as AllocationWithDetails })}
|
onClick={() => setConfirmDelete({ single: demand as AllocationWithDetails })}
|
||||||
disabled={singleDeletePending}
|
disabled={singleDeletePending}
|
||||||
className="text-xs text-red-500 hover:text-red-700 font-medium hover:underline disabled:opacity-50"
|
className="text-xs font-medium text-red-500 hover:text-red-700 hover:underline disabled:opacity-50 dark:text-red-300 dark:hover:text-red-200"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
@@ -511,8 +501,8 @@ export function AllocationsClient() {
|
|||||||
{/* Batch Status Picker */}
|
{/* Batch Status Picker */}
|
||||||
{batchStatusPicker && (
|
{batchStatusPicker && (
|
||||||
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4" onClick={() => setBatchStatusPicker(false)}>
|
<div className="fixed inset-0 bg-black/30 z-50 flex items-center justify-center p-4" onClick={() => setBatchStatusPicker(false)}>
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl p-5 min-w-[220px]" onClick={(e) => e.stopPropagation()}>
|
<div className="min-w-[220px] rounded-2xl bg-white p-5 shadow-2xl dark:bg-gray-900" onClick={(e) => e.stopPropagation()}>
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Set status for {selection.count} allocations</h3>
|
<h3 className="mb-3 text-sm font-semibold text-gray-900 dark:text-gray-100">Set status for {selection.count} allocations</h3>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{ALL_ALLOC_STATUSES.map((s) => (
|
{ALL_ALLOC_STATUSES.map((s) => (
|
||||||
<button
|
<button
|
||||||
@@ -522,7 +512,7 @@ export function AllocationsClient() {
|
|||||||
setConfirmBatchStatus({ ids: selectedMutationIds, status: s.value });
|
setConfirmBatchStatus({ ids: selectedMutationIds, status: s.value });
|
||||||
setBatchStatusPicker(false);
|
setBatchStatusPicker(false);
|
||||||
}}
|
}}
|
||||||
className="w-full text-left px-3 py-2 text-sm rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
className="w-full rounded-xl px-3 py-2 text-left text-sm transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[s.value]}`}>
|
<span className={`inline-block px-2 py-0.5 text-xs rounded-full font-medium ${STATUS_BADGE[s.value]}`}>
|
||||||
{s.label}
|
{s.label}
|
||||||
|
|||||||
@@ -5,14 +5,7 @@ import { trpc } from "~/lib/trpc/client.js";
|
|||||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||||
import { ProjectStatus } from "@planarchy/shared/types";
|
import { ProjectStatus } from "@planarchy/shared/types";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
import { PROJECT_STATUS_BADGE as STATUS_COLORS } from "~/lib/status-styles.js";
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
|
||||||
DRAFT: "bg-gray-100 text-gray-700",
|
|
||||||
ACTIVE: "bg-green-100 text-green-700",
|
|
||||||
ON_HOLD: "bg-yellow-100 text-yellow-700",
|
|
||||||
COMPLETED: "bg-blue-100 text-blue-700",
|
|
||||||
CANCELLED: "bg-red-100 text-red-700",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
|
export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||||
const status = (config.status as ProjectStatus) || undefined;
|
const status = (config.status as ProjectStatus) || undefined;
|
||||||
|
|||||||
@@ -2,12 +2,9 @@
|
|||||||
|
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||||
|
import { formatMoney } from "~/lib/format.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
|
||||||
function formatMoney(cents: number): string {
|
|
||||||
return (cents / 100).toLocaleString("de-DE") + " EUR";
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({ label, value, sub, info }: { label: string; value: string | number; sub?: string; info?: React.ReactNode }) {
|
function StatCard({ label, value, sub, info }: { label: string; value: string | number; sub?: string; info?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
|
import { formatCents } from "~/lib/format.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
interface ApplyExperienceMultipliersProps {
|
interface ApplyExperienceMultipliersProps {
|
||||||
@@ -10,13 +11,6 @@ interface ApplyExperienceMultipliersProps {
|
|||||||
onApplied?: () => void;
|
onApplied?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCents(cents: number): string {
|
|
||||||
return (cents / 100).toLocaleString("de-DE", {
|
|
||||||
minimumFractionDigits: 2,
|
|
||||||
maximumFractionDigits: 2,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: ApplyExperienceMultipliersProps) {
|
export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: ApplyExperienceMultipliersProps) {
|
||||||
const utils = trpc.useUtils();
|
const utils = trpc.useUtils();
|
||||||
const { data: sets, isLoading } = trpc.experienceMultiplier.list.useQuery();
|
const { data: sets, isLoading } = trpc.experienceMultiplier.list.useQuery();
|
||||||
@@ -29,7 +23,7 @@ export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: A
|
|||||||
{ enabled: showPreview && Boolean(selectedSetId) },
|
{ enabled: showPreview && Boolean(selectedSetId) },
|
||||||
);
|
);
|
||||||
|
|
||||||
const applyMutation = trpc.experienceMultiplier.apply.useMutation({
|
const applyMutation = trpc.experienceMultiplier.applyRules.useMutation({
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
utils.estimate.getById.invalidate();
|
utils.estimate.getById.invalidate();
|
||||||
setShowPreview(false);
|
setShowPreview(false);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { computeEvenSpread } from "@planarchy/engine";
|
|||||||
import { isSpreadsheetFile } from "~/lib/excel.js";
|
import { isSpreadsheetFile } from "~/lib/excel.js";
|
||||||
import { parseScopeImport } from "~/lib/scopeImportParser.js";
|
import { parseScopeImport } from "~/lib/scopeImportParser.js";
|
||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
|
import { formatMoney } from "~/lib/format.js";
|
||||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||||
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
|
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
|
||||||
import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js";
|
import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js";
|
||||||
@@ -120,14 +121,6 @@ function toHours(value: string) {
|
|||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatMoney(cents: number, currency = "EUR") {
|
|
||||||
return new Intl.NumberFormat("de-DE", {
|
|
||||||
style: "currency",
|
|
||||||
currency,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(cents / 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function slugify(value: string) {
|
function slugify(value: string) {
|
||||||
return value
|
return value
|
||||||
.trim()
|
.trim()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
CommercialTerms,
|
||||||
EstimateDemandLineMetadata,
|
EstimateDemandLineMetadata,
|
||||||
EstimateExportArtifactPayload,
|
EstimateExportArtifactPayload,
|
||||||
EstimateExportFormat,
|
EstimateExportFormat,
|
||||||
@@ -98,6 +99,7 @@ export interface EstimateVersionView {
|
|||||||
resourceSnapshots: EstimateResourceSnapshotView[];
|
resourceSnapshots: EstimateResourceSnapshotView[];
|
||||||
metrics: EstimateMetricView[];
|
metrics: EstimateMetricView[];
|
||||||
exports: EstimateExportView[];
|
exports: EstimateExportView[];
|
||||||
|
commercialTerms?: CommercialTerms | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EstimateWorkspaceView {
|
export interface EstimateWorkspaceView {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -19,62 +19,22 @@ import type {
|
|||||||
EstimateWorkspaceView,
|
EstimateWorkspaceView,
|
||||||
WorkspaceTab,
|
WorkspaceTab,
|
||||||
} from "~/components/estimates/EstimateWorkspace.types.js";
|
} from "~/components/estimates/EstimateWorkspace.types.js";
|
||||||
import { isSpreadsheetFile } from "~/lib/excel.js";
|
import {
|
||||||
import { parseScopeImport } from "~/lib/scopeImportParser.js";
|
AssumptionEditor,
|
||||||
|
type EditableAssumption,
|
||||||
|
} from "~/components/estimates/editors/AssumptionEditor.js";
|
||||||
|
import {
|
||||||
|
ScopeItemEditor,
|
||||||
|
type EditableScopeItem,
|
||||||
|
} from "~/components/estimates/editors/ScopeItemEditor.js";
|
||||||
|
import {
|
||||||
|
DemandLineEditor,
|
||||||
|
type EditableDemandLine,
|
||||||
|
type ResourceOption,
|
||||||
|
} from "~/components/estimates/editors/DemandLineEditor.js";
|
||||||
|
import { formatMoney } from "~/lib/format.js";
|
||||||
import { trpc } from "~/lib/trpc/client.js";
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
interface EditableAssumption {
|
|
||||||
id?: string;
|
|
||||||
category: string;
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
valueType: string;
|
|
||||||
value: string;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EditableScopeItem {
|
|
||||||
id?: string;
|
|
||||||
sequenceNo: string;
|
|
||||||
scopeType: string;
|
|
||||||
packageCode: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EditableDemandLine {
|
|
||||||
id?: string;
|
|
||||||
scopeItemId?: string;
|
|
||||||
roleId?: string;
|
|
||||||
resourceId?: string;
|
|
||||||
lineType: string;
|
|
||||||
name: string;
|
|
||||||
chapter: string;
|
|
||||||
hours: string;
|
|
||||||
currency: string;
|
|
||||||
costRate: string;
|
|
||||||
billRate: string;
|
|
||||||
costRateMode: EstimateDemandLineRateMode;
|
|
||||||
billRateMode: EstimateDemandLineRateMode;
|
|
||||||
metadata: Record<string, unknown>;
|
|
||||||
/** Locked monthly hours overrides, keyed by "YYYY-MM" */
|
|
||||||
lockedMonths: Record<string, number>;
|
|
||||||
/** Whether the monthly spread section is expanded */
|
|
||||||
spreadExpanded: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResourceOption {
|
|
||||||
id: string;
|
|
||||||
eid: string;
|
|
||||||
displayName: string;
|
|
||||||
chapter?: string | null;
|
|
||||||
roleId?: string | null;
|
|
||||||
lcrCents: number;
|
|
||||||
ucrCents: number;
|
|
||||||
currency: string;
|
|
||||||
dynamicFields?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResourceListView {
|
interface ResourceListView {
|
||||||
resources: ResourceOption[];
|
resources: ResourceOption[];
|
||||||
}
|
}
|
||||||
@@ -83,14 +43,6 @@ const INPUT_CLS =
|
|||||||
"w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100";
|
"w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100";
|
||||||
const LABEL_CLS = "mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500";
|
const LABEL_CLS = "mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500";
|
||||||
|
|
||||||
function formatMoney(cents: number, currency = "EUR") {
|
|
||||||
return new Intl.NumberFormat("de-DE", {
|
|
||||||
style: "currency",
|
|
||||||
currency,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(cents / 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toNumber(value: string) {
|
function toNumber(value: string) {
|
||||||
const parsed = Number.parseFloat(value);
|
const parsed = Number.parseFloat(value);
|
||||||
return Number.isFinite(parsed) ? parsed : 0;
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
@@ -130,44 +82,6 @@ function stringifyAssumptionValue(value: unknown) {
|
|||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeAssumption(): EditableAssumption {
|
|
||||||
return {
|
|
||||||
category: "commercial",
|
|
||||||
key: "",
|
|
||||||
label: "",
|
|
||||||
valueType: "string",
|
|
||||||
value: "",
|
|
||||||
notes: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeScopeItem(sequenceNo: number): EditableScopeItem {
|
|
||||||
return {
|
|
||||||
sequenceNo: String(sequenceNo),
|
|
||||||
scopeType: "SHOT",
|
|
||||||
packageCode: "",
|
|
||||||
name: "",
|
|
||||||
description: "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeDemandLine(): EditableDemandLine {
|
|
||||||
return {
|
|
||||||
lineType: "LABOR",
|
|
||||||
name: "",
|
|
||||||
chapter: "",
|
|
||||||
hours: "8",
|
|
||||||
currency: "EUR",
|
|
||||||
costRate: "0",
|
|
||||||
billRate: "0",
|
|
||||||
costRateMode: "manual",
|
|
||||||
billRateMode: "manual",
|
|
||||||
metadata: {},
|
|
||||||
lockedMonths: {},
|
|
||||||
spreadExpanded: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderEditorHint() {
|
function renderEditorHint() {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
|
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
|
||||||
@@ -356,8 +270,6 @@ export function EstimateWorkspaceDraftEditor({
|
|||||||
return renderEditorHint();
|
return renderEditorHint();
|
||||||
}
|
}
|
||||||
|
|
||||||
const editableVersion = workingVersion;
|
|
||||||
|
|
||||||
function getLineResourceSnapshot(line: EditableDemandLine) {
|
function getLineResourceSnapshot(line: EditableDemandLine) {
|
||||||
if (!line.resourceId) {
|
if (!line.resourceId) {
|
||||||
return null;
|
return null;
|
||||||
@@ -379,117 +291,6 @@ export function EstimateWorkspaceDraftEditor({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDemandLine(index: number, updater: (line: EditableDemandLine) => EditableDemandLine) {
|
|
||||||
setDemandLines((current) =>
|
|
||||||
current.map((entry, entryIndex) => (entryIndex === index ? updater(entry) : entry)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyResourceSelection(index: number, nextResourceId: string) {
|
|
||||||
updateDemandLine(index, (line) => {
|
|
||||||
if (!nextResourceId) {
|
|
||||||
const { resourceId: _resourceId, ...unlinkedLine } = line;
|
|
||||||
return {
|
|
||||||
...unlinkedLine,
|
|
||||||
costRateMode: "manual",
|
|
||||||
billRateMode: "manual",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const resource = resourceMap.get(nextResourceId);
|
|
||||||
if (!resource) {
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...line,
|
|
||||||
resourceId: resource.id,
|
|
||||||
...(resource.roleId ? { roleId: resource.roleId } : {}),
|
|
||||||
chapter: resource.chapter ?? line.chapter,
|
|
||||||
currency: resource.currency,
|
|
||||||
costRate: (resource.lcrCents / 100).toFixed(2),
|
|
||||||
billRate: (resource.ucrCents / 100).toFixed(2),
|
|
||||||
costRateMode: "resource",
|
|
||||||
billRateMode: "resource",
|
|
||||||
name: line.name.trim() ? line.name : resource.displayName,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncDemandLineRates(index: number) {
|
|
||||||
updateDemandLine(index, (line) => {
|
|
||||||
if (!line.resourceId) {
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resource = resourceMap.get(line.resourceId);
|
|
||||||
if (!resource) {
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...line,
|
|
||||||
chapter: resource.chapter ?? line.chapter,
|
|
||||||
currency: resource.currency,
|
|
||||||
costRate: (resource.lcrCents / 100).toFixed(2),
|
|
||||||
billRate: (resource.ucrCents / 100).toFixed(2),
|
|
||||||
costRateMode: "resource",
|
|
||||||
billRateMode: "resource",
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setDemandLineRateMode(
|
|
||||||
index: number,
|
|
||||||
rateField: "costRateMode" | "billRateMode",
|
|
||||||
nextMode: EstimateDemandLineRateMode,
|
|
||||||
) {
|
|
||||||
updateDemandLine(index, (line) => {
|
|
||||||
const resourceSnapshot = getLineResourceSnapshot(line);
|
|
||||||
|
|
||||||
if (!resourceSnapshot || nextMode === "manual") {
|
|
||||||
return {
|
|
||||||
...line,
|
|
||||||
[rateField]: "manual",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...line,
|
|
||||||
[rateField]: "resource",
|
|
||||||
...(rateField === "costRateMode"
|
|
||||||
? { costRate: (resourceSnapshot.lcrCents / 100).toFixed(2) }
|
|
||||||
: { billRate: (resourceSnapshot.ucrCents / 100).toFixed(2) }),
|
|
||||||
...(resourceSnapshot.currency ? { currency: resourceSnapshot.currency } : {}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncAllLiveLinkedLines() {
|
|
||||||
setDemandLines((current) =>
|
|
||||||
current.map((line) => {
|
|
||||||
const resourceSnapshot = getLineResourceSnapshot(line);
|
|
||||||
|
|
||||||
if (!resourceSnapshot) {
|
|
||||||
return line;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...line,
|
|
||||||
currency: resourceSnapshot.currency,
|
|
||||||
costRate:
|
|
||||||
line.costRateMode === "resource"
|
|
||||||
? (resourceSnapshot.lcrCents / 100).toFixed(2)
|
|
||||||
: line.costRate,
|
|
||||||
billRate:
|
|
||||||
line.billRateMode === "resource"
|
|
||||||
? (resourceSnapshot.ucrCents / 100).toFixed(2)
|
|
||||||
: line.billRate,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const projectStartDate = estimate.project?.startDate
|
const projectStartDate = estimate.project?.startDate
|
||||||
? new Date(estimate.project.startDate)
|
? new Date(estimate.project.startDate)
|
||||||
: null;
|
: null;
|
||||||
@@ -520,41 +321,14 @@ export function EstimateWorkspaceDraftEditor({
|
|||||||
}).spread;
|
}).spread;
|
||||||
}
|
}
|
||||||
|
|
||||||
const spreadMonths = useMemo(
|
const spreadMonths =
|
||||||
() =>
|
|
||||||
hasProjectDates
|
hasProjectDates
|
||||||
? getEstimateMonthRange(projectStartDate, projectEndDate)
|
? getEstimateMonthRange(projectStartDate, projectEndDate)
|
||||||
: [],
|
: [];
|
||||||
[hasProjectDates, projectStartDate, projectEndDate],
|
|
||||||
);
|
|
||||||
|
|
||||||
const aggregatedSpread = useMemo(() => {
|
const aggregatedSpread = hasProjectDates
|
||||||
if (!hasProjectDates) return {};
|
? summarizeMonthlySpread(demandLines.map(computeLineSpread))
|
||||||
return summarizeMonthlySpread(demandLines.map(computeLineSpread));
|
: {};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [hasProjectDates, demandLines, projectStartDate, projectEndDate]);
|
|
||||||
|
|
||||||
function toggleMonthLock(lineIndex: number, monthKey: string, currentValue: number) {
|
|
||||||
updateDemandLine(lineIndex, (line) => {
|
|
||||||
const isLocked = monthKey in line.lockedMonths;
|
|
||||||
if (isLocked) {
|
|
||||||
const { [monthKey]: _removed, ...rest } = line.lockedMonths;
|
|
||||||
return { ...line, lockedMonths: rest };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...line,
|
|
||||||
lockedMonths: { ...line.lockedMonths, [monthKey]: currentValue },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function setLockedMonthValue(lineIndex: number, monthKey: string, value: string) {
|
|
||||||
const numValue = Math.max(0, toNumber(value));
|
|
||||||
updateDemandLine(lineIndex, (line) => ({
|
|
||||||
...line,
|
|
||||||
lockedMonths: { ...line.lockedMonths, [monthKey]: Math.round(numValue * 10) / 10 },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -749,429 +523,6 @@ export function EstimateWorkspaceDraftEditor({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAssumptionsEditor() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{assumptions.map((assumption, index) => (
|
|
||||||
<div key={assumption.id ?? `new-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<label>
|
|
||||||
<span className={LABEL_CLS}>Category</span>
|
|
||||||
<input className={INPUT_CLS} value={assumption.category} onChange={(event) => setAssumptions((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, category: event.target.value } : item))} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span className={LABEL_CLS}>Key</span>
|
|
||||||
<input className={INPUT_CLS} value={assumption.key} onChange={(event) => setAssumptions((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, key: event.target.value } : item))} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span className={LABEL_CLS}>Label</span>
|
|
||||||
<input className={INPUT_CLS} value={assumption.label} onChange={(event) => setAssumptions((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value } : item))} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span className={LABEL_CLS}>Type</span>
|
|
||||||
<input className={INPUT_CLS} value={assumption.valueType} onChange={(event) => setAssumptions((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, valueType: event.target.value } : item))} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr),220px]">
|
|
||||||
<label className="block">
|
|
||||||
<span className={LABEL_CLS}>Value</span>
|
|
||||||
<textarea className={`${INPUT_CLS} min-h-24`} value={assumption.value} onChange={(event) => setAssumptions((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} />
|
|
||||||
</label>
|
|
||||||
<label className="block">
|
|
||||||
<span className={LABEL_CLS}>Notes</span>
|
|
||||||
<textarea className={`${INPUT_CLS} min-h-24`} value={assumption.notes} onChange={(event) => setAssumptions((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, notes: event.target.value } : item))} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex justify-end">
|
|
||||||
<button type="button" className="text-sm font-medium text-rose-600" onClick={() => setAssumptions((current) => current.filter((_, itemIndex) => itemIndex !== index))}>
|
|
||||||
Remove assumption
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<button type="button" className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600" onClick={() => setAssumptions((current) => [...current, makeAssumption()])}>
|
|
||||||
Add assumption
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleScopeImport(event: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file || !isSpreadsheetFile(file)) return;
|
|
||||||
event.target.value = "";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await parseScopeImport(file);
|
|
||||||
setScopeImportWarnings(result.warnings);
|
|
||||||
if (result.rows.length > 0) {
|
|
||||||
const imported: EditableScopeItem[] = result.rows.map((row) => ({
|
|
||||||
sequenceNo: String(row.sequenceNo),
|
|
||||||
scopeType: row.scopeType,
|
|
||||||
packageCode: row.packageCode,
|
|
||||||
name: row.name,
|
|
||||||
description: row.description,
|
|
||||||
}));
|
|
||||||
setScopeItems((current) => [...current, ...imported]);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setScopeImportWarnings(["Failed to parse the uploaded file."]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderScopeEditor() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<label className="cursor-pointer rounded-2xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50">
|
|
||||||
Import scope from XLSX
|
|
||||||
<input type="file" accept=".xlsx,.xls,.csv" className="hidden" onChange={(event) => void handleScopeImport(event)} />
|
|
||||||
</label>
|
|
||||||
{scopeImportWarnings.length > 0 && (
|
|
||||||
<div className="text-xs text-amber-700">
|
|
||||||
{scopeImportWarnings.map((warning, index) => (
|
|
||||||
<p key={index}>{warning}</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{scopeItems.map((item, index) => (
|
|
||||||
<div key={item.id ?? `scope-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
|
||||||
<label>
|
|
||||||
<span className={LABEL_CLS}>Sequence</span>
|
|
||||||
<input className={INPUT_CLS} value={item.sequenceNo} onChange={(event) => setScopeItems((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, sequenceNo: event.target.value } : entry))} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span className={LABEL_CLS}>Scope type</span>
|
|
||||||
<input className={INPUT_CLS} value={item.scopeType} onChange={(event) => setScopeItems((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, scopeType: event.target.value } : entry))} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span className={LABEL_CLS}>Package code</span>
|
|
||||||
<input className={INPUT_CLS} value={item.packageCode} onChange={(event) => setScopeItems((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, packageCode: event.target.value } : entry))} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,0.9fr),minmax(0,1.1fr)]">
|
|
||||||
<label>
|
|
||||||
<span className={LABEL_CLS}>Name</span>
|
|
||||||
<input className={INPUT_CLS} value={item.name} onChange={(event) => setScopeItems((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, name: event.target.value } : entry))} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span className={LABEL_CLS}>Description</span>
|
|
||||||
<textarea className={`${INPUT_CLS} min-h-24`} value={item.description} onChange={(event) => setScopeItems((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, description: event.target.value } : entry))} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 flex justify-end">
|
|
||||||
<button type="button" className="text-sm font-medium text-rose-600" onClick={() => setScopeItems((current) => current.filter((_, entryIndex) => entryIndex !== index))}>
|
|
||||||
Remove scope item
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<button type="button" className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600" onClick={() => setScopeItems((current) => [...current, makeScopeItem(current.length + 1)])}>
|
|
||||||
Add scope item
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStaffingEditor() {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-2xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700"
|
|
||||||
onClick={syncAllLiveLinkedLines}
|
|
||||||
>
|
|
||||||
Recalculate live-linked lines
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{demandLines.map((line, index) => {
|
|
||||||
const linkedResource = line.resourceId ? getLineResourceSnapshot(line) : null;
|
|
||||||
const effectiveValues = getLineEffectiveValues(line);
|
|
||||||
const costDeltaCents =
|
|
||||||
linkedResource != null ? toCents(line.costRate) - linkedResource.lcrCents : 0;
|
|
||||||
const billDeltaCents =
|
|
||||||
linkedResource != null ? toCents(line.billRate) - linkedResource.ucrCents : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={line.id ?? `line-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
|
||||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">Resource link</p>
|
|
||||||
<p className="mt-1 text-sm text-gray-700">
|
|
||||||
{linkedResource
|
|
||||||
? `${linkedResource.displayName} (${("eid" in linkedResource ? linkedResource.eid : linkedResource.sourceEid) ?? "snapshot"})`
|
|
||||||
: "This demand line is currently unlinked."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{linkedResource && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
line.costRateMode === "resource" && line.billRateMode === "resource"
|
|
||||||
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700"
|
|
||||||
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{line.costRateMode === "resource" && line.billRateMode === "resource"
|
|
||||||
? "Live rates synced"
|
|
||||||
: "Manual override active"}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700"
|
|
||||||
onClick={() => syncDemandLineRates(index)}
|
|
||||||
>
|
|
||||||
Apply current resource rates
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4 grid gap-4 md:grid-cols-2">
|
|
||||||
<label>
|
|
||||||
<span className={LABEL_CLS}>Linked resource</span>
|
|
||||||
<select
|
|
||||||
className={INPUT_CLS}
|
|
||||||
value={line.resourceId ?? ""}
|
|
||||||
onChange={(event) => applyResourceSelection(index, event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="">Unlinked</option>
|
|
||||||
{resourceOptions.map((resource) => (
|
|
||||||
<option key={resource.id} value={resource.id}>
|
|
||||||
{resource.displayName} ({resource.eid})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<div className="rounded-2xl bg-gray-50 px-4 py-3">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-gray-400">Snapshot behavior</p>
|
|
||||||
<p className="mt-1 text-sm text-gray-700">
|
|
||||||
Linked resources refresh from live Planarchy rates when a rate is set to live mode. Manual overrides are persisted on the demand line.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
||||||
<label>
|
|
||||||
<span className={LABEL_CLS}>Name</span>
|
|
||||||
<input className={INPUT_CLS} value={line.name} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, name: event.target.value }))} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span className={LABEL_CLS}>Line type</span>
|
|
||||||
<input className={INPUT_CLS} value={line.lineType} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, lineType: event.target.value }))} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span className={LABEL_CLS}>Chapter</span>
|
|
||||||
<input className={INPUT_CLS} value={line.chapter} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, chapter: event.target.value }))} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span className={LABEL_CLS}>Hours</span>
|
|
||||||
<input className={INPUT_CLS} value={line.hours} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, hours: event.target.value }))} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span className={LABEL_CLS}>Currency</span>
|
|
||||||
<input className={INPUT_CLS} maxLength={3} value={line.currency} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, currency: event.target.value.toUpperCase() }))} />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span className={LABEL_CLS}>Cost rate</span>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<select
|
|
||||||
className={INPUT_CLS}
|
|
||||||
value={line.costRateMode}
|
|
||||||
onChange={(event) =>
|
|
||||||
setDemandLineRateMode(index, "costRateMode", event.target.value as EstimateDemandLineRateMode)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{getLineResourceSnapshot(line) && <option value="resource">Use live resource rate</option>}
|
|
||||||
<option value="manual">Manual override</option>
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
className={INPUT_CLS}
|
|
||||||
value={line.costRate}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateDemandLine(index, (entry) => ({
|
|
||||||
...entry,
|
|
||||||
costRateMode: "manual",
|
|
||||||
costRate: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{linkedResource && (
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Live snapshot {formatMoney(linkedResource.lcrCents, linkedResource.currency)}
|
|
||||||
{line.costRateMode === "manual" && costDeltaCents !== 0
|
|
||||||
? ` (${costDeltaCents > 0 ? "+" : ""}${formatMoney(costDeltaCents, linkedResource.currency)} delta)`
|
|
||||||
: ""}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span className={LABEL_CLS}>Bill rate</span>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<select
|
|
||||||
className={INPUT_CLS}
|
|
||||||
value={line.billRateMode}
|
|
||||||
onChange={(event) =>
|
|
||||||
setDemandLineRateMode(index, "billRateMode", event.target.value as EstimateDemandLineRateMode)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{getLineResourceSnapshot(line) && <option value="resource">Use live resource rate</option>}
|
|
||||||
<option value="manual">Manual override</option>
|
|
||||||
</select>
|
|
||||||
<input
|
|
||||||
className={INPUT_CLS}
|
|
||||||
value={line.billRate}
|
|
||||||
onChange={(event) =>
|
|
||||||
updateDemandLine(index, (entry) => ({
|
|
||||||
...entry,
|
|
||||||
billRateMode: "manual",
|
|
||||||
billRate: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{linkedResource && (
|
|
||||||
<p className="text-xs text-gray-500">
|
|
||||||
Live snapshot {formatMoney(linkedResource.ucrCents, linkedResource.currency)}
|
|
||||||
{line.billRateMode === "manual" && billDeltaCents !== 0
|
|
||||||
? ` (${billDeltaCents > 0 ? "+" : ""}${formatMoney(billDeltaCents, linkedResource.currency)} delta)`
|
|
||||||
: ""}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
<div className="rounded-2xl bg-gray-50 px-4 py-3">
|
|
||||||
<p className="text-xs uppercase tracking-wide text-gray-400">Cost total</p>
|
|
||||||
<p className="mt-1 text-sm font-semibold text-gray-900">
|
|
||||||
{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}
|
|
||||||
</p>
|
|
||||||
<p className="mt-3 text-xs uppercase tracking-wide text-gray-400">Price total</p>
|
|
||||||
<p className="mt-1 text-sm font-semibold text-gray-900">
|
|
||||||
{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{hasProjectDates && spreadMonths.length > 0 && (
|
|
||||||
<div className="mt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center gap-1.5 text-xs font-medium text-gray-600"
|
|
||||||
onClick={() => updateDemandLine(index, (entry) => ({ ...entry, spreadExpanded: !entry.spreadExpanded }))}
|
|
||||||
>
|
|
||||||
<span className={`inline-block transition-transform ${line.spreadExpanded ? "rotate-90" : ""}`}>▶</span>
|
|
||||||
Monthly phasing ({spreadMonths.length} months)
|
|
||||||
</button>
|
|
||||||
{line.spreadExpanded && (() => {
|
|
||||||
const lineSpread = computeLineSpread(line);
|
|
||||||
return (
|
|
||||||
<div className="mt-3 overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-gray-200">
|
|
||||||
<th className="px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-400">Month</th>
|
|
||||||
<th className="px-2 py-1.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-400">Hours</th>
|
|
||||||
<th className="px-2 py-1.5 text-center text-xs font-semibold uppercase tracking-wide text-gray-400">Lock</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{spreadMonths.map((monthKey) => {
|
|
||||||
const isLocked = monthKey in line.lockedMonths;
|
|
||||||
const value = lineSpread[monthKey] ?? 0;
|
|
||||||
return (
|
|
||||||
<tr key={monthKey} className="border-b border-gray-100">
|
|
||||||
<td className="px-2 py-1.5 text-gray-700">{monthKey}</td>
|
|
||||||
<td className="px-2 py-1.5 text-right">
|
|
||||||
{isLocked ? (
|
|
||||||
<input
|
|
||||||
className="w-20 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-right text-sm text-gray-900"
|
|
||||||
value={line.lockedMonths[monthKey]}
|
|
||||||
onChange={(event) => setLockedMonthValue(index, monthKey, event.target.value)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span className="text-gray-700">{value.toFixed(1)}</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5 text-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`rounded px-2 py-0.5 text-xs font-medium ${isLocked ? "bg-amber-100 text-amber-700" : "bg-gray-100 text-gray-500"}`}
|
|
||||||
onClick={() => toggleMonthLock(index, monthKey, value)}
|
|
||||||
>
|
|
||||||
{isLocked ? "Locked" : "Auto"}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr className="border-t border-gray-300">
|
|
||||||
<td className="px-2 py-1.5 text-xs font-semibold uppercase text-gray-500">Total</td>
|
|
||||||
<td className="px-2 py-1.5 text-right text-sm font-semibold text-gray-900">
|
|
||||||
{Object.values(lineSpread).reduce((a, b) => a + b, 0).toFixed(1)}
|
|
||||||
</td>
|
|
||||||
<td />
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="mt-4 flex justify-end">
|
|
||||||
<button type="button" className="text-sm font-medium text-rose-600" onClick={() => setDemandLines((current) => current.filter((_, entryIndex) => entryIndex !== index))}>
|
|
||||||
Remove demand line
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<button type="button" className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600" onClick={() => setDemandLines((current) => [...current, makeDemandLine()])}>
|
|
||||||
Add demand line
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{hasProjectDates && spreadMonths.length > 0 && demandLines.length > 0 && (
|
|
||||||
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
|
||||||
<p className="mb-3 text-sm font-semibold text-gray-900">Aggregated monthly phasing</p>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-gray-200">
|
|
||||||
<th className="px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-400">Month</th>
|
|
||||||
<th className="px-2 py-1.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-400">Total hours</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{spreadMonths.map((monthKey) => (
|
|
||||||
<tr key={monthKey} className="border-b border-gray-100">
|
|
||||||
<td className="px-2 py-1.5 text-gray-700">{monthKey}</td>
|
|
||||||
<td className="px-2 py-1.5 text-right text-gray-900">{(aggregatedSpread[monthKey] ?? 0).toFixed(1)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
<tfoot>
|
|
||||||
<tr className="border-t border-gray-300">
|
|
||||||
<td className="px-2 py-1.5 text-xs font-semibold uppercase text-gray-500">Grand total</td>
|
|
||||||
<td className="px-2 py-1.5 text-right text-sm font-semibold text-gray-900">
|
|
||||||
{Object.values(aggregatedSpread).reduce((a, b) => a + b, 0).toFixed(1)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-brand-200 bg-brand-50 px-5 py-4">
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-3xl border border-brand-200 bg-brand-50 px-5 py-4">
|
||||||
@@ -1196,9 +547,34 @@ export function EstimateWorkspaceDraftEditor({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "overview" && renderOverviewEditor()}
|
{tab === "overview" && renderOverviewEditor()}
|
||||||
{tab === "assumptions" && renderAssumptionsEditor()}
|
{tab === "assumptions" && (
|
||||||
{tab === "scope" && renderScopeEditor()}
|
<AssumptionEditor
|
||||||
{tab === "staffing" && renderStaffingEditor()}
|
assumptions={assumptions}
|
||||||
|
onChange={setAssumptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === "scope" && (
|
||||||
|
<ScopeItemEditor
|
||||||
|
scopeItems={scopeItems}
|
||||||
|
onChange={setScopeItems}
|
||||||
|
scopeImportWarnings={scopeImportWarnings}
|
||||||
|
onScopeImportWarnings={setScopeImportWarnings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === "staffing" && (
|
||||||
|
<DemandLineEditor
|
||||||
|
demandLines={demandLines}
|
||||||
|
onChange={setDemandLines}
|
||||||
|
resourceOptions={resourceOptions}
|
||||||
|
resourceMap={resourceMap}
|
||||||
|
snapshotByResourceId={snapshotByResourceId}
|
||||||
|
baseCurrency={baseCurrency}
|
||||||
|
projectStartDate={projectStartDate}
|
||||||
|
projectEndDate={projectEndDate}
|
||||||
|
spreadMonths={spreadMonths}
|
||||||
|
aggregatedSpread={aggregatedSpread}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{(tab === "versions" || tab === "exports") && renderEditorHint()}
|
{(tab === "versions" || tab === "exports") && renderEditorHint()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -10,14 +10,7 @@ import {
|
|||||||
type ScopeItemDiff,
|
type ScopeItemDiff,
|
||||||
} from "@planarchy/engine";
|
} from "@planarchy/engine";
|
||||||
import type { EstimateVersionView } from "~/components/estimates/EstimateWorkspace.types.js";
|
import type { EstimateVersionView } from "~/components/estimates/EstimateWorkspace.types.js";
|
||||||
|
import { formatMoney } from "~/lib/format.js";
|
||||||
function formatMoney(cents: number, currency = "EUR") {
|
|
||||||
return new Intl.NumberFormat("de-DE", {
|
|
||||||
style: "currency",
|
|
||||||
currency,
|
|
||||||
maximumFractionDigits: 0,
|
|
||||||
}).format(cents / 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDelta(value: number, formatter: (v: number) => string) {
|
function formatDelta(value: number, formatter: (v: number) => string) {
|
||||||
const prefix = value > 0 ? "+" : "";
|
const prefix = value > 0 ? "+" : "";
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
const INPUT_CLS =
|
||||||
|
"w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100";
|
||||||
|
const LABEL_CLS = "mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500";
|
||||||
|
|
||||||
|
export interface EditableAssumption {
|
||||||
|
id?: string;
|
||||||
|
category: string;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
valueType: string;
|
||||||
|
value: string;
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeAssumption(): EditableAssumption {
|
||||||
|
return {
|
||||||
|
category: "commercial",
|
||||||
|
key: "",
|
||||||
|
label: "",
|
||||||
|
valueType: "string",
|
||||||
|
value: "",
|
||||||
|
notes: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AssumptionEditorProps {
|
||||||
|
assumptions: EditableAssumption[];
|
||||||
|
onChange: (updater: (current: EditableAssumption[]) => EditableAssumption[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssumptionEditor({ assumptions, onChange }: AssumptionEditorProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{assumptions.map((assumption, index) => (
|
||||||
|
<div key={assumption.id ?? `new-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<label>
|
||||||
|
<span className={LABEL_CLS}>Category</span>
|
||||||
|
<input className={INPUT_CLS} value={assumption.category} onChange={(event) => onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, category: event.target.value } : item))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className={LABEL_CLS}>Key</span>
|
||||||
|
<input className={INPUT_CLS} value={assumption.key} onChange={(event) => onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, key: event.target.value } : item))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className={LABEL_CLS}>Label</span>
|
||||||
|
<input className={INPUT_CLS} value={assumption.label} onChange={(event) => onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value } : item))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className={LABEL_CLS}>Type</span>
|
||||||
|
<input className={INPUT_CLS} value={assumption.valueType} onChange={(event) => onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, valueType: event.target.value } : item))} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,1fr),220px]">
|
||||||
|
<label className="block">
|
||||||
|
<span className={LABEL_CLS}>Value</span>
|
||||||
|
<textarea className={`${INPUT_CLS} min-h-24`} value={assumption.value} onChange={(event) => onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, value: event.target.value } : item))} />
|
||||||
|
</label>
|
||||||
|
<label className="block">
|
||||||
|
<span className={LABEL_CLS}>Notes</span>
|
||||||
|
<textarea className={`${INPUT_CLS} min-h-24`} value={assumption.notes} onChange={(event) => onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, notes: event.target.value } : item))} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button type="button" className="text-sm font-medium text-rose-600" onClick={() => onChange((current) => current.filter((_, itemIndex) => itemIndex !== index))}>
|
||||||
|
Remove assumption
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button type="button" className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600" onClick={() => onChange((current) => [...current, makeAssumption()])}>
|
||||||
|
Add assumption
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import type { EstimateDemandLineRateMode } from "@planarchy/shared";
|
||||||
|
import {
|
||||||
|
computeEvenSpread,
|
||||||
|
rebalanceSpread,
|
||||||
|
} from "@planarchy/engine";
|
||||||
|
import {
|
||||||
|
getEffectiveDemandLineValues,
|
||||||
|
} from "~/components/estimates/EstimateWorkspace.calculations.js";
|
||||||
|
import type { EstimateResourceSnapshotView } from "~/components/estimates/EstimateWorkspace.types.js";
|
||||||
|
import { formatMoney } from "~/lib/format.js";
|
||||||
|
|
||||||
|
const INPUT_CLS =
|
||||||
|
"w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100";
|
||||||
|
const LABEL_CLS = "mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500";
|
||||||
|
|
||||||
|
function toNumber(value: string) {
|
||||||
|
const parsed = Number.parseFloat(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCents(value: string) {
|
||||||
|
return Math.round(toNumber(value) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EditableDemandLine {
|
||||||
|
id?: string;
|
||||||
|
scopeItemId?: string;
|
||||||
|
roleId?: string;
|
||||||
|
resourceId?: string;
|
||||||
|
lineType: string;
|
||||||
|
name: string;
|
||||||
|
chapter: string;
|
||||||
|
hours: string;
|
||||||
|
currency: string;
|
||||||
|
costRate: string;
|
||||||
|
billRate: string;
|
||||||
|
costRateMode: EstimateDemandLineRateMode;
|
||||||
|
billRateMode: EstimateDemandLineRateMode;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
/** Locked monthly hours overrides, keyed by "YYYY-MM" */
|
||||||
|
lockedMonths: Record<string, number>;
|
||||||
|
/** Whether the monthly spread section is expanded */
|
||||||
|
spreadExpanded: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeDemandLine(): EditableDemandLine {
|
||||||
|
return {
|
||||||
|
lineType: "LABOR",
|
||||||
|
name: "",
|
||||||
|
chapter: "",
|
||||||
|
hours: "8",
|
||||||
|
currency: "EUR",
|
||||||
|
costRate: "0",
|
||||||
|
billRate: "0",
|
||||||
|
costRateMode: "manual",
|
||||||
|
billRateMode: "manual",
|
||||||
|
metadata: {},
|
||||||
|
lockedMonths: {},
|
||||||
|
spreadExpanded: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourceOption {
|
||||||
|
id: string;
|
||||||
|
eid: string;
|
||||||
|
displayName: string;
|
||||||
|
chapter?: string | null;
|
||||||
|
roleId?: string | null;
|
||||||
|
lcrCents: number;
|
||||||
|
ucrCents: number;
|
||||||
|
currency: string;
|
||||||
|
dynamicFields?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DemandLineEditorProps {
|
||||||
|
demandLines: EditableDemandLine[];
|
||||||
|
onChange: (updater: (current: EditableDemandLine[]) => EditableDemandLine[]) => void;
|
||||||
|
resourceOptions: ResourceOption[];
|
||||||
|
resourceMap: Map<string, ResourceOption>;
|
||||||
|
snapshotByResourceId: Map<string, EstimateResourceSnapshotView>;
|
||||||
|
baseCurrency: string;
|
||||||
|
projectStartDate: Date | null;
|
||||||
|
projectEndDate: Date | null;
|
||||||
|
spreadMonths: string[];
|
||||||
|
aggregatedSpread: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResourceSnapshotLike = ResourceOption | EstimateResourceSnapshotView;
|
||||||
|
|
||||||
|
export function DemandLineEditor({
|
||||||
|
demandLines,
|
||||||
|
onChange,
|
||||||
|
resourceOptions,
|
||||||
|
resourceMap,
|
||||||
|
snapshotByResourceId,
|
||||||
|
baseCurrency,
|
||||||
|
projectStartDate,
|
||||||
|
projectEndDate,
|
||||||
|
spreadMonths,
|
||||||
|
aggregatedSpread,
|
||||||
|
}: DemandLineEditorProps) {
|
||||||
|
const hasProjectDates = projectStartDate !== null && projectEndDate !== null;
|
||||||
|
|
||||||
|
function getLineResourceSnapshot(line: EditableDemandLine): ResourceSnapshotLike | null {
|
||||||
|
if (!line.resourceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return resourceMap.get(line.resourceId) ?? snapshotByResourceId.get(line.resourceId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLineEffectiveValues(line: EditableDemandLine) {
|
||||||
|
return getEffectiveDemandLineValues({
|
||||||
|
resourceSnapshot: getLineResourceSnapshot(line),
|
||||||
|
hours: toNumber(line.hours),
|
||||||
|
currency: line.currency,
|
||||||
|
defaultCurrency: baseCurrency,
|
||||||
|
costRateCents: toCents(line.costRate),
|
||||||
|
billRateCents: toCents(line.billRate),
|
||||||
|
costRateMode: line.costRateMode,
|
||||||
|
billRateMode: line.billRateMode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDemandLine(index: number, updater: (line: EditableDemandLine) => EditableDemandLine) {
|
||||||
|
onChange((current) =>
|
||||||
|
current.map((entry, entryIndex) => (entryIndex === index ? updater(entry) : entry)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyResourceSelection(index: number, nextResourceId: string) {
|
||||||
|
updateDemandLine(index, (line) => {
|
||||||
|
if (!nextResourceId) {
|
||||||
|
const { resourceId: _resourceId, ...unlinkedLine } = line;
|
||||||
|
return {
|
||||||
|
...unlinkedLine,
|
||||||
|
costRateMode: "manual",
|
||||||
|
billRateMode: "manual",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = resourceMap.get(nextResourceId);
|
||||||
|
if (!resource) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...line,
|
||||||
|
resourceId: resource.id,
|
||||||
|
...(resource.roleId ? { roleId: resource.roleId } : {}),
|
||||||
|
chapter: resource.chapter ?? line.chapter,
|
||||||
|
currency: resource.currency,
|
||||||
|
costRate: (resource.lcrCents / 100).toFixed(2),
|
||||||
|
billRate: (resource.ucrCents / 100).toFixed(2),
|
||||||
|
costRateMode: "resource",
|
||||||
|
billRateMode: "resource",
|
||||||
|
name: line.name.trim() ? line.name : resource.displayName,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDemandLineRates(index: number) {
|
||||||
|
updateDemandLine(index, (line) => {
|
||||||
|
if (!line.resourceId) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = resourceMap.get(line.resourceId);
|
||||||
|
if (!resource) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...line,
|
||||||
|
chapter: resource.chapter ?? line.chapter,
|
||||||
|
currency: resource.currency,
|
||||||
|
costRate: (resource.lcrCents / 100).toFixed(2),
|
||||||
|
billRate: (resource.ucrCents / 100).toFixed(2),
|
||||||
|
costRateMode: "resource",
|
||||||
|
billRateMode: "resource",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDemandLineRateMode(
|
||||||
|
index: number,
|
||||||
|
rateField: "costRateMode" | "billRateMode",
|
||||||
|
nextMode: EstimateDemandLineRateMode,
|
||||||
|
) {
|
||||||
|
updateDemandLine(index, (line) => {
|
||||||
|
const resourceSnapshot = getLineResourceSnapshot(line);
|
||||||
|
|
||||||
|
if (!resourceSnapshot || nextMode === "manual") {
|
||||||
|
return {
|
||||||
|
...line,
|
||||||
|
[rateField]: "manual",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...line,
|
||||||
|
[rateField]: "resource",
|
||||||
|
...(rateField === "costRateMode"
|
||||||
|
? { costRate: (resourceSnapshot.lcrCents / 100).toFixed(2) }
|
||||||
|
: { billRate: (resourceSnapshot.ucrCents / 100).toFixed(2) }),
|
||||||
|
...(resourceSnapshot.currency ? { currency: resourceSnapshot.currency } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncAllLiveLinkedLines() {
|
||||||
|
onChange((current) =>
|
||||||
|
current.map((line) => {
|
||||||
|
const resourceSnapshot = getLineResourceSnapshot(line);
|
||||||
|
|
||||||
|
if (!resourceSnapshot) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...line,
|
||||||
|
currency: resourceSnapshot.currency,
|
||||||
|
costRate:
|
||||||
|
line.costRateMode === "resource"
|
||||||
|
? (resourceSnapshot.lcrCents / 100).toFixed(2)
|
||||||
|
: line.costRate,
|
||||||
|
billRate:
|
||||||
|
line.billRateMode === "resource"
|
||||||
|
? (resourceSnapshot.ucrCents / 100).toFixed(2)
|
||||||
|
: line.billRate,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeLineSpread(line: EditableDemandLine): Record<string, number> {
|
||||||
|
if (!hasProjectDates) return {};
|
||||||
|
const hours = toNumber(line.hours);
|
||||||
|
if (hours <= 0) return {};
|
||||||
|
|
||||||
|
const lockedKeys = Object.keys(line.lockedMonths);
|
||||||
|
if (lockedKeys.length > 0) {
|
||||||
|
return rebalanceSpread({
|
||||||
|
totalHours: hours,
|
||||||
|
startDate: projectStartDate,
|
||||||
|
endDate: projectEndDate,
|
||||||
|
lockedMonths: line.lockedMonths,
|
||||||
|
}).spread;
|
||||||
|
}
|
||||||
|
|
||||||
|
return computeEvenSpread({
|
||||||
|
totalHours: hours,
|
||||||
|
startDate: projectStartDate,
|
||||||
|
endDate: projectEndDate,
|
||||||
|
}).spread;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMonthLock(lineIndex: number, monthKey: string, currentValue: number) {
|
||||||
|
updateDemandLine(lineIndex, (line) => {
|
||||||
|
const isLocked = monthKey in line.lockedMonths;
|
||||||
|
if (isLocked) {
|
||||||
|
const { [monthKey]: _removed, ...rest } = line.lockedMonths;
|
||||||
|
return { ...line, lockedMonths: rest };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...line,
|
||||||
|
lockedMonths: { ...line.lockedMonths, [monthKey]: currentValue },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLockedMonthValue(lineIndex: number, monthKey: string, value: string) {
|
||||||
|
const numValue = Math.max(0, toNumber(value));
|
||||||
|
updateDemandLine(lineIndex, (line) => ({
|
||||||
|
...line,
|
||||||
|
lockedMonths: { ...line.lockedMonths, [monthKey]: Math.round(numValue * 10) / 10 },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-2xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700"
|
||||||
|
onClick={syncAllLiveLinkedLines}
|
||||||
|
>
|
||||||
|
Recalculate live-linked lines
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{demandLines.map((line, index) => {
|
||||||
|
const linkedResource = line.resourceId ? getLineResourceSnapshot(line) : null;
|
||||||
|
const effectiveValues = getLineEffectiveValues(line);
|
||||||
|
const costDeltaCents =
|
||||||
|
linkedResource != null ? toCents(line.costRate) - linkedResource.lcrCents : 0;
|
||||||
|
const billDeltaCents =
|
||||||
|
linkedResource != null ? toCents(line.billRate) - linkedResource.ucrCents : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={line.id ?? `line-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-gray-500">Resource link</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-700">
|
||||||
|
{linkedResource
|
||||||
|
? `${linkedResource.displayName} (${("eid" in linkedResource ? linkedResource.eid : (linkedResource as EstimateResourceSnapshotView).sourceEid) ?? "snapshot"})`
|
||||||
|
: "This demand line is currently unlinked."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{linkedResource && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
line.costRateMode === "resource" && line.billRateMode === "resource"
|
||||||
|
? "rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700"
|
||||||
|
: "rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{line.costRateMode === "resource" && line.billRateMode === "resource"
|
||||||
|
? "Live rates synced"
|
||||||
|
: "Manual override active"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-xl border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700"
|
||||||
|
onClick={() => syncDemandLineRates(index)}
|
||||||
|
>
|
||||||
|
Apply current resource rates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 grid gap-4 md:grid-cols-2">
|
||||||
|
<label>
|
||||||
|
<span className={LABEL_CLS}>Linked resource</span>
|
||||||
|
<select
|
||||||
|
className={INPUT_CLS}
|
||||||
|
value={line.resourceId ?? ""}
|
||||||
|
onChange={(event) => applyResourceSelection(index, event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Unlinked</option>
|
||||||
|
{resourceOptions.map((resource) => (
|
||||||
|
<option key={resource.id} value={resource.id}>
|
||||||
|
{resource.displayName} ({resource.eid})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="rounded-2xl bg-gray-50 px-4 py-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">Snapshot behavior</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-700">
|
||||||
|
Linked resources refresh from live Planarchy rates when a rate is set to live mode. Manual overrides are persisted on the demand line.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<label>
|
||||||
|
<span className={LABEL_CLS}>Name</span>
|
||||||
|
<input className={INPUT_CLS} value={line.name} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, name: event.target.value }))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className={LABEL_CLS}>Line type</span>
|
||||||
|
<input className={INPUT_CLS} value={line.lineType} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, lineType: event.target.value }))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className={LABEL_CLS}>Chapter</span>
|
||||||
|
<input className={INPUT_CLS} value={line.chapter} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, chapter: event.target.value }))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className={LABEL_CLS}>Hours</span>
|
||||||
|
<input className={INPUT_CLS} value={line.hours} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, hours: event.target.value }))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className={LABEL_CLS}>Currency</span>
|
||||||
|
<input className={INPUT_CLS} maxLength={3} value={line.currency} onChange={(event) => updateDemandLine(index, (entry) => ({ ...entry, currency: event.target.value.toUpperCase() }))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className={LABEL_CLS}>Cost rate</span>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<select
|
||||||
|
className={INPUT_CLS}
|
||||||
|
value={line.costRateMode}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDemandLineRateMode(index, "costRateMode", event.target.value as EstimateDemandLineRateMode)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getLineResourceSnapshot(line) && <option value="resource">Use live resource rate</option>}
|
||||||
|
<option value="manual">Manual override</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className={INPUT_CLS}
|
||||||
|
value={line.costRate}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateDemandLine(index, (entry) => ({
|
||||||
|
...entry,
|
||||||
|
costRateMode: "manual",
|
||||||
|
costRate: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{linkedResource && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Live snapshot {formatMoney(linkedResource.lcrCents, linkedResource.currency)}
|
||||||
|
{line.costRateMode === "manual" && costDeltaCents !== 0
|
||||||
|
? ` (${costDeltaCents > 0 ? "+" : ""}${formatMoney(costDeltaCents, linkedResource.currency)} delta)`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className={LABEL_CLS}>Bill rate</span>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<select
|
||||||
|
className={INPUT_CLS}
|
||||||
|
value={line.billRateMode}
|
||||||
|
onChange={(event) =>
|
||||||
|
setDemandLineRateMode(index, "billRateMode", event.target.value as EstimateDemandLineRateMode)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getLineResourceSnapshot(line) && <option value="resource">Use live resource rate</option>}
|
||||||
|
<option value="manual">Manual override</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
className={INPUT_CLS}
|
||||||
|
value={line.billRate}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateDemandLine(index, (entry) => ({
|
||||||
|
...entry,
|
||||||
|
billRateMode: "manual",
|
||||||
|
billRate: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{linkedResource && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Live snapshot {formatMoney(linkedResource.ucrCents, linkedResource.currency)}
|
||||||
|
{line.billRateMode === "manual" && billDeltaCents !== 0
|
||||||
|
? ` (${billDeltaCents > 0 ? "+" : ""}${formatMoney(billDeltaCents, linkedResource.currency)} delta)`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<div className="rounded-2xl bg-gray-50 px-4 py-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">Cost total</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-gray-900">
|
||||||
|
{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-xs uppercase tracking-wide text-gray-400">Price total</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-gray-900">
|
||||||
|
{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasProjectDates && spreadMonths.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex items-center gap-1.5 text-xs font-medium text-gray-600"
|
||||||
|
onClick={() => updateDemandLine(index, (entry) => ({ ...entry, spreadExpanded: !entry.spreadExpanded }))}
|
||||||
|
>
|
||||||
|
<span className={`inline-block transition-transform ${line.spreadExpanded ? "rotate-90" : ""}`}>▶</span>
|
||||||
|
Monthly phasing ({spreadMonths.length} months)
|
||||||
|
</button>
|
||||||
|
{line.spreadExpanded && (() => {
|
||||||
|
const lineSpread = computeLineSpread(line);
|
||||||
|
return (
|
||||||
|
<div className="mt-3 overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-400">Month</th>
|
||||||
|
<th className="px-2 py-1.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-400">Hours</th>
|
||||||
|
<th className="px-2 py-1.5 text-center text-xs font-semibold uppercase tracking-wide text-gray-400">Lock</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{spreadMonths.map((monthKey) => {
|
||||||
|
const isLocked = monthKey in line.lockedMonths;
|
||||||
|
const value = lineSpread[monthKey] ?? 0;
|
||||||
|
return (
|
||||||
|
<tr key={monthKey} className="border-b border-gray-100">
|
||||||
|
<td className="px-2 py-1.5 text-gray-700">{monthKey}</td>
|
||||||
|
<td className="px-2 py-1.5 text-right">
|
||||||
|
{isLocked ? (
|
||||||
|
<input
|
||||||
|
className="w-20 rounded border border-amber-300 bg-amber-50 px-2 py-1 text-right text-sm text-gray-900"
|
||||||
|
value={line.lockedMonths[monthKey]}
|
||||||
|
onChange={(event) => setLockedMonthValue(index, monthKey, event.target.value)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-700">{value.toFixed(1)}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`rounded px-2 py-0.5 text-xs font-medium ${isLocked ? "bg-amber-100 text-amber-700" : "bg-gray-100 text-gray-500"}`}
|
||||||
|
onClick={() => toggleMonthLock(index, monthKey, value)}
|
||||||
|
>
|
||||||
|
{isLocked ? "Locked" : "Auto"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t border-gray-300">
|
||||||
|
<td className="px-2 py-1.5 text-xs font-semibold uppercase text-gray-500">Total</td>
|
||||||
|
<td className="px-2 py-1.5 text-right text-sm font-semibold text-gray-900">
|
||||||
|
{Object.values(lineSpread).reduce((a, b) => a + b, 0).toFixed(1)}
|
||||||
|
</td>
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button type="button" className="text-sm font-medium text-rose-600" onClick={() => onChange((current) => current.filter((_, entryIndex) => entryIndex !== index))}>
|
||||||
|
Remove demand line
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<button type="button" className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600" onClick={() => onChange((current) => [...current, makeDemandLine()])}>
|
||||||
|
Add demand line
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{hasProjectDates && spreadMonths.length > 0 && demandLines.length > 0 && (
|
||||||
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<p className="mb-3 text-sm font-semibold text-gray-900">Aggregated monthly phasing</p>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200">
|
||||||
|
<th className="px-2 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-gray-400">Month</th>
|
||||||
|
<th className="px-2 py-1.5 text-right text-xs font-semibold uppercase tracking-wide text-gray-400">Total hours</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{spreadMonths.map((monthKey) => (
|
||||||
|
<tr key={monthKey} className="border-b border-gray-100">
|
||||||
|
<td className="px-2 py-1.5 text-gray-700">{monthKey}</td>
|
||||||
|
<td className="px-2 py-1.5 text-right text-gray-900">{(aggregatedSpread[monthKey] ?? 0).toFixed(1)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t border-gray-300">
|
||||||
|
<td className="px-2 py-1.5 text-xs font-semibold uppercase text-gray-500">Grand total</td>
|
||||||
|
<td className="px-2 py-1.5 text-right text-sm font-semibold text-gray-900">
|
||||||
|
{Object.values(aggregatedSpread).reduce((a, b) => a + b, 0).toFixed(1)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { isSpreadsheetFile } from "~/lib/excel.js";
|
||||||
|
import { parseScopeImport } from "~/lib/scopeImportParser.js";
|
||||||
|
|
||||||
|
const INPUT_CLS =
|
||||||
|
"w-full rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-100";
|
||||||
|
const LABEL_CLS = "mb-1.5 block text-xs font-semibold uppercase tracking-wide text-gray-500";
|
||||||
|
|
||||||
|
export interface EditableScopeItem {
|
||||||
|
id?: string;
|
||||||
|
sequenceNo: string;
|
||||||
|
scopeType: string;
|
||||||
|
packageCode: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeScopeItem(sequenceNo: number): EditableScopeItem {
|
||||||
|
return {
|
||||||
|
sequenceNo: String(sequenceNo),
|
||||||
|
scopeType: "SHOT",
|
||||||
|
packageCode: "",
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScopeItemEditorProps {
|
||||||
|
scopeItems: EditableScopeItem[];
|
||||||
|
onChange: (updater: (current: EditableScopeItem[]) => EditableScopeItem[]) => void;
|
||||||
|
scopeImportWarnings: string[];
|
||||||
|
onScopeImportWarnings: (warnings: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScopeItemEditor({
|
||||||
|
scopeItems,
|
||||||
|
onChange,
|
||||||
|
scopeImportWarnings,
|
||||||
|
onScopeImportWarnings,
|
||||||
|
}: ScopeItemEditorProps) {
|
||||||
|
async function handleScopeImport(event: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file || !isSpreadsheetFile(file)) return;
|
||||||
|
event.target.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await parseScopeImport(file);
|
||||||
|
onScopeImportWarnings(result.warnings);
|
||||||
|
if (result.rows.length > 0) {
|
||||||
|
const imported: EditableScopeItem[] = result.rows.map((row) => ({
|
||||||
|
sequenceNo: String(row.sequenceNo),
|
||||||
|
scopeType: row.scopeType,
|
||||||
|
packageCode: row.packageCode,
|
||||||
|
name: row.name,
|
||||||
|
description: row.description,
|
||||||
|
}));
|
||||||
|
onChange((current) => [...current, ...imported]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
onScopeImportWarnings(["Failed to parse the uploaded file."]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label className="cursor-pointer rounded-2xl border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50">
|
||||||
|
Import scope from XLSX
|
||||||
|
<input type="file" accept=".xlsx,.xls,.csv" className="hidden" onChange={(event) => void handleScopeImport(event)} />
|
||||||
|
</label>
|
||||||
|
{scopeImportWarnings.length > 0 && (
|
||||||
|
<div className="text-xs text-amber-700">
|
||||||
|
{scopeImportWarnings.map((warning, index) => (
|
||||||
|
<p key={index}>{warning}</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{scopeItems.map((item, index) => (
|
||||||
|
<div key={item.id ?? `scope-${index}`} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<label>
|
||||||
|
<span className={LABEL_CLS}>Sequence</span>
|
||||||
|
<input className={INPUT_CLS} value={item.sequenceNo} onChange={(event) => onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, sequenceNo: event.target.value } : entry))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className={LABEL_CLS}>Scope type</span>
|
||||||
|
<input className={INPUT_CLS} value={item.scopeType} onChange={(event) => onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, scopeType: event.target.value } : entry))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className={LABEL_CLS}>Package code</span>
|
||||||
|
<input className={INPUT_CLS} value={item.packageCode} onChange={(event) => onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, packageCode: event.target.value } : entry))} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,0.9fr),minmax(0,1.1fr)]">
|
||||||
|
<label>
|
||||||
|
<span className={LABEL_CLS}>Name</span>
|
||||||
|
<input className={INPUT_CLS} value={item.name} onChange={(event) => onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, name: event.target.value } : entry))} />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span className={LABEL_CLS}>Description</span>
|
||||||
|
<textarea className={`${INPUT_CLS} min-h-24`} value={item.description} onChange={(event) => onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, description: event.target.value } : entry))} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button type="button" className="text-sm font-medium text-rose-600" onClick={() => onChange((current) => current.filter((_, entryIndex) => entryIndex !== index))}>
|
||||||
|
Remove scope item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button type="button" className="rounded-2xl border border-dashed border-gray-300 px-4 py-3 text-sm font-medium text-gray-600" onClick={() => onChange((current) => [...current, makeScopeItem(current.length + 1)])}>
|
||||||
|
Add scope item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
EstimateVersionView,
|
||||||
|
EstimateWorkspaceView,
|
||||||
|
} from "~/components/estimates/EstimateWorkspace.types.js";
|
||||||
|
|
||||||
|
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssumptionsTab({ estimate }: { estimate: EstimateWorkspaceView }) {
|
||||||
|
const versions = estimate.versions as EstimateVersionView[];
|
||||||
|
const latestVersion = versions[0] ?? null;
|
||||||
|
const assumptions = latestVersion?.assumptions ?? [];
|
||||||
|
|
||||||
|
if (assumptions.length === 0) {
|
||||||
|
return <EmptyState>No assumptions captured for the current version yet.</EmptyState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-gray-200 bg-white shadow-sm">
|
||||||
|
<div className="border-b border-gray-100 px-6 py-4">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Commercial and delivery assumptions</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{assumptions.map((assumption) => (
|
||||||
|
<div key={assumption.id} className="grid gap-3 px-6 py-4 md:grid-cols-[160px,1fr,1fr]">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">Category</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-gray-900">{assumption.category}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">Label</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-800">{assumption.label}</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-400">{assumption.key}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">Value</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-800">{String(assumption.value)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
type EstimateExportArtifactPayload,
|
||||||
|
EstimateExportFormat,
|
||||||
|
} from "@planarchy/shared";
|
||||||
|
import type {
|
||||||
|
EstimateExportView,
|
||||||
|
EstimateVersionView,
|
||||||
|
EstimateWorkspaceView,
|
||||||
|
} from "~/components/estimates/EstimateWorkspace.types.js";
|
||||||
|
import { formatDateLong, formatMoney } from "~/lib/format.js";
|
||||||
|
|
||||||
|
const EXPORT_FORMATS: EstimateExportFormat[] = [
|
||||||
|
EstimateExportFormat.XLSX,
|
||||||
|
EstimateExportFormat.CSV,
|
||||||
|
EstimateExportFormat.JSON,
|
||||||
|
EstimateExportFormat.SAP,
|
||||||
|
EstimateExportFormat.MMP,
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatBytes(value: number | null | undefined) {
|
||||||
|
const bytes = value ?? 0;
|
||||||
|
if (bytes < 1024) {
|
||||||
|
return `${bytes} B`;
|
||||||
|
}
|
||||||
|
if (bytes < 1024 * 1024) {
|
||||||
|
return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
}
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEstimateExportArtifactPayload(
|
||||||
|
payload: EstimateExportView["payload"],
|
||||||
|
): payload is EstimateExportArtifactPayload {
|
||||||
|
return (
|
||||||
|
typeof payload === "object" &&
|
||||||
|
payload !== null &&
|
||||||
|
typeof payload.content === "string" &&
|
||||||
|
typeof payload.mimeType === "string" &&
|
||||||
|
typeof payload.encoding === "string" &&
|
||||||
|
typeof payload.fileExtension === "string" &&
|
||||||
|
typeof payload.generatedAt === "string" &&
|
||||||
|
typeof payload.byteLength === "number" &&
|
||||||
|
typeof payload.summary === "object" &&
|
||||||
|
payload.summary !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64(value: string) {
|
||||||
|
const binary = atob(value);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
|
||||||
|
for (let index = 0; index < binary.length; index += 1) {
|
||||||
|
bytes[index] = binary.charCodeAt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadEstimateExport(estimateExport: EstimateExportView) {
|
||||||
|
const payload = estimateExport.payload;
|
||||||
|
if (!isEstimateExportArtifactPayload(payload)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob =
|
||||||
|
payload.encoding === "base64"
|
||||||
|
? new Blob([decodeBase64(payload.content)], { type: payload.mimeType })
|
||||||
|
: new Blob([payload.content], { type: payload.mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = estimateExport.fileName;
|
||||||
|
document.body.append(link);
|
||||||
|
link.click();
|
||||||
|
link.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExportsTabProps {
|
||||||
|
estimate: EstimateWorkspaceView;
|
||||||
|
canEdit: boolean;
|
||||||
|
onCreateExport: (versionId: string, format: EstimateExportFormat) => void;
|
||||||
|
isCreatingExport: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportsTab({
|
||||||
|
estimate,
|
||||||
|
canEdit,
|
||||||
|
onCreateExport,
|
||||||
|
isCreatingExport,
|
||||||
|
}: ExportsTabProps) {
|
||||||
|
const versions = estimate.versions as EstimateVersionView[];
|
||||||
|
const latestVersion = versions[0] ?? null;
|
||||||
|
const exports = latestVersion?.exports ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Export delivery</h2>
|
||||||
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
|
Generate format-specific artifacts from the current version and download them directly from the stored serializer payload.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{latestVersion && canEdit && (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{EXPORT_FORMATS.map((format) => (
|
||||||
|
<button
|
||||||
|
key={format}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCreateExport(latestVersion.id, format)}
|
||||||
|
disabled={isCreatingExport}
|
||||||
|
className="rounded-2xl border border-brand-200 bg-white px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isCreatingExport ? "Generating..." : `Create ${format}`}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-3xl border border-gray-200 bg-white shadow-sm">
|
||||||
|
<div className="border-b border-gray-100 px-6 py-4">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Generated exports</h2>
|
||||||
|
</div>
|
||||||
|
{exports.length === 0 ? (
|
||||||
|
<div className="px-6 py-8">
|
||||||
|
<p className="text-sm text-gray-400">No exports have been generated for the current version yet.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{exports.map((estimateExport) => {
|
||||||
|
const payload = isEstimateExportArtifactPayload(estimateExport.payload)
|
||||||
|
? estimateExport.payload
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={estimateExport.id} className="px-6 py-5">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{estimateExport.fileName}</p>
|
||||||
|
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wide text-gray-600">
|
||||||
|
{estimateExport.format}
|
||||||
|
</span>
|
||||||
|
{payload?.sheetNames?.length ? (
|
||||||
|
<span className="rounded-full bg-sky-50 px-2.5 py-1 text-[11px] font-semibold text-sky-700">
|
||||||
|
{payload.sheetNames.length} sheets
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-500">
|
||||||
|
<span>{formatDateLong(estimateExport.createdAt)}</span>
|
||||||
|
{payload ? <span>{formatBytes(payload.byteLength)}</span> : null}
|
||||||
|
{payload?.rowCount != null ? <span>{payload.rowCount} rows</span> : null}
|
||||||
|
{payload?.lineCount != null ? <span>{payload.lineCount} lines</span> : null}
|
||||||
|
</div>
|
||||||
|
{payload ? (
|
||||||
|
<div className="mt-3 grid gap-2 text-xs text-gray-600 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-2xl bg-gray-50 px-3 py-2">
|
||||||
|
<p className="uppercase tracking-wide text-gray-400">Hours</p>
|
||||||
|
<p className="mt-1 font-semibold text-gray-900">
|
||||||
|
{payload.summary.totalHours.toFixed(1)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-gray-50 px-3 py-2">
|
||||||
|
<p className="uppercase tracking-wide text-gray-400">Cost</p>
|
||||||
|
<p className="mt-1 font-semibold text-gray-900">
|
||||||
|
{formatMoney(
|
||||||
|
payload.summary.totalCostCents,
|
||||||
|
payload.summary.baseCurrency,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-gray-50 px-3 py-2">
|
||||||
|
<p className="uppercase tracking-wide text-gray-400">Price</p>
|
||||||
|
<p className="mt-1 font-semibold text-gray-900">
|
||||||
|
{formatMoney(
|
||||||
|
payload.summary.totalPriceCents,
|
||||||
|
payload.summary.baseCurrency,
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-gray-50 px-3 py-2">
|
||||||
|
<p className="uppercase tracking-wide text-gray-400">Margin</p>
|
||||||
|
<p className="mt-1 font-semibold text-gray-900">
|
||||||
|
{payload.summary.marginPercent.toFixed(0)}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-xs text-amber-700">
|
||||||
|
Legacy export record detected. Regenerate it to get downloadable serializer output.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{payload ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadEstimateExport(estimateExport)}
|
||||||
|
className="rounded-2xl border border-brand-200 bg-white px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{payload?.previewText ? (
|
||||||
|
<pre className="mt-4 overflow-x-auto rounded-2xl bg-gray-950/95 p-4 text-xs text-gray-100">
|
||||||
|
{payload.previewText}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import { CommercialTermsEditor } from "~/components/estimates/CommercialTermsEditor.js";
|
||||||
|
import type {
|
||||||
|
EstimateVersionView,
|
||||||
|
EstimateWorkspaceView,
|
||||||
|
} from "~/components/estimates/EstimateWorkspace.types.js";
|
||||||
|
import { formatMoney } from "~/lib/format.js";
|
||||||
|
|
||||||
|
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspaceView; canEdit: boolean }) {
|
||||||
|
const versions = estimate.versions as EstimateVersionView[];
|
||||||
|
const latestVersion = versions[0] ?? null;
|
||||||
|
const demandLines = latestVersion?.demandLines ?? [];
|
||||||
|
|
||||||
|
if (demandLines.length === 0) {
|
||||||
|
return <EmptyState>No demand lines available to generate financial summaries.</EmptyState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = demandLines.reduce(
|
||||||
|
(acc, line) => ({
|
||||||
|
hours: acc.hours + line.hours,
|
||||||
|
costCents: acc.costCents + line.costTotalCents,
|
||||||
|
priceCents: acc.priceCents + line.priceTotalCents,
|
||||||
|
}),
|
||||||
|
{ hours: 0, costCents: 0, priceCents: 0 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const marginCents = totals.priceCents - totals.costCents;
|
||||||
|
const marginPercent = totals.priceCents > 0 ? (marginCents / totals.priceCents) * 100 : 0;
|
||||||
|
const avgCostRate = totals.hours > 0 ? totals.costCents / totals.hours : 0;
|
||||||
|
const avgBillRate = totals.hours > 0 ? totals.priceCents / totals.hours : 0;
|
||||||
|
|
||||||
|
// Group by chapter
|
||||||
|
const chapterMap = new Map<string, { hours: number; costCents: number; priceCents: number; count: number }>();
|
||||||
|
for (const line of demandLines) {
|
||||||
|
const chapter = line.chapter?.trim() || "Unassigned";
|
||||||
|
const existing = chapterMap.get(chapter) ?? { hours: 0, costCents: 0, priceCents: 0, count: 0 };
|
||||||
|
existing.hours += line.hours;
|
||||||
|
existing.costCents += line.costTotalCents;
|
||||||
|
existing.priceCents += line.priceTotalCents;
|
||||||
|
existing.count += 1;
|
||||||
|
chapterMap.set(chapter, existing);
|
||||||
|
}
|
||||||
|
const chapterBreakdown = [...chapterMap.entries()]
|
||||||
|
.sort(([, a], [, b]) => b.priceCents - a.priceCents);
|
||||||
|
|
||||||
|
// Monthly cost/price phasing
|
||||||
|
const spreads = demandLines.filter(
|
||||||
|
(line): line is typeof line & { monthlySpread: Record<string, number> } =>
|
||||||
|
line.monthlySpread != null && Object.keys(line.monthlySpread).length > 0,
|
||||||
|
);
|
||||||
|
const monthlyFinancials = new Map<string, { hours: number; costCents: number; priceCents: number }>();
|
||||||
|
for (const line of spreads) {
|
||||||
|
const costRate = line.hours > 0 ? line.costTotalCents / line.hours : 0;
|
||||||
|
const billRate = line.hours > 0 ? line.priceTotalCents / line.hours : 0;
|
||||||
|
for (const [month, hours] of Object.entries(line.monthlySpread)) {
|
||||||
|
const existing = monthlyFinancials.get(month) ?? { hours: 0, costCents: 0, priceCents: 0 };
|
||||||
|
existing.hours += hours;
|
||||||
|
existing.costCents += Math.round(hours * costRate);
|
||||||
|
existing.priceCents += Math.round(hours * billRate);
|
||||||
|
monthlyFinancials.set(month, existing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sortedMonths = [...monthlyFinancials.keys()].sort();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(totals.costCents, estimate.baseCurrency)}</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">Avg {formatMoney(Math.round(avgCostRate), estimate.baseCurrency)}/h</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(totals.priceCents, estimate.baseCurrency)}</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">Avg {formatMoney(Math.round(avgBillRate), estimate.baseCurrency)}/h</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">Margin</p>
|
||||||
|
<p className={clsx("mt-2 text-2xl font-semibold", marginCents >= 0 ? "text-emerald-700" : "text-red-700")}>
|
||||||
|
{formatMoney(marginCents, estimate.baseCurrency)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">{marginPercent.toFixed(1)}% of price</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-gray-900">{totals.hours.toFixed(1)} h</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">{demandLines.length} demand lines</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Margin waterfall: Cost -> Margin -> Price */}
|
||||||
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<h3 className="mb-4 text-base font-semibold text-gray-900">Cost to price bridge</h3>
|
||||||
|
<div className="flex items-end gap-1 h-32">
|
||||||
|
{(() => {
|
||||||
|
const maxVal = Math.max(totals.costCents, totals.priceCents, 1);
|
||||||
|
const costH = (totals.costCents / maxVal) * 100;
|
||||||
|
const marginH = (Math.abs(marginCents) / maxVal) * 100;
|
||||||
|
const priceH = (totals.priceCents / maxVal) * 100;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex-1 flex flex-col items-center gap-1">
|
||||||
|
<div className="w-full rounded-t-xl bg-gray-300" style={{ height: `${costH}%` }} />
|
||||||
|
<span className="text-xs font-medium text-gray-600">Cost</span>
|
||||||
|
<span className="text-xs text-gray-500">{formatMoney(totals.costCents, estimate.baseCurrency)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col items-center gap-1">
|
||||||
|
<div
|
||||||
|
className={clsx("w-full rounded-t-xl", marginCents >= 0 ? "bg-emerald-400" : "bg-red-400")}
|
||||||
|
style={{ height: `${marginH}%` }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-medium text-gray-600">Margin</span>
|
||||||
|
<span className="text-xs text-gray-500">{formatMoney(marginCents, estimate.baseCurrency)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex flex-col items-center gap-1">
|
||||||
|
<div className="w-full rounded-t-xl bg-brand-500" style={{ height: `${priceH}%` }} />
|
||||||
|
<span className="text-xs font-medium text-gray-600">Price</span>
|
||||||
|
<span className="text-xs text-gray-500">{formatMoney(totals.priceCents, estimate.baseCurrency)}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Chapter breakdown */}
|
||||||
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<h3 className="mb-3 text-base font-semibold text-gray-900">Breakdown by chapter</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||||
|
<th className="py-2 pr-3 font-medium">Chapter</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Lines</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Hours</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Cost</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Price</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Margin</th>
|
||||||
|
<th className="pl-3 py-2 text-right font-medium">Margin %</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{chapterBreakdown.map(([chapter, data]) => {
|
||||||
|
const chapterMargin = data.priceCents - data.costCents;
|
||||||
|
const chapterMarginPct = data.priceCents > 0 ? (chapterMargin / data.priceCents) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<tr key={chapter} className="border-b border-gray-100">
|
||||||
|
<td className="py-2 pr-3 font-medium text-gray-900">{chapter}</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums text-gray-600">{data.count}</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{data.hours.toFixed(1)}</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.costCents, estimate.baseCurrency)}</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.priceCents, estimate.baseCurrency)}</td>
|
||||||
|
<td className={clsx("px-3 py-2 text-right tabular-nums", chapterMargin >= 0 ? "text-emerald-700" : "text-red-700")}>
|
||||||
|
{formatMoney(chapterMargin, estimate.baseCurrency)}
|
||||||
|
</td>
|
||||||
|
<td className={clsx("pl-3 py-2 text-right tabular-nums", chapterMarginPct >= 0 ? "text-emerald-700" : "text-red-700")}>
|
||||||
|
{chapterMarginPct.toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<tr className="border-t-2 border-gray-300 font-semibold">
|
||||||
|
<td className="py-2 pr-3 text-gray-900">Total</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{demandLines.length}</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{totals.hours.toFixed(1)}</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{formatMoney(totals.costCents, estimate.baseCurrency)}</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums text-gray-900">{formatMoney(totals.priceCents, estimate.baseCurrency)}</td>
|
||||||
|
<td className={clsx("px-3 py-2 text-right tabular-nums", marginCents >= 0 ? "text-emerald-700" : "text-red-700")}>
|
||||||
|
{formatMoney(marginCents, estimate.baseCurrency)}
|
||||||
|
</td>
|
||||||
|
<td className={clsx("pl-3 py-2 text-right tabular-nums", marginPercent >= 0 ? "text-emerald-700" : "text-red-700")}>
|
||||||
|
{marginPercent.toFixed(1)}%
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monthly cost/price phasing */}
|
||||||
|
{sortedMonths.length > 0 && (
|
||||||
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<h3 className="mb-3 text-base font-semibold text-gray-900">Monthly financial phasing</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
|
||||||
|
<th className="py-2 pr-3 font-medium">Month</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Hours</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Cost</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Price</th>
|
||||||
|
<th className="pl-3 py-2 text-right font-medium">Margin</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedMonths.map((month) => {
|
||||||
|
const data = monthlyFinancials.get(month)!;
|
||||||
|
const mMargin = data.priceCents - data.costCents;
|
||||||
|
return (
|
||||||
|
<tr key={month} className="border-b border-gray-100">
|
||||||
|
<td className="py-2 pr-3 font-medium text-gray-900">{month}</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{data.hours.toFixed(1)}</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.costCents, estimate.baseCurrency)}</td>
|
||||||
|
<td className="px-3 py-2 text-right tabular-nums text-gray-700">{formatMoney(data.priceCents, estimate.baseCurrency)}</td>
|
||||||
|
<td className={clsx("pl-3 py-2 text-right tabular-nums", mMargin >= 0 ? "text-emerald-700" : "text-red-700")}>
|
||||||
|
{formatMoney(mMargin, estimate.baseCurrency)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Commercial Terms */}
|
||||||
|
<CommercialTermsEditor
|
||||||
|
estimateId={estimate.id}
|
||||||
|
baseCostCents={totals.costCents}
|
||||||
|
basePriceCents={totals.priceCents}
|
||||||
|
baseCurrency={estimate.baseCurrency}
|
||||||
|
canEdit={canEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import { EstimateStatus, EstimateVersionStatus } from "@planarchy/shared";
|
||||||
|
import type {
|
||||||
|
EstimateMetricView,
|
||||||
|
EstimateVersionView,
|
||||||
|
EstimateWorkspaceView,
|
||||||
|
} from "~/components/estimates/EstimateWorkspace.types.js";
|
||||||
|
import { formatDateLong, formatMoney } from "~/lib/format.js";
|
||||||
|
|
||||||
|
const STATUS_STYLES: Record<EstimateStatus, string> = {
|
||||||
|
DRAFT: "bg-slate-100 text-slate-700",
|
||||||
|
IN_REVIEW: "bg-amber-100 text-amber-700",
|
||||||
|
APPROVED: "bg-emerald-100 text-emerald-700",
|
||||||
|
ARCHIVED: "bg-zinc-200 text-zinc-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
|
||||||
|
WORKING: "bg-sky-100 text-sky-700",
|
||||||
|
BASELINE: "bg-violet-100 text-violet-700",
|
||||||
|
SUBMITTED: "bg-amber-100 text-amber-700",
|
||||||
|
APPROVED: "bg-emerald-100 text-emerald-700",
|
||||||
|
SUPERSEDED: "bg-zinc-200 text-zinc-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatMetricValue(metric: EstimateMetricView) {
|
||||||
|
if (metric.valueCents != null) {
|
||||||
|
return formatMoney(metric.valueCents, metric.currency ?? "EUR");
|
||||||
|
}
|
||||||
|
if (metric.key === "margin_percent") {
|
||||||
|
return `${metric.valueDecimal.toFixed(0)}%`;
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 }).format(metric.valueDecimal);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
|
||||||
|
const versions = estimate.versions as EstimateVersionView[];
|
||||||
|
const latestVersion = versions[0] ?? null;
|
||||||
|
const latestMetrics = latestVersion?.metrics ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),340px]">
|
||||||
|
<section className="space-y-6">
|
||||||
|
<div className="rounded-3xl border border-gray-200 bg-white p-6 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={clsx("rounded-full px-3 py-1 text-xs font-semibold", STATUS_STYLES[estimate.status])}>
|
||||||
|
{estimate.status.replace("_", " ")}
|
||||||
|
</span>
|
||||||
|
{estimate.project && (
|
||||||
|
<span className="rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-600">
|
||||||
|
{estimate.project.shortCode}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">Opportunity</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-800">{estimate.opportunityId ?? "Not set"}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">Base currency</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-800">{estimate.baseCurrency}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">Latest version</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-800">
|
||||||
|
{latestVersion ? `v${latestVersion.versionNumber}${latestVersion.label ? ` - ${latestVersion.label}` : ""}` : "No version"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">Updated</p>
|
||||||
|
<p className="mt-1 text-sm text-gray-800">{formatDateLong(estimate.updatedAt)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{latestVersion?.notes && (
|
||||||
|
<div className="mt-5 rounded-2xl border border-gray-100 bg-gray-50 p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">Version notes</p>
|
||||||
|
<p className="mt-2 text-sm text-gray-700">{latestVersion.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">Scope items</p>
|
||||||
|
<span className="text-xs text-gray-400">{latestVersion?.scopeItems.length ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{(latestVersion?.scopeItems ?? []).slice(0, 4).map((item) => (
|
||||||
|
<div key={item.id} className="rounded-2xl border border-gray-100 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{item.name}</p>
|
||||||
|
<span className="text-xs text-gray-400">{item.scopeType}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(latestVersion?.scopeItems.length ?? 0) === 0 && <p className="text-sm text-gray-400">No scope rows captured yet.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">Demand lines</p>
|
||||||
|
<span className="text-xs text-gray-400">{latestVersion?.demandLines.length ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{(latestVersion?.demandLines ?? []).slice(0, 4).map((line) => (
|
||||||
|
<div key={line.id} className="rounded-2xl border border-gray-100 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{line.name}</p>
|
||||||
|
<span className="text-xs text-gray-500">{line.hours.toFixed(1)} h</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(latestVersion?.demandLines.length ?? 0) === 0 && <p className="text-sm text-gray-400">No demand lines captured yet.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">Summary metrics</p>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{latestMetrics.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400">No derived metrics available yet.</p>
|
||||||
|
) : (
|
||||||
|
latestMetrics.map((metric) => (
|
||||||
|
<div key={metric.id} className="flex items-center justify-between gap-3 rounded-2xl bg-gray-50 px-4 py-3">
|
||||||
|
<span className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</span>
|
||||||
|
<span className="text-sm font-semibold text-gray-900">{formatMetricValue(metric)}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">Version context</p>
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{latestVersion ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm text-gray-500">Status</span>
|
||||||
|
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[latestVersion.status])}>
|
||||||
|
{latestVersion.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm text-gray-500">Assumptions</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{latestVersion.assumptions.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm text-gray-500">Snapshots</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{latestVersion.resourceSnapshots.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-sm text-gray-500">Exports</span>
|
||||||
|
<span className="text-sm font-medium text-gray-900">{latestVersion.exports.length}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400">No version available.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type {
|
||||||
|
EstimateVersionView,
|
||||||
|
EstimateWorkspaceView,
|
||||||
|
} from "~/components/estimates/EstimateWorkspace.types.js";
|
||||||
|
|
||||||
|
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScopeTab({ estimate }: { estimate: EstimateWorkspaceView }) {
|
||||||
|
const versions = estimate.versions as EstimateVersionView[];
|
||||||
|
const latestVersion = versions[0] ?? null;
|
||||||
|
const scopeItems = latestVersion?.scopeItems ?? [];
|
||||||
|
|
||||||
|
if (scopeItems.length === 0) {
|
||||||
|
return <EmptyState>No scope rows captured for the current version yet.</EmptyState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{scopeItems.map((item) => (
|
||||||
|
<div key={item.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="rounded-full bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-600">
|
||||||
|
#{item.sequenceNo}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full bg-brand-50 px-2.5 py-1 text-xs font-medium text-brand-700">
|
||||||
|
{item.scopeType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-3 text-lg font-semibold text-gray-900">{item.name}</h3>
|
||||||
|
{item.description && <p className="mt-2 text-sm text-gray-600">{item.description}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2 text-right text-xs text-gray-400">
|
||||||
|
{item.frameCount != null && <span>{item.frameCount} frames</span>}
|
||||||
|
{item.itemCount != null && <span>{item.itemCount} units</span>}
|
||||||
|
{item.unitMode && <span>{item.unitMode}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { EstimateVersionStatus } from "@planarchy/shared";
|
||||||
|
import { summarizeMonthlySpread } from "@planarchy/engine";
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import {
|
||||||
|
getEffectiveDemandLineValues,
|
||||||
|
resolveDemandLineCalculationMetadata,
|
||||||
|
} from "~/components/estimates/EstimateWorkspace.calculations.js";
|
||||||
|
import { ApplyEffortRules } from "~/components/estimates/ApplyEffortRules.js";
|
||||||
|
import { ApplyExperienceMultipliers } from "~/components/estimates/ApplyExperienceMultipliers.js";
|
||||||
|
import type {
|
||||||
|
EstimateVersionView,
|
||||||
|
EstimateWorkspaceView,
|
||||||
|
} from "~/components/estimates/EstimateWorkspace.types.js";
|
||||||
|
import { formatMoney } from "~/lib/format.js";
|
||||||
|
|
||||||
|
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspaceView; canEdit: boolean }) {
|
||||||
|
const versions = estimate.versions as EstimateVersionView[];
|
||||||
|
const latestVersion = versions[0] ?? null;
|
||||||
|
const demandLines = latestVersion?.demandLines ?? [];
|
||||||
|
const snapshots = latestVersion?.resourceSnapshots ?? [];
|
||||||
|
const isWorking = latestVersion?.status === EstimateVersionStatus.WORKING;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isWorking && (
|
||||||
|
<>
|
||||||
|
<ApplyEffortRules
|
||||||
|
estimateId={estimate.id}
|
||||||
|
canEdit={canEdit}
|
||||||
|
/>
|
||||||
|
<ApplyExperienceMultipliers
|
||||||
|
estimateId={estimate.id}
|
||||||
|
canEdit={canEdit}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{demandLines.length === 0 && (
|
||||||
|
<EmptyState>No staffing demand lines captured for the current version yet.</EmptyState>
|
||||||
|
)}
|
||||||
|
{demandLines.map((line) => {
|
||||||
|
const linkedSnapshot = line.resourceId
|
||||||
|
? snapshots.find((snapshot) => snapshot.resourceId === line.resourceId) ?? null
|
||||||
|
: null;
|
||||||
|
const calculation = resolveDemandLineCalculationMetadata({
|
||||||
|
resourceSnapshot: linkedSnapshot,
|
||||||
|
metadata: line.metadata,
|
||||||
|
costRateCents: line.costRateCents,
|
||||||
|
billRateCents: line.billRateCents,
|
||||||
|
});
|
||||||
|
const effectiveValues = getEffectiveDemandLineValues({
|
||||||
|
resourceSnapshot: linkedSnapshot,
|
||||||
|
hours: line.hours,
|
||||||
|
currency: line.currency,
|
||||||
|
defaultCurrency: line.currency,
|
||||||
|
costRateCents: line.costRateCents,
|
||||||
|
billRateCents: line.billRateCents,
|
||||||
|
costRateMode: calculation.costRateMode,
|
||||||
|
billRateMode: calculation.billRateMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={line.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">{line.name}</h3>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500">
|
||||||
|
<span>{line.lineType}</span>
|
||||||
|
{line.chapter && <span>{line.chapter}</span>}
|
||||||
|
{line.rateSource && <span>{line.rateSource}</span>}
|
||||||
|
{linkedSnapshot && <span>Linked: {linkedSnapshot.displayName}</span>}
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full px-2.5 py-1 font-medium",
|
||||||
|
calculation.costRateMode === "resource"
|
||||||
|
? "bg-emerald-50 text-emerald-700"
|
||||||
|
: "bg-amber-50 text-amber-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Cost {calculation.costRateMode === "resource" ? "live" : "manual"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={clsx(
|
||||||
|
"rounded-full px-2.5 py-1 font-medium",
|
||||||
|
calculation.billRateMode === "resource"
|
||||||
|
? "bg-emerald-50 text-emerald-700"
|
||||||
|
: "bg-amber-50 text-amber-700",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Sell {calculation.billRateMode === "resource" ? "live" : "manual"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">{line.hours.toFixed(1)} h</p>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">{effectiveValues.currency}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-3 md:grid-cols-4">
|
||||||
|
<div className="rounded-2xl bg-gray-50 px-4 py-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">Cost rate</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(line.costRateCents, line.currency)}</p>
|
||||||
|
{linkedSnapshot && calculation.costRateMode === "manual" && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Live snapshot {formatMoney(linkedSnapshot.lcrCents, linkedSnapshot.currency)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-gray-50 px-4 py-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">Sell rate</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(line.billRateCents, line.currency)}</p>
|
||||||
|
{linkedSnapshot && calculation.billRateMode === "manual" && (
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Live snapshot {formatMoney(linkedSnapshot.ucrCents, linkedSnapshot.currency)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-gray-50 px-4 py-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">Cost total</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl bg-gray-50 px-4 py-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">Price total</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{line.monthlySpread && Object.keys(line.monthlySpread).length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Monthly phasing</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{Object.entries(line.monthlySpread)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([month, hours]) => (
|
||||||
|
<div key={month} className="rounded-xl bg-gray-50 px-3 py-1.5 text-xs">
|
||||||
|
<span className="text-gray-500">{month}</span>
|
||||||
|
<span className="ml-1.5 font-medium text-gray-900">{hours.toFixed(1)} h</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const spreads = demandLines
|
||||||
|
.map((line) => line.monthlySpread)
|
||||||
|
.filter((spread): spread is Record<string, number> => spread != null && Object.keys(spread).length > 0);
|
||||||
|
if (spreads.length === 0) return null;
|
||||||
|
const aggregated = summarizeMonthlySpread(spreads);
|
||||||
|
const months = Object.keys(aggregated).sort();
|
||||||
|
if (months.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<p className="mb-3 text-sm font-semibold text-gray-900">Aggregated monthly phasing</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{months.map((month) => (
|
||||||
|
<div key={month} className="rounded-xl bg-gray-50 px-3 py-2 text-sm">
|
||||||
|
<span className="text-gray-500">{month}</span>
|
||||||
|
<span className="ml-2 font-semibold text-gray-900">{(aggregated[month] ?? 0).toFixed(1)} h</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-right text-sm font-semibold text-gray-700">
|
||||||
|
Total: {Object.values(aggregated).reduce((a, b) => a + b, 0).toFixed(1)} h
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { EstimateVersionStatus } from "@planarchy/shared";
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import { VersionCompare } from "~/components/estimates/VersionCompare.js";
|
||||||
|
import type {
|
||||||
|
EstimateMetricView,
|
||||||
|
EstimateVersionView,
|
||||||
|
EstimateWorkspaceView,
|
||||||
|
} from "~/components/estimates/EstimateWorkspace.types.js";
|
||||||
|
import { formatDateLong, formatMoney } from "~/lib/format.js";
|
||||||
|
|
||||||
|
const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
|
||||||
|
WORKING: "bg-sky-100 text-sky-700",
|
||||||
|
BASELINE: "bg-violet-100 text-violet-700",
|
||||||
|
SUBMITTED: "bg-amber-100 text-amber-700",
|
||||||
|
APPROVED: "bg-emerald-100 text-emerald-700",
|
||||||
|
SUPERSEDED: "bg-zinc-200 text-zinc-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatMetricValue(metric: EstimateMetricView) {
|
||||||
|
if (metric.valueCents != null) {
|
||||||
|
return formatMoney(metric.valueCents, metric.currency ?? "EUR");
|
||||||
|
}
|
||||||
|
if (metric.key === "margin_percent") {
|
||||||
|
return `${metric.valueDecimal.toFixed(0)}%`;
|
||||||
|
}
|
||||||
|
return new Intl.NumberFormat("de-DE", { maximumFractionDigits: 1 }).format(metric.valueDecimal);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-3xl border border-dashed border-gray-200 bg-white px-5 py-10 text-center text-sm text-gray-400">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionsTabProps {
|
||||||
|
estimate: EstimateWorkspaceView;
|
||||||
|
canEdit: boolean;
|
||||||
|
hasLinkedProject: boolean;
|
||||||
|
onSubmitVersion: (versionId: string) => void;
|
||||||
|
onApproveVersion: (versionId: string) => void;
|
||||||
|
onCreateRevision: (versionId: string) => void;
|
||||||
|
onCreatePlanningHandoff: (versionId: string) => void;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
isApproving: boolean;
|
||||||
|
isCreatingRevision: boolean;
|
||||||
|
isCreatingPlanningHandoff: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VersionsTab({
|
||||||
|
estimate,
|
||||||
|
canEdit,
|
||||||
|
hasLinkedProject,
|
||||||
|
onSubmitVersion,
|
||||||
|
onApproveVersion,
|
||||||
|
onCreateRevision,
|
||||||
|
onCreatePlanningHandoff,
|
||||||
|
isSubmitting,
|
||||||
|
isApproving,
|
||||||
|
isCreatingRevision,
|
||||||
|
isCreatingPlanningHandoff,
|
||||||
|
}: VersionsTabProps) {
|
||||||
|
const versions = estimate.versions as EstimateVersionView[];
|
||||||
|
const hasWorkingVersion = versions.some(
|
||||||
|
(version) => version.status === EstimateVersionStatus.WORKING,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (versions.length === 0) {
|
||||||
|
return <EmptyState>No versions available for this estimate yet.</EmptyState>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{versions.map((version) => (
|
||||||
|
<div key={version.id} className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-lg font-semibold text-gray-900">v{version.versionNumber}</span>
|
||||||
|
<span className={clsx("rounded-full px-2.5 py-1 text-xs font-medium", VERSION_STYLES[version.status])}>
|
||||||
|
{version.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-gray-600">{version.label ?? "Unlabeled version"}</p>
|
||||||
|
{version.notes && <p className="mt-2 text-sm text-gray-500">{version.notes}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-sm text-gray-500">
|
||||||
|
<p>Updated {formatDateLong(version.updatedAt)}</p>
|
||||||
|
{version.lockedAt && (
|
||||||
|
<p className="mt-1">Locked {formatDateLong(version.lockedAt)}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1">{version.demandLines.length} lines</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{version.status === EstimateVersionStatus.WORKING && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSubmitVersion(version.id)}
|
||||||
|
disabled={isSubmitting || isApproving || isCreatingRevision}
|
||||||
|
className="rounded-2xl border border-amber-200 bg-amber-50 px-3 py-2 text-sm font-semibold text-amber-800 transition hover:border-amber-300 hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Submitting..." : "Submit for review"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{version.status === EstimateVersionStatus.SUBMITTED && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onApproveVersion(version.id)}
|
||||||
|
disabled={isSubmitting || isApproving || isCreatingRevision}
|
||||||
|
className="rounded-2xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-sm font-semibold text-emerald-800 transition hover:border-emerald-300 hover:bg-emerald-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isApproving ? "Approving..." : "Approve version"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{version.status !== EstimateVersionStatus.WORKING && !hasWorkingVersion && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCreateRevision(version.id)}
|
||||||
|
disabled={isSubmitting || isApproving || isCreatingRevision || isCreatingPlanningHandoff}
|
||||||
|
className="rounded-2xl border border-brand-200 bg-white px-3 py-2 text-sm font-semibold text-brand-700 transition hover:border-brand-300 hover:bg-brand-50 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isCreatingRevision ? "Creating revision..." : "Create working revision"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{version.status === EstimateVersionStatus.APPROVED && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onCreatePlanningHandoff(version.id)}
|
||||||
|
disabled={
|
||||||
|
isSubmitting ||
|
||||||
|
isApproving ||
|
||||||
|
isCreatingRevision ||
|
||||||
|
isCreatingPlanningHandoff ||
|
||||||
|
!hasLinkedProject
|
||||||
|
}
|
||||||
|
className="rounded-2xl border border-sky-200 bg-sky-50 px-3 py-2 text-sm font-semibold text-sky-800 transition hover:border-sky-300 hover:bg-sky-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{isCreatingPlanningHandoff
|
||||||
|
? "Creating planning allocations..."
|
||||||
|
: hasLinkedProject
|
||||||
|
? "Create planning allocations"
|
||||||
|
: "Link project to hand off"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{version.status === EstimateVersionStatus.APPROVED && !hasLinkedProject && (
|
||||||
|
<p className="mt-3 text-sm text-amber-700">
|
||||||
|
Link this estimate to a project before handing approved demand into planning.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{version.metrics.length > 0 && (
|
||||||
|
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||||
|
{version.metrics.map((metric) => (
|
||||||
|
<div key={metric.id} className="rounded-2xl bg-gray-50 px-4 py-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-gray-400">{metric.label}</p>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-gray-900">{formatMetricValue(metric)}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{versions.length >= 2 && <VersionCompare versions={versions} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
VacationStatus,
|
||||||
|
type AllocationLike,
|
||||||
|
type AllocationReadModel,
|
||||||
|
type Assignment,
|
||||||
|
type DemandRequirement,
|
||||||
|
} from "@planarchy/shared";
|
||||||
|
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { useTimelineSSE } from "~/hooks/useTimelineSSE.js";
|
||||||
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
import { readAppPreferences, useAppPreferences } from "~/hooks/useAppPreferences.js";
|
||||||
|
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
|
||||||
|
|
||||||
|
export type TimelineDisplayMode = "strip" | "bar" | "heatmap";
|
||||||
|
import { addDays } from "./utils.js";
|
||||||
|
import { DEFAULT_FILTERS, type TimelineFilters } from "./TimelineFilter.js";
|
||||||
|
import { DONE_STATUSES } from "./timelineConstants.js";
|
||||||
|
|
||||||
|
// ─── Local timeline types ─────────────────────────────────────────────────────
|
||||||
|
// These re-declare the shapes that the original TimelineView used internally.
|
||||||
|
// Kept here so every timeline sub-component can import them from one place.
|
||||||
|
|
||||||
|
export type TimelineResource = {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
eid: string;
|
||||||
|
chapter?: string | null;
|
||||||
|
lcrCents: number;
|
||||||
|
availability?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelineProject = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
shortCode: string;
|
||||||
|
status: string;
|
||||||
|
startDate: Date | string;
|
||||||
|
endDate: Date | string;
|
||||||
|
orderType: string;
|
||||||
|
budgetCents?: number;
|
||||||
|
winProbability?: number;
|
||||||
|
staffingReqs?: unknown;
|
||||||
|
responsiblePerson?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelineRole = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelineAllocation = Omit<AllocationLike, "resource" | "project" | "roleEntity" | "metadata"> & {
|
||||||
|
resource?: TimelineResource | null;
|
||||||
|
project: TimelineProject;
|
||||||
|
roleEntity?: TimelineRole | null;
|
||||||
|
metadata: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelineAssignmentEntry = Assignment<TimelineAllocation>;
|
||||||
|
export type TimelineDemandEntry = DemandRequirement<TimelineAllocation>;
|
||||||
|
|
||||||
|
export type TimelineEntriesView = AllocationReadModel<TimelineAllocation> & {
|
||||||
|
assignments: TimelineAssignmentEntry[];
|
||||||
|
demands: TimelineDemandEntry[];
|
||||||
|
};
|
||||||
|
export type TimelineProjectEntry = TimelineAssignmentEntry | TimelineDemandEntry;
|
||||||
|
|
||||||
|
export type ViewMode = "resource" | "project";
|
||||||
|
|
||||||
|
// ─── Derived resource type used throughout the timeline ─────────────────────
|
||||||
|
export type ResourceBrief = {
|
||||||
|
id: string;
|
||||||
|
displayName: string;
|
||||||
|
eid: string;
|
||||||
|
chapter: string | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Project group type (project view) ──────────────────────────────────────
|
||||||
|
export type ProjectGroup = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
shortCode: string;
|
||||||
|
orderType: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
status: string;
|
||||||
|
resourceRows: { resource: ResourceBrief; allocs: TimelineAssignmentEntry[] }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Vacation entry type (inferred from tRPC) ──────────────────────────────
|
||||||
|
export type VacationEntry = {
|
||||||
|
id: string;
|
||||||
|
resourceId: string;
|
||||||
|
type: string;
|
||||||
|
startDate: Date | string;
|
||||||
|
endDate: Date | string;
|
||||||
|
note?: string | null;
|
||||||
|
status: string;
|
||||||
|
requestedBy?: { name?: string | null; email: string } | null;
|
||||||
|
approvedBy?: { name?: string | null; email: string } | null;
|
||||||
|
approvedAt?: Date | string | null;
|
||||||
|
isHalfDay?: boolean;
|
||||||
|
halfDayPart?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Context shape ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TimelineContextValue {
|
||||||
|
// ─ Data
|
||||||
|
assignments: TimelineAssignmentEntry[];
|
||||||
|
demands: TimelineDemandEntry[];
|
||||||
|
visibleAssignments: TimelineAssignmentEntry[];
|
||||||
|
visibleDemands: TimelineDemandEntry[];
|
||||||
|
vacationsByResource: Map<string, VacationEntry[]>;
|
||||||
|
resources: ResourceBrief[];
|
||||||
|
resourceMap: Map<string, ResourceBrief>;
|
||||||
|
allocsByResource: Map<string, TimelineAssignmentEntry[]>;
|
||||||
|
projectGroups: ProjectGroup[];
|
||||||
|
openDemandsByProject: Map<string, TimelineDemandEntry[]>;
|
||||||
|
|
||||||
|
// ─ View state
|
||||||
|
viewStart: Date;
|
||||||
|
viewEnd: Date;
|
||||||
|
viewDays: number;
|
||||||
|
setViewStart: React.Dispatch<React.SetStateAction<Date>>;
|
||||||
|
setViewDays: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
filters: TimelineFilters;
|
||||||
|
setFilters: React.Dispatch<React.SetStateAction<TimelineFilters>>;
|
||||||
|
filterOpen: boolean;
|
||||||
|
setFilterOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
viewMode: ViewMode;
|
||||||
|
setViewMode: React.Dispatch<React.SetStateAction<ViewMode>>;
|
||||||
|
today: Date;
|
||||||
|
|
||||||
|
// ─ Display preferences
|
||||||
|
displayMode: TimelineDisplayMode;
|
||||||
|
heatmapScheme: HeatmapColorScheme;
|
||||||
|
|
||||||
|
// ─ Loading
|
||||||
|
isLoading: boolean;
|
||||||
|
isInitialLoading: boolean;
|
||||||
|
totalAllocCount: number;
|
||||||
|
activeFilterCount: number;
|
||||||
|
|
||||||
|
// ─ SSE is initialized by the provider (no value exposed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimelineContext = createContext<TimelineContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useTimelineContext(): TimelineContextValue {
|
||||||
|
const ctx = useContext(TimelineContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error("useTimelineContext must be used within a <TimelineProvider>");
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Provider ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TimelineProviderProps {
|
||||||
|
/** Cross-project context resource IDs from drag — injected from the parent. */
|
||||||
|
contextAllocations: TimelineAssignmentEntry[];
|
||||||
|
isDragging: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TimelineProvider({
|
||||||
|
contextAllocations,
|
||||||
|
isDragging,
|
||||||
|
children,
|
||||||
|
}: TimelineProviderProps) {
|
||||||
|
const today = useMemo(() => {
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
return d;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [viewStart, setViewStart] = useState(() => addDays(today, -30));
|
||||||
|
const [viewDays, setViewDays] = useState(180);
|
||||||
|
const viewEnd = addDays(viewStart, viewDays);
|
||||||
|
|
||||||
|
const [filters, setFilters] = useState<TimelineFilters>(() => ({
|
||||||
|
...DEFAULT_FILTERS,
|
||||||
|
hideCompletedProjects: readAppPreferences().hideCompletedProjects,
|
||||||
|
}));
|
||||||
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("resource");
|
||||||
|
|
||||||
|
useTimelineSSE();
|
||||||
|
|
||||||
|
const { prefs: appPrefs } = useAppPreferences();
|
||||||
|
const displayMode = appPrefs.timelineDisplayMode;
|
||||||
|
const heatmapScheme = appPrefs.heatmapColorScheme;
|
||||||
|
|
||||||
|
// ─── Data queries ──────────────────────────────────────────────────────────
|
||||||
|
const { data: entriesView, isLoading } = trpc.timeline.getEntriesView.useQuery(
|
||||||
|
{ startDate: viewStart, endDate: viewEnd },
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
{ placeholderData: (prev: any) => prev },
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
) as { data: TimelineEntriesView | undefined; isLoading: boolean };
|
||||||
|
|
||||||
|
const assignments = entriesView?.assignments ?? [];
|
||||||
|
const demands = entriesView?.demands ?? [];
|
||||||
|
|
||||||
|
const { data: vacationEntries = [] } = trpc.vacation.list.useQuery(
|
||||||
|
{ startDate: viewStart, endDate: viewEnd, status: VacationStatus.APPROVED, limit: 500 },
|
||||||
|
{ placeholderData: (prev) => prev },
|
||||||
|
);
|
||||||
|
|
||||||
|
const vacationsByResource = useMemo(() => {
|
||||||
|
const map = new Map<string, VacationEntry[]>();
|
||||||
|
for (const vacation of vacationEntries as VacationEntry[]) {
|
||||||
|
const existing = map.get(vacation.resourceId);
|
||||||
|
if (existing) {
|
||||||
|
existing.push(vacation);
|
||||||
|
} else {
|
||||||
|
map.set(vacation.resourceId, [vacation]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [vacationEntries]);
|
||||||
|
|
||||||
|
// When EID filter is active, explicitly fetch those resources.
|
||||||
|
const { data: eidFilterData } = trpc.resource.list.useQuery(
|
||||||
|
{ eids: filters.eids, limit: 100 },
|
||||||
|
{ enabled: filters.eids.length > 0, staleTime: 30_000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// ─── Filtered entries ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const visibleAssignments = useMemo(
|
||||||
|
() => assignments.filter((entry) => {
|
||||||
|
if (entry.project.status === "DRAFT" && !filters.showDrafts) return false;
|
||||||
|
if (DONE_STATUSES.has(entry.project.status) && filters.hideCompletedProjects) return false;
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
[assignments, filters.hideCompletedProjects, filters.showDrafts],
|
||||||
|
);
|
||||||
|
|
||||||
|
const visibleDemands = useMemo(
|
||||||
|
() => demands.filter((entry) => {
|
||||||
|
if (entry.project.status === "DRAFT" && !filters.showDrafts) return false;
|
||||||
|
if (DONE_STATUSES.has(entry.project.status) && filters.hideCompletedProjects) return false;
|
||||||
|
if (!filters.showPlaceholders) return false;
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
[demands, filters.hideCompletedProjects, filters.showDrafts, filters.showPlaceholders],
|
||||||
|
);
|
||||||
|
|
||||||
|
const openDemandsByProject = useMemo(() => {
|
||||||
|
const map = new Map<string, TimelineDemandEntry[]>();
|
||||||
|
for (const demand of visibleDemands) {
|
||||||
|
const arr = map.get(demand.projectId) ?? [];
|
||||||
|
arr.push(demand);
|
||||||
|
map.set(demand.projectId, arr);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [visibleDemands]);
|
||||||
|
|
||||||
|
// ─── Resource map + allocsByResource ──────────────────────────────────────
|
||||||
|
const { resourceMap, allocsByResource, resources } = useMemo(() => {
|
||||||
|
const resourceMap = new Map<string, ResourceBrief>();
|
||||||
|
const allocsByResource = new Map<string, TimelineAssignmentEntry[]>();
|
||||||
|
|
||||||
|
if (eidFilterData?.resources) {
|
||||||
|
for (const r of eidFilterData.resources as { id: string; displayName: string; eid: string; chapter: string | null }[]) {
|
||||||
|
if (!resourceMap.has(r.id)) {
|
||||||
|
resourceMap.set(r.id, { id: r.id, displayName: r.displayName, eid: r.eid, chapter: r.chapter });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const entry of visibleAssignments) {
|
||||||
|
if (!entry.resourceId) continue;
|
||||||
|
if (!resourceMap.has(entry.resourceId)) {
|
||||||
|
resourceMap.set(entry.resourceId, {
|
||||||
|
id: entry.resource!.id,
|
||||||
|
displayName: entry.resource!.displayName,
|
||||||
|
eid: entry.resource!.eid,
|
||||||
|
chapter: entry.resource!.chapter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const arr = allocsByResource.get(entry.resourceId) ?? [];
|
||||||
|
arr.push(entry);
|
||||||
|
allocsByResource.set(entry.resourceId, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge cross-project context allocations so they appear during drag
|
||||||
|
if (isDragging && contextAllocations.length > 0) {
|
||||||
|
for (const ca of contextAllocations) {
|
||||||
|
if (!ca.resourceId) continue;
|
||||||
|
const existing = visibleAssignments.find((entry) => entry.resourceId === ca.resourceId);
|
||||||
|
if (existing && !resourceMap.has(ca.resourceId)) {
|
||||||
|
resourceMap.set(ca.resourceId, {
|
||||||
|
id: existing.resource!.id,
|
||||||
|
displayName: existing.resource!.displayName,
|
||||||
|
eid: existing.resource!.eid,
|
||||||
|
chapter: existing.resource!.chapter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resources = [...resourceMap.values()].sort((a, b) =>
|
||||||
|
a.displayName.localeCompare(b.displayName),
|
||||||
|
);
|
||||||
|
if (filters.chapters.length > 0) {
|
||||||
|
resources = resources.filter((r) => r.chapter && filters.chapters.includes(r.chapter));
|
||||||
|
}
|
||||||
|
if (filters.eids.length > 0) {
|
||||||
|
resources = resources.filter((r) => filters.eids.includes(r.eid));
|
||||||
|
}
|
||||||
|
if (filters.projectIds.length > 0) {
|
||||||
|
resources = resources.filter((r) =>
|
||||||
|
visibleAssignments.some(
|
||||||
|
(e) => e.resourceId === r.id && filters.projectIds.includes(e.projectId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resourceMap, allocsByResource, resources };
|
||||||
|
}, [visibleAssignments, eidFilterData, isDragging, contextAllocations, filters.chapters, filters.eids, filters.projectIds]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ─── Project groups (for project view) ────────────────────────────────────
|
||||||
|
const projectGroups = useMemo(() => {
|
||||||
|
const projectGroupMap = new Map<string, ProjectGroup>();
|
||||||
|
const allGroupEntries: TimelineProjectEntry[] = [...visibleAssignments, ...visibleDemands];
|
||||||
|
for (const entry of allGroupEntries) {
|
||||||
|
let group = projectGroupMap.get(entry.projectId);
|
||||||
|
if (!group) {
|
||||||
|
group = {
|
||||||
|
id: entry.projectId,
|
||||||
|
name: entry.project.name,
|
||||||
|
shortCode: entry.project.shortCode,
|
||||||
|
orderType: entry.project.orderType,
|
||||||
|
startDate: new Date(entry.project.startDate as unknown as string),
|
||||||
|
endDate: new Date(entry.project.endDate as unknown as string),
|
||||||
|
status: entry.project.status,
|
||||||
|
resourceRows: [],
|
||||||
|
};
|
||||||
|
projectGroupMap.set(entry.projectId, group);
|
||||||
|
}
|
||||||
|
const currentGroup = group;
|
||||||
|
if (entry.kind === "assignment" && entry.resourceId) {
|
||||||
|
const existingRow = currentGroup.resourceRows.find((r) => r.resource.id === entry.resourceId);
|
||||||
|
if (existingRow) {
|
||||||
|
existingRow.allocs.push(entry);
|
||||||
|
} else {
|
||||||
|
const res = resourceMap.get(entry.resourceId);
|
||||||
|
if (res) {
|
||||||
|
currentGroup.resourceRows.push({ resource: res, allocs: [entry] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const group of projectGroupMap.values()) {
|
||||||
|
group.resourceRows.sort((a, b) => a.resource.displayName.localeCompare(b.resource.displayName));
|
||||||
|
}
|
||||||
|
return [...projectGroupMap.values()]
|
||||||
|
.sort((a, b) => a.startDate.getTime() - b.startDate.getTime())
|
||||||
|
.filter((pg) => {
|
||||||
|
if (filters.projectIds.length > 0 && !filters.projectIds.includes(pg.id)) return false;
|
||||||
|
if (filters.chapters.length > 0 && !pg.resourceRows.some((r) => r.resource.chapter && filters.chapters.includes(r.resource.chapter))) return false;
|
||||||
|
if (filters.eids.length > 0 && !pg.resourceRows.some((r) => filters.eids.includes(r.resource.eid))) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [visibleAssignments, visibleDemands, resourceMap, filters.projectIds, filters.chapters, filters.eids]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// ─── Derived counts ───────────────────────────────────────────────────────
|
||||||
|
const isInitialLoading = isLoading && !entriesView;
|
||||||
|
const totalAllocCount = entriesView?.allocations.length ?? 0;
|
||||||
|
const activeFilterCount =
|
||||||
|
filters.chapters.length + filters.eids.length + filters.projectIds.length;
|
||||||
|
|
||||||
|
const value = useMemo<TimelineContextValue>(
|
||||||
|
() => ({
|
||||||
|
assignments,
|
||||||
|
demands,
|
||||||
|
visibleAssignments,
|
||||||
|
visibleDemands,
|
||||||
|
vacationsByResource,
|
||||||
|
resources,
|
||||||
|
resourceMap,
|
||||||
|
allocsByResource,
|
||||||
|
projectGroups,
|
||||||
|
openDemandsByProject,
|
||||||
|
viewStart,
|
||||||
|
viewEnd,
|
||||||
|
viewDays,
|
||||||
|
setViewStart,
|
||||||
|
setViewDays,
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
filterOpen,
|
||||||
|
setFilterOpen,
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
|
today,
|
||||||
|
displayMode,
|
||||||
|
heatmapScheme,
|
||||||
|
isLoading,
|
||||||
|
isInitialLoading,
|
||||||
|
totalAllocCount,
|
||||||
|
activeFilterCount,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
assignments,
|
||||||
|
demands,
|
||||||
|
visibleAssignments,
|
||||||
|
visibleDemands,
|
||||||
|
vacationsByResource,
|
||||||
|
resources,
|
||||||
|
resourceMap,
|
||||||
|
allocsByResource,
|
||||||
|
projectGroups,
|
||||||
|
openDemandsByProject,
|
||||||
|
viewStart,
|
||||||
|
viewEnd,
|
||||||
|
viewDays,
|
||||||
|
filters,
|
||||||
|
filterOpen,
|
||||||
|
viewMode,
|
||||||
|
today,
|
||||||
|
displayMode,
|
||||||
|
heatmapScheme,
|
||||||
|
isLoading,
|
||||||
|
isInitialLoading,
|
||||||
|
totalAllocCount,
|
||||||
|
activeFilterCount,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TimelineContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</TimelineContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import { Fragment, startTransition, useCallback, useRef, useState } from "react";
|
||||||
|
import { useTimelineContext, type TimelineAssignmentEntry, type TimelineDemandEntry } from "./TimelineContext.js";
|
||||||
|
import { heatmapColor } from "./heatmapUtils.js";
|
||||||
|
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||||
|
import {
|
||||||
|
ROW_HEIGHT,
|
||||||
|
SUB_LANE_HEIGHT,
|
||||||
|
LABEL_WIDTH,
|
||||||
|
PROJECT_HEADER_HEIGHT,
|
||||||
|
ORDER_TYPE_COLORS,
|
||||||
|
} from "./timelineConstants.js";
|
||||||
|
import type { DragState, AllocDragState, RangeState } from "~/hooks/useTimelineDrag.js";
|
||||||
|
import type { AllocMouseDownInfo, RowMouseDownInfo } from "./TimelineResourcePanel.js";
|
||||||
|
|
||||||
|
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TimelineProjectPanelProps {
|
||||||
|
dragState: DragState;
|
||||||
|
allocDragState: AllocDragState;
|
||||||
|
rangeState: RangeState;
|
||||||
|
onProjectBarMouseDown: (e: React.MouseEvent, info: ProjectBarInfo) => void;
|
||||||
|
onProjectBarTouchStart: (e: React.TouchEvent, info: ProjectBarInfo) => void;
|
||||||
|
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void;
|
||||||
|
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void;
|
||||||
|
onRowMouseDown: (e: React.MouseEvent, info: RowMouseDownInfo) => void;
|
||||||
|
onRowTouchStart: (e: React.TouchEvent, info: RowMouseDownInfo) => void;
|
||||||
|
onOpenPanel: (projectId: string) => void;
|
||||||
|
onOpenDemandClick: (demand: OpenDemandAssignment) => void;
|
||||||
|
// Layout from useTimelineLayout
|
||||||
|
CELL_WIDTH: number;
|
||||||
|
dates: Date[];
|
||||||
|
totalCanvasWidth: number;
|
||||||
|
toLeft: (date: Date) => number;
|
||||||
|
toWidth: (start: Date, end: Date) => number;
|
||||||
|
gridLines: React.ReactNode;
|
||||||
|
xToDate: (clientX: number, rect: DOMRect) => Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectBarInfo {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenDemandAssignment {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
roleId: string | null;
|
||||||
|
role: string | null;
|
||||||
|
headcount: number;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
hoursPerDay: number;
|
||||||
|
roleEntity?: { id: string; name: string; color: string | null } | null;
|
||||||
|
project?: { id: string; name: string; shortCode: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function TimelineProjectPanel({
|
||||||
|
dragState,
|
||||||
|
allocDragState,
|
||||||
|
rangeState,
|
||||||
|
onProjectBarMouseDown,
|
||||||
|
onProjectBarTouchStart,
|
||||||
|
onAllocMouseDown,
|
||||||
|
onAllocTouchStart,
|
||||||
|
onRowMouseDown,
|
||||||
|
onRowTouchStart,
|
||||||
|
onOpenPanel,
|
||||||
|
onOpenDemandClick,
|
||||||
|
CELL_WIDTH,
|
||||||
|
dates,
|
||||||
|
totalCanvasWidth,
|
||||||
|
toLeft,
|
||||||
|
toWidth,
|
||||||
|
gridLines,
|
||||||
|
xToDate,
|
||||||
|
}: TimelineProjectPanelProps) {
|
||||||
|
const {
|
||||||
|
projectGroups,
|
||||||
|
openDemandsByProject,
|
||||||
|
allocsByResource,
|
||||||
|
vacationsByResource,
|
||||||
|
filters,
|
||||||
|
displayMode,
|
||||||
|
heatmapScheme,
|
||||||
|
activeFilterCount,
|
||||||
|
} = useTimelineContext();
|
||||||
|
|
||||||
|
// ─── Heatmap hover (same mechanism as resource panel) ─────────────────────
|
||||||
|
const heatmapRafRef = useRef<number | null>(null);
|
||||||
|
const lastHeatmapDayRef = useRef<number>(-1);
|
||||||
|
const vacationHoverRafRef = useRef<number | null>(null);
|
||||||
|
const hoveredVacationKeyRef = useRef<string | null>(null);
|
||||||
|
const pendingHeatmapRef = useRef<{ clientX: number; rect: DOMRect; allocs: TimelineAssignmentEntry[] } | null>(null);
|
||||||
|
|
||||||
|
const handleRowHeatmapMove = useCallback((e: React.MouseEvent, allocs: TimelineAssignmentEntry[]) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
|
||||||
|
if (dayIndex === lastHeatmapDayRef.current) return;
|
||||||
|
|
||||||
|
pendingHeatmapRef.current = { clientX: e.clientX, rect, allocs };
|
||||||
|
if (heatmapRafRef.current !== null) return;
|
||||||
|
|
||||||
|
heatmapRafRef.current = requestAnimationFrame(() => {
|
||||||
|
heatmapRafRef.current = null;
|
||||||
|
pendingHeatmapRef.current = null;
|
||||||
|
lastHeatmapDayRef.current = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
|
||||||
|
});
|
||||||
|
}, [CELL_WIDTH]);
|
||||||
|
|
||||||
|
const handleRowVacationHover = useCallback((_e: React.MouseEvent, _resourceId: string) => {
|
||||||
|
// Vacation hover in project view uses the same RAF mechanism.
|
||||||
|
// Tooltip rendering is handled by the parent TimelineView.
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearHoverTooltips = useCallback(() => {
|
||||||
|
if (heatmapRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(heatmapRafRef.current);
|
||||||
|
heatmapRafRef.current = null;
|
||||||
|
}
|
||||||
|
if (vacationHoverRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(vacationHoverRafRef.current);
|
||||||
|
vacationHoverRafRef.current = null;
|
||||||
|
}
|
||||||
|
lastHeatmapDayRef.current = -1;
|
||||||
|
hoveredVacationKeyRef.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (projectGroups.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-16 text-gray-400">
|
||||||
|
No projects in this time range{activeFilterCount > 0 && " (filtered)"}.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{projectGroups.map((project) => {
|
||||||
|
const colors = ORDER_TYPE_COLORS[project.orderType] ?? { bg: "bg-gray-400", text: "text-white", light: "bg-gray-50 border-gray-200" };
|
||||||
|
const isThisProjectShifting = dragState.isDragging && dragState.projectId === project.id;
|
||||||
|
const projDispStart = isThisProjectShifting && dragState.currentStartDate ? dragState.currentStartDate : project.startDate;
|
||||||
|
const projDispEnd = isThisProjectShifting && dragState.currentEndDate ? dragState.currentEndDate : project.endDate;
|
||||||
|
const projLeft = toLeft(projDispStart);
|
||||||
|
const projWidth = Math.max(CELL_WIDTH, toWidth(projDispStart, projDispEnd));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={project.id}>
|
||||||
|
{/* Project header row */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex border-b border-gray-200 group/proj",
|
||||||
|
colors.light,
|
||||||
|
)}
|
||||||
|
style={{ height: PROJECT_HEADER_HEIGHT }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex-shrink-0 border-r border-gray-300 flex items-center px-4 gap-2.5 sticky left-0 z-30 cursor-pointer",
|
||||||
|
colors.light,
|
||||||
|
)}
|
||||||
|
style={{ width: LABEL_WIDTH }}
|
||||||
|
onClick={() => onOpenPanel(project.id)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-gray-800 dark:text-gray-100 truncate">{project.name}</div>
|
||||||
|
<div className="text-xs text-gray-400 dark:text-gray-500">{project.status}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden"
|
||||||
|
style={{ width: totalCanvasWidth, height: PROJECT_HEADER_HEIGHT }}
|
||||||
|
>
|
||||||
|
{gridLines}
|
||||||
|
{projWidth > 0 && projLeft < totalCanvasWidth && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"absolute rounded flex items-center px-2 gap-1.5 transition-all duration-75",
|
||||||
|
isThisProjectShifting
|
||||||
|
? "opacity-90 shadow-lg ring-2 ring-white ring-offset-1 cursor-grabbing z-20 scale-[1.01]"
|
||||||
|
: "cursor-grab hover:opacity-90 hover:ring-2 hover:ring-white hover:ring-offset-1",
|
||||||
|
colors.bg, colors.text,
|
||||||
|
)}
|
||||||
|
style={{ left: projLeft + 2, width: projWidth - 4, top: 8, height: 24 }}
|
||||||
|
onClick={() => {
|
||||||
|
if (!dragState.isDragging) onOpenPanel(project.id);
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) =>
|
||||||
|
onProjectBarMouseDown(e, {
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.name,
|
||||||
|
startDate: project.startDate,
|
||||||
|
endDate: project.endDate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onTouchStart={(e) =>
|
||||||
|
onProjectBarTouchStart(e, {
|
||||||
|
projectId: project.id,
|
||||||
|
projectName: project.name,
|
||||||
|
startDate: project.startDate,
|
||||||
|
endDate: project.endDate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-semibold truncate">{project.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Open demand row */}
|
||||||
|
{(() => {
|
||||||
|
const openDemands = openDemandsByProject.get(project.id) ?? [];
|
||||||
|
return openDemands.length > 0
|
||||||
|
? renderOpenDemandRow(openDemands, CELL_WIDTH, totalCanvasWidth, toLeft, toWidth, gridLines, onOpenDemandClick)
|
||||||
|
: null;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Resource sub-rows */}
|
||||||
|
{project.resourceRows.map(({ resource, allocs }) => {
|
||||||
|
const allResourceAllocs = allocsByResource.get(resource.id) ?? [];
|
||||||
|
const rowHeight = ROW_HEIGHT;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${project.id}-${resource.id}`}
|
||||||
|
className="flex border-b border-gray-100 hover:bg-blue-50/20 group"
|
||||||
|
style={{ height: rowHeight }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 border-r border-gray-200 flex items-center pl-8 pr-4 gap-2 bg-white sticky left-0 z-30 group-hover:bg-blue-50"
|
||||||
|
style={{ width: LABEL_WIDTH }}
|
||||||
|
>
|
||||||
|
<div className="w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center text-[10px] font-bold text-gray-600 flex-shrink-0">
|
||||||
|
{resource.displayName.slice(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-medium text-gray-800 truncate">{resource.displayName}</div>
|
||||||
|
<div className="text-[10px] text-gray-400 truncate">{resource.eid}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden touch-none"
|
||||||
|
style={{ width: totalCanvasWidth, height: rowHeight, touchAction: "none" }}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const date = xToDate(e.clientX, rect);
|
||||||
|
onRowMouseDown(e, {
|
||||||
|
resourceId: resource.id,
|
||||||
|
startDate: date,
|
||||||
|
suggestedProjectId: project.id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onTouchStart={(e) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const date = xToDate(e.touches[0]?.clientX ?? 0, rect);
|
||||||
|
onRowTouchStart(e, {
|
||||||
|
resourceId: resource.id,
|
||||||
|
startDate: date,
|
||||||
|
suggestedProjectId: project.id,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onMouseMove={(e) => { handleRowHeatmapMove(e, allResourceAllocs); handleRowVacationHover(e, resource.id); }}
|
||||||
|
onMouseLeave={clearHoverTooltips}
|
||||||
|
>
|
||||||
|
{gridLines}
|
||||||
|
{renderProjectUtilBars(allocs, allResourceAllocs, dates, CELL_WIDTH, displayMode, heatmapScheme)}
|
||||||
|
{renderProjectDragHandles(allocs, allocDragState, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, onAllocMouseDown, onAllocTouchStart)}
|
||||||
|
{renderVacationBlocksForProjectRow(vacationsByResource.get(resource.id) ?? [], rowHeight, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, filters.showVacations)}
|
||||||
|
{renderRangeOverlayProject(rangeState, resource.id, rowHeight, toLeft, toWidth, CELL_WIDTH)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pure render functions ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderOpenDemandRow(
|
||||||
|
openDemands: TimelineDemandEntry[],
|
||||||
|
CELL_WIDTH: number,
|
||||||
|
totalCanvasWidth: number,
|
||||||
|
toLeft: (d: Date) => number,
|
||||||
|
toWidth: (s: Date, e: Date) => number,
|
||||||
|
gridLines: React.ReactNode,
|
||||||
|
onOpenDemandClick: (demand: OpenDemandAssignment) => void,
|
||||||
|
) {
|
||||||
|
if (openDemands.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex border-b border-dashed border-amber-200 bg-amber-50/30 hover:bg-amber-50/50 group"
|
||||||
|
style={{ height: ROW_HEIGHT }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 border-r border-amber-200 flex items-center pl-8 pr-4 gap-2 bg-amber-50 sticky left-0 z-30"
|
||||||
|
style={{ width: LABEL_WIDTH }}
|
||||||
|
>
|
||||||
|
<div className="w-6 h-6 rounded-full bg-amber-100 flex items-center justify-center text-[10px] font-bold text-amber-600 flex-shrink-0 border border-dashed border-amber-400">
|
||||||
|
?
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-medium text-amber-700 truncate">Open demand</div>
|
||||||
|
<div className="text-[10px] text-amber-500 truncate">{openDemands.length} open demand{openDemands.length > 1 ? "s" : ""}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden"
|
||||||
|
style={{ width: totalCanvasWidth, height: ROW_HEIGHT }}
|
||||||
|
>
|
||||||
|
{gridLines}
|
||||||
|
{openDemands.map((alloc) => {
|
||||||
|
const allocStart = new Date(alloc.startDate);
|
||||||
|
const allocEnd = new Date(alloc.endDate);
|
||||||
|
const left = toLeft(allocStart);
|
||||||
|
const width = Math.max(CELL_WIDTH, toWidth(allocStart, allocEnd));
|
||||||
|
if (width <= 0 || left >= totalCanvasWidth) return null;
|
||||||
|
|
||||||
|
const roleEntity = (alloc as { roleEntity?: { id: string; name: string; color: string | null } | null }).roleEntity;
|
||||||
|
const roleName = roleEntity?.name ?? (alloc as { role?: string | null }).role ?? "Open demand";
|
||||||
|
const roleColor = roleEntity?.color ?? "#f59e0b";
|
||||||
|
const headcount = (alloc as { headcount?: number }).headcount ?? 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={alloc.id}
|
||||||
|
className="absolute rounded-md flex items-center px-2 gap-1 overflow-hidden cursor-pointer hover:ring-2 hover:ring-amber-400 hover:ring-offset-1 z-[10]"
|
||||||
|
style={{
|
||||||
|
left: left + 2,
|
||||||
|
width: width - 4,
|
||||||
|
top: 8,
|
||||||
|
height: SUB_LANE_HEIGHT - 8,
|
||||||
|
backgroundColor: `${roleColor}33`,
|
||||||
|
border: `2px dashed ${roleColor}99`,
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
onOpenDemandClick({
|
||||||
|
id: getPlanningEntryMutationId(alloc),
|
||||||
|
projectId: alloc.projectId,
|
||||||
|
roleId: (alloc as { roleId?: string | null }).roleId ?? null,
|
||||||
|
role: (alloc as { role?: string | null }).role ?? null,
|
||||||
|
headcount,
|
||||||
|
startDate: allocStart,
|
||||||
|
endDate: allocEnd,
|
||||||
|
hoursPerDay: alloc.hoursPerDay,
|
||||||
|
roleEntity: roleEntity ?? null,
|
||||||
|
project: alloc.project as { id: string; name: string; shortCode: string },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-xs font-medium truncate" style={{ color: roleColor }}>
|
||||||
|
{roleName}{headcount > 1 ? ` x${headcount}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Project-view: per-resource utilisation band ────────────────────────────
|
||||||
|
|
||||||
|
function renderProjectUtilBars(
|
||||||
|
projectAllocs: TimelineAssignmentEntry[],
|
||||||
|
allResourceAllocs: TimelineAssignmentEntry[],
|
||||||
|
dates: Date[],
|
||||||
|
CELL_WIDTH: number,
|
||||||
|
displayMode: string,
|
||||||
|
heatmapScheme: string,
|
||||||
|
) {
|
||||||
|
const BAND_H = 7;
|
||||||
|
const BAR_H = ROW_HEIGHT - BAND_H - 11;
|
||||||
|
const REF_H = 8;
|
||||||
|
|
||||||
|
function hoursOnDay(list: TimelineAssignmentEntry[], t: number) {
|
||||||
|
return list.reduce((sum, a) => {
|
||||||
|
const s = new Date(a.startDate); s.setHours(0, 0, 0, 0);
|
||||||
|
const e = new Date(a.endDate); e.setHours(0, 0, 0, 0);
|
||||||
|
return t >= s.getTime() && t <= e.getTime() ? sum + a.hoursPerDay : sum;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates.map((date, i) => {
|
||||||
|
const t = date.getTime();
|
||||||
|
const projH = hoursOnDay(projectAllocs, t);
|
||||||
|
const totalH = hoursOnDay(allResourceAllocs, t);
|
||||||
|
if (totalH === 0 && projH === 0) return null;
|
||||||
|
|
||||||
|
const isOver = totalH > REF_H;
|
||||||
|
const totalBarH = Math.max(projH > 0 ? 2 : 0, Math.round((Math.min(totalH, REF_H) / REF_H) * BAR_H));
|
||||||
|
const projBarH = projH > 0 ? Math.min(totalBarH, Math.max(2, Math.round((projH / REF_H) * BAR_H))) : 0;
|
||||||
|
const otherBarH = totalBarH - projBarH;
|
||||||
|
|
||||||
|
const useHeatmapColors = displayMode === "bar";
|
||||||
|
const projPct = (projH / REF_H) * 100;
|
||||||
|
const totalPct = (totalH / REF_H) * 100;
|
||||||
|
const projColor = useHeatmapColors ? heatmapColor(projPct, heatmapScheme as import("~/hooks/useAppPreferences.js").HeatmapColorScheme, "bar") : null;
|
||||||
|
const totalColor = useHeatmapColors ? heatmapColor(totalPct, heatmapScheme as import("~/hooks/useAppPreferences.js").HeatmapColorScheme, "bar") : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment key={`putil-${i}`}>
|
||||||
|
{projH > 0 && (
|
||||||
|
<div
|
||||||
|
className={clsx("absolute top-1.5 pointer-events-none", !useHeatmapColors && "bg-brand-400/80")}
|
||||||
|
style={{ left: i * CELL_WIDTH + 1, width: CELL_WIDTH - 2, height: BAND_H, ...(projColor ? { backgroundColor: projColor } : {}) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{otherBarH > 0 && (
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"absolute pointer-events-none",
|
||||||
|
!useHeatmapColors && (isOver ? "bg-amber-300/80" : "bg-gray-300/80"),
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: i * CELL_WIDTH + 3, width: CELL_WIDTH - 6, height: otherBarH, bottom: 4 + projBarH,
|
||||||
|
...(useHeatmapColors ? { backgroundColor: totalColor ?? "rgba(156,163,175,0.50)" } : {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{projBarH > 0 && (
|
||||||
|
<div
|
||||||
|
className={clsx("absolute pointer-events-none", !useHeatmapColors && "bg-brand-500/80")}
|
||||||
|
style={{
|
||||||
|
left: i * CELL_WIDTH + 3, width: CELL_WIDTH - 6, height: projBarH, bottom: 4,
|
||||||
|
...(projColor ? { backgroundColor: projColor } : {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOver && totalBarH > 0 && (
|
||||||
|
<div
|
||||||
|
className="absolute pointer-events-none bg-red-500 z-10"
|
||||||
|
style={{ left: i * CELL_WIDTH + 3, width: CELL_WIDTH - 6, height: 3, bottom: 4 + totalBarH }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Project-view: transparent drag handles ─────────────────────────────────
|
||||||
|
|
||||||
|
function renderProjectDragHandles(
|
||||||
|
allocs: TimelineAssignmentEntry[],
|
||||||
|
allocDragState: AllocDragState,
|
||||||
|
toLeft: (d: Date) => number,
|
||||||
|
toWidth: (s: Date, e: Date) => number,
|
||||||
|
CELL_WIDTH: number,
|
||||||
|
totalCanvasWidth: number,
|
||||||
|
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
|
||||||
|
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
|
||||||
|
) {
|
||||||
|
return allocs.map((alloc) => {
|
||||||
|
const allocStart = new Date(alloc.startDate);
|
||||||
|
const allocEnd = new Date(alloc.endDate);
|
||||||
|
|
||||||
|
const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
|
||||||
|
const dispStart = isAllocDragged && allocDragState.currentStartDate ? allocDragState.currentStartDate : allocStart;
|
||||||
|
const dispEnd = isAllocDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : allocEnd;
|
||||||
|
|
||||||
|
const left = toLeft(dispStart);
|
||||||
|
const width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
|
||||||
|
if (width <= 0 || left >= totalCanvasWidth) return null;
|
||||||
|
|
||||||
|
const HANDLE_W = width >= 48 ? 8 : 0;
|
||||||
|
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence;
|
||||||
|
|
||||||
|
const allocInfo: AllocMouseDownInfo = {
|
||||||
|
mode: "move",
|
||||||
|
allocationId: alloc.id,
|
||||||
|
mutationAllocationId: getPlanningEntryMutationId(alloc),
|
||||||
|
projectId: alloc.projectId,
|
||||||
|
projectName: alloc.project.name,
|
||||||
|
resourceId: alloc.resourceId,
|
||||||
|
startDate: allocStart,
|
||||||
|
endDate: allocEnd,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`dh-${alloc.id}`}
|
||||||
|
className={clsx(
|
||||||
|
"absolute flex items-stretch rounded",
|
||||||
|
hasRecurrence && "border-2 border-dashed border-brand-400/60",
|
||||||
|
isAllocDragged
|
||||||
|
? "ring-2 ring-brand-400 z-20"
|
||||||
|
: "hover:ring-1 hover:ring-brand-300/70 z-[15]",
|
||||||
|
)}
|
||||||
|
style={{ left: left + 2, width: width - 4, top: 2, bottom: 2 }}
|
||||||
|
>
|
||||||
|
{HANDLE_W > 0 && (
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 cursor-ew-resize"
|
||||||
|
style={{ width: HANDLE_W }}
|
||||||
|
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
|
||||||
|
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" }); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={clsx("flex-1 min-w-0 flex items-center", isAllocDragged ? "cursor-grabbing" : "cursor-grab")}
|
||||||
|
onMouseDown={(e) => onAllocMouseDown(e, allocInfo)}
|
||||||
|
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, allocInfo); }}
|
||||||
|
>
|
||||||
|
{hasRecurrence && width > 28 && (
|
||||||
|
<span className="text-[10px] text-brand-600 opacity-70 pointer-events-none pl-1">↻</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{HANDLE_W > 0 && (
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 cursor-ew-resize"
|
||||||
|
style={{ width: HANDLE_W }}
|
||||||
|
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
|
||||||
|
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Vacation blocks for project view rows ──────────────────────────────────
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
ANNUAL: "bg-orange-400/40",
|
||||||
|
SICK: "bg-red-500/40",
|
||||||
|
PUBLIC_HOLIDAY: "bg-violet-400/40",
|
||||||
|
OTHER: "bg-amber-400/40",
|
||||||
|
};
|
||||||
|
const TYPE_BORDER: Record<string, string> = {
|
||||||
|
ANNUAL: "border-orange-500",
|
||||||
|
SICK: "border-red-600",
|
||||||
|
PUBLIC_HOLIDAY: "border-violet-500",
|
||||||
|
OTHER: "border-amber-500",
|
||||||
|
};
|
||||||
|
const TYPE_LABELS_SHORT: Record<string, string> = {
|
||||||
|
ANNUAL: "Annual",
|
||||||
|
SICK: "Sick",
|
||||||
|
PUBLIC_HOLIDAY: "Holiday",
|
||||||
|
OTHER: "Other",
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderVacationBlocksForProjectRow(
|
||||||
|
vacations: { id: string; type: string; startDate: Date | string; endDate: Date | string }[],
|
||||||
|
rowHeight: number,
|
||||||
|
toLeft: (d: Date) => number,
|
||||||
|
toWidth: (s: Date, e: Date) => number,
|
||||||
|
CELL_WIDTH: number,
|
||||||
|
totalCanvasWidth: number,
|
||||||
|
showVacations: boolean,
|
||||||
|
) {
|
||||||
|
if (!showVacations || vacations.length === 0) return null;
|
||||||
|
|
||||||
|
return vacations.map((v) => {
|
||||||
|
const vStart = new Date(v.startDate);
|
||||||
|
const vEnd = new Date(v.endDate);
|
||||||
|
const left = toLeft(vStart);
|
||||||
|
const width = Math.max(CELL_WIDTH, toWidth(vStart, vEnd));
|
||||||
|
if (width <= 0 || left >= totalCanvasWidth) return null;
|
||||||
|
|
||||||
|
const colorClass = TYPE_COLORS[v.type] ?? "bg-orange-400/40";
|
||||||
|
const borderClass = TYPE_BORDER[v.type] ?? "border-orange-500";
|
||||||
|
const label = TYPE_LABELS_SHORT[v.type] ?? v.type;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`vac-${v.id}`}
|
||||||
|
className={clsx(
|
||||||
|
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden border-t-2 pointer-events-none",
|
||||||
|
colorClass,
|
||||||
|
borderClass,
|
||||||
|
)}
|
||||||
|
style={{ left: left + 1, width: width - 2, top: 0, height: rowHeight }}
|
||||||
|
>
|
||||||
|
{width > 40 && (
|
||||||
|
<span className="text-[9px] font-bold truncate opacity-70 text-gray-700 dark:text-gray-200 pointer-events-none">
|
||||||
|
🏖 {label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Range overlay for project view ─────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderRangeOverlayProject(
|
||||||
|
rangeState: RangeState,
|
||||||
|
resourceId: string,
|
||||||
|
rowHeight: number,
|
||||||
|
toLeft: (d: Date) => number,
|
||||||
|
toWidth: (s: Date, e: Date) => number,
|
||||||
|
CELL_WIDTH: number,
|
||||||
|
) {
|
||||||
|
if (!rangeState.isSelecting || rangeState.resourceId !== resourceId || !rangeState.startDate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const end = rangeState.currentDate ?? rangeState.startDate;
|
||||||
|
const [selStart, selEnd] =
|
||||||
|
rangeState.startDate <= end
|
||||||
|
? [rangeState.startDate, end]
|
||||||
|
: [end, rangeState.startDate];
|
||||||
|
|
||||||
|
const left = toLeft(selStart);
|
||||||
|
const width = Math.max(CELL_WIDTH, toWidth(selStart, selEnd));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute bg-brand-200/40 border-2 border-brand-400 rounded pointer-events-none z-10"
|
||||||
|
style={{ left, width, top: 4, height: rowHeight - 8 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,830 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import { useTimelineContext, type TimelineAssignmentEntry, type VacationEntry } from "./TimelineContext.js";
|
||||||
|
import { ConflictOverlay } from "./ConflictOverlay.js";
|
||||||
|
import { computeSubLanes } from "./utils.js";
|
||||||
|
import { heatmapBgColor, heatmapColor } from "./heatmapUtils.js";
|
||||||
|
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.js";
|
||||||
|
import {
|
||||||
|
ROW_HEIGHT,
|
||||||
|
SUB_LANE_HEIGHT,
|
||||||
|
LABEL_WIDTH,
|
||||||
|
ORDER_TYPE_COLORS,
|
||||||
|
} from "./timelineConstants.js";
|
||||||
|
import type { DragState, AllocDragState, RangeState, ShiftPreviewData } from "~/hooks/useTimelineDrag.js";
|
||||||
|
import type { HeatmapColorScheme } from "~/hooks/useAppPreferences.js";
|
||||||
|
|
||||||
|
// ─── Props ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface TimelineResourcePanelProps {
|
||||||
|
scrollContainerRef: React.RefObject<HTMLDivElement | null>;
|
||||||
|
dragState: DragState;
|
||||||
|
allocDragState: AllocDragState;
|
||||||
|
rangeState: RangeState;
|
||||||
|
shiftPreview: ShiftPreviewData | null;
|
||||||
|
contextResourceIds: string[];
|
||||||
|
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void;
|
||||||
|
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void;
|
||||||
|
onRowMouseDown: (e: React.MouseEvent, info: RowMouseDownInfo) => void;
|
||||||
|
onRowTouchStart: (e: React.TouchEvent, info: RowMouseDownInfo) => void;
|
||||||
|
// Layout from useTimelineLayout
|
||||||
|
CELL_WIDTH: number;
|
||||||
|
dates: Date[];
|
||||||
|
totalCanvasWidth: number;
|
||||||
|
toLeft: (date: Date) => number;
|
||||||
|
toWidth: (start: Date, end: Date) => number;
|
||||||
|
gridLines: React.ReactNode;
|
||||||
|
xToDate: (clientX: number, rect: DOMRect) => Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AllocMouseDownInfo {
|
||||||
|
mode: "move" | "resize-start" | "resize-end";
|
||||||
|
allocationId: string;
|
||||||
|
mutationAllocationId: string;
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
resourceId: string | null;
|
||||||
|
startDate: Date;
|
||||||
|
endDate: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RowMouseDownInfo {
|
||||||
|
resourceId: string;
|
||||||
|
startDate: Date;
|
||||||
|
suggestedProjectId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Component ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function TimelineResourcePanel({
|
||||||
|
scrollContainerRef,
|
||||||
|
dragState,
|
||||||
|
allocDragState,
|
||||||
|
rangeState,
|
||||||
|
shiftPreview,
|
||||||
|
contextResourceIds,
|
||||||
|
onAllocMouseDown,
|
||||||
|
onAllocTouchStart,
|
||||||
|
onRowMouseDown,
|
||||||
|
onRowTouchStart,
|
||||||
|
CELL_WIDTH,
|
||||||
|
dates,
|
||||||
|
totalCanvasWidth,
|
||||||
|
toLeft,
|
||||||
|
toWidth,
|
||||||
|
gridLines,
|
||||||
|
xToDate,
|
||||||
|
}: TimelineResourcePanelProps) {
|
||||||
|
const {
|
||||||
|
resources,
|
||||||
|
allocsByResource,
|
||||||
|
vacationsByResource,
|
||||||
|
filters,
|
||||||
|
viewStart,
|
||||||
|
viewEnd,
|
||||||
|
displayMode,
|
||||||
|
heatmapScheme,
|
||||||
|
activeFilterCount,
|
||||||
|
} = useTimelineContext();
|
||||||
|
|
||||||
|
// ─── Heatmap hover state ────────────────────────────────────────────────────
|
||||||
|
const heatmapRafRef = useRef<number | null>(null);
|
||||||
|
const lastHeatmapDayRef = useRef<number>(-1);
|
||||||
|
const vacationHoverRafRef = useRef<number | null>(null);
|
||||||
|
const hoveredVacationKeyRef = useRef<string | null>(null);
|
||||||
|
const pendingHeatmapRef = useRef<{ clientX: number; rect: DOMRect; allocs: TimelineAssignmentEntry[] } | null>(null);
|
||||||
|
|
||||||
|
const [heatmapHover, setHeatmapHover] = useState<{
|
||||||
|
date: Date;
|
||||||
|
totalH: number;
|
||||||
|
pct: number;
|
||||||
|
breakdown: { projectId: string; shortCode: string; projectName: string; orderType: string; hoursPerDay: number; responsiblePerson?: string | null }[];
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const [vacationHover, setVacationHover] = useState<null | {
|
||||||
|
type: string; startDate: Date | string; endDate: Date | string; note?: string | null;
|
||||||
|
requestedBy?: { name?: string | null; email: string } | null;
|
||||||
|
approvedBy?: { name?: string | null; email: string } | null;
|
||||||
|
approvedAt?: Date | string | null;
|
||||||
|
}>(null);
|
||||||
|
|
||||||
|
// ─── Virtual row list ────────────────────────────────────────────────────────
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: resources.length,
|
||||||
|
getScrollElement: () => scrollContainerRef.current,
|
||||||
|
estimateSize: () => ROW_HEIGHT,
|
||||||
|
overscan: 5,
|
||||||
|
});
|
||||||
|
const virtualItems = rowVirtualizer.getVirtualItems();
|
||||||
|
const totalRowHeight = rowVirtualizer.getTotalSize();
|
||||||
|
|
||||||
|
// ─── Memo 1: resourceRows — which rows to render ─────────────────────────
|
||||||
|
// (virtualizer handles which subset is visible; this memo just pre-computes
|
||||||
|
// per-row data that the render loop needs)
|
||||||
|
const resourceRows = useMemo(() => {
|
||||||
|
return resources.map((resource) => {
|
||||||
|
const allocs = allocsByResource.get(resource.id) ?? [];
|
||||||
|
const isContextResource = contextResourceIds.includes(resource.id);
|
||||||
|
return { resource, allocs, isContextResource };
|
||||||
|
});
|
||||||
|
}, [resources, allocsByResource, contextResourceIds]);
|
||||||
|
|
||||||
|
// ─── Memo 2: vacationBlocks — vacation bar positions per resource ─────────
|
||||||
|
const vacationBlocksByResource = useMemo(() => {
|
||||||
|
if (!filters.showVacations) return new Map<string, VacationBlockInfo[]>();
|
||||||
|
|
||||||
|
const result = new Map<string, VacationBlockInfo[]>();
|
||||||
|
for (const [resourceId, vacations] of vacationsByResource) {
|
||||||
|
const blocks: VacationBlockInfo[] = [];
|
||||||
|
for (const v of vacations) {
|
||||||
|
const vStart = new Date(v.startDate);
|
||||||
|
const vEnd = new Date(v.endDate);
|
||||||
|
const left = toLeft(vStart);
|
||||||
|
const width = Math.max(CELL_WIDTH, toWidth(vStart, vEnd));
|
||||||
|
if (width <= 0 || left >= totalCanvasWidth) continue;
|
||||||
|
blocks.push({ vacation: v, left, width });
|
||||||
|
}
|
||||||
|
if (blocks.length > 0) {
|
||||||
|
result.set(resourceId, blocks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [vacationsByResource, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, filters.showVacations]);
|
||||||
|
|
||||||
|
// ─── Memo 3: assignmentBlocks — pre-computed per resource for strip mode ──
|
||||||
|
// (Bar mode computes differently per-day, so we only pre-compute for strip.)
|
||||||
|
const assignmentBlocksByResource = useMemo(() => {
|
||||||
|
if (displayMode === "bar") return new Map<string, { laneCount: number; blockData: AllocBlockData[] }>();
|
||||||
|
|
||||||
|
const result = new Map<string, { laneCount: number; blockData: AllocBlockData[] }>();
|
||||||
|
for (const { resource, allocs } of resourceRows) {
|
||||||
|
if (allocs.length === 0) continue;
|
||||||
|
|
||||||
|
const subLaneMap = computeSubLanes(
|
||||||
|
allocs.map((a) => ({ id: a.id, startDate: new Date(a.startDate), endDate: new Date(a.endDate) })),
|
||||||
|
);
|
||||||
|
const laneCount = subLaneMap.size > 0 ? Math.max(...subLaneMap.values()) + 1 : 1;
|
||||||
|
const blockData: AllocBlockData[] = allocs.map((alloc) => ({
|
||||||
|
alloc,
|
||||||
|
lane: subLaneMap.get(alloc.id) ?? 0,
|
||||||
|
}));
|
||||||
|
result.set(resource.id, { laneCount, blockData });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [displayMode, resourceRows]);
|
||||||
|
|
||||||
|
// ─── Heatmap row hover handler ────────────────────────────────────────────
|
||||||
|
const handleRowHeatmapMove = useCallback((e: React.MouseEvent, allocs: TimelineAssignmentEntry[]) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const dayIndex = Math.floor((e.clientX - rect.left) / CELL_WIDTH);
|
||||||
|
if (dayIndex === lastHeatmapDayRef.current) return;
|
||||||
|
|
||||||
|
pendingHeatmapRef.current = { clientX: e.clientX, rect, allocs };
|
||||||
|
if (heatmapRafRef.current !== null) return;
|
||||||
|
|
||||||
|
heatmapRafRef.current = requestAnimationFrame(() => {
|
||||||
|
heatmapRafRef.current = null;
|
||||||
|
const pending = pendingHeatmapRef.current;
|
||||||
|
pendingHeatmapRef.current = null;
|
||||||
|
if (!pending) return;
|
||||||
|
|
||||||
|
const { clientX, rect: r, allocs: a } = pending;
|
||||||
|
const dayIdx = Math.floor((clientX - r.left) / CELL_WIDTH);
|
||||||
|
const date = dates[dayIdx];
|
||||||
|
if (!date) {
|
||||||
|
lastHeatmapDayRef.current = -1;
|
||||||
|
startTransition(() => setHeatmapHover(null));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastHeatmapDayRef.current = dayIdx;
|
||||||
|
|
||||||
|
const t = date.getTime();
|
||||||
|
const REF_H = 8;
|
||||||
|
const projectHours = new Map<string, { shortCode: string; projectName: string; orderType: string; hours: number; responsiblePerson?: string | null }>();
|
||||||
|
for (const alloc of a) {
|
||||||
|
const s = new Date(alloc.startDate); s.setHours(0, 0, 0, 0);
|
||||||
|
const ev = new Date(alloc.endDate); ev.setHours(0, 0, 0, 0);
|
||||||
|
if (t < s.getTime() || t > ev.getTime()) continue;
|
||||||
|
const existing = projectHours.get(alloc.projectId);
|
||||||
|
if (existing) {
|
||||||
|
existing.hours += alloc.hoursPerDay;
|
||||||
|
} else {
|
||||||
|
projectHours.set(alloc.projectId, {
|
||||||
|
shortCode: alloc.project.shortCode,
|
||||||
|
projectName: alloc.project.name,
|
||||||
|
orderType: alloc.project.orderType,
|
||||||
|
hours: alloc.hoursPerDay,
|
||||||
|
responsiblePerson: (alloc.project as { responsiblePerson?: string | null }).responsiblePerson ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const breakdown = [...projectHours.entries()]
|
||||||
|
.map(([projectId, v]) => ({ projectId, ...v, hoursPerDay: v.hours }))
|
||||||
|
.sort((a, b) => b.hoursPerDay - a.hoursPerDay);
|
||||||
|
|
||||||
|
const totalH = breakdown.reduce((sum, b) => sum + b.hoursPerDay, 0);
|
||||||
|
startTransition(() => {
|
||||||
|
setHeatmapHover({ date, totalH, pct: (totalH / REF_H) * 100, breakdown });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [CELL_WIDTH, dates]);
|
||||||
|
|
||||||
|
// ─── Vacation hover ───────────────────────────────────────────────────────
|
||||||
|
const handleRowVacationHover = useCallback((e: React.MouseEvent, resourceId: string) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const clientX = e.clientX;
|
||||||
|
|
||||||
|
if (vacationHoverRafRef.current !== null) return;
|
||||||
|
|
||||||
|
vacationHoverRafRef.current = requestAnimationFrame(() => {
|
||||||
|
vacationHoverRafRef.current = null;
|
||||||
|
const date = xToDate(clientX, rect);
|
||||||
|
const t = date.getTime();
|
||||||
|
const resourceVacations = vacationsByResource.get(resourceId) ?? [];
|
||||||
|
const hit = resourceVacations.find((v) => {
|
||||||
|
const s = new Date(v.startDate); s.setHours(0, 0, 0, 0);
|
||||||
|
const end = new Date(v.endDate); end.setHours(0, 0, 0, 0);
|
||||||
|
return t >= s.getTime() && t <= end.getTime();
|
||||||
|
}) ?? null;
|
||||||
|
|
||||||
|
const nextKey = hit ? `${resourceId}:${hit.id}` : null;
|
||||||
|
if (nextKey === hoveredVacationKeyRef.current) return;
|
||||||
|
|
||||||
|
hoveredVacationKeyRef.current = nextKey;
|
||||||
|
startTransition(() => {
|
||||||
|
setVacationHover(hit);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [vacationsByResource, xToDate]);
|
||||||
|
|
||||||
|
const clearHoverTooltips = useCallback(() => {
|
||||||
|
if (heatmapRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(heatmapRafRef.current);
|
||||||
|
heatmapRafRef.current = null;
|
||||||
|
}
|
||||||
|
if (vacationHoverRafRef.current !== null) {
|
||||||
|
cancelAnimationFrame(vacationHoverRafRef.current);
|
||||||
|
vacationHoverRafRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldClearHeatmap = lastHeatmapDayRef.current !== -1;
|
||||||
|
const shouldClearVacation = hoveredVacationKeyRef.current !== null;
|
||||||
|
|
||||||
|
lastHeatmapDayRef.current = -1;
|
||||||
|
hoveredVacationKeyRef.current = null;
|
||||||
|
|
||||||
|
if (shouldClearHeatmap || shouldClearVacation) {
|
||||||
|
startTransition(() => {
|
||||||
|
if (shouldClearHeatmap) setHeatmapHover(null);
|
||||||
|
if (shouldClearVacation) setVacationHover(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ─── Cleanup rAF on unmount ───────────────────────────────────────────────
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (heatmapRafRef.current !== null) cancelAnimationFrame(heatmapRafRef.current);
|
||||||
|
if (vacationHoverRafRef.current !== null) cancelAnimationFrame(vacationHoverRafRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ─── Render helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (resources.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-16 text-gray-400">
|
||||||
|
No allocations in this time range{activeFilterCount > 0 && " (filtered)"}.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: totalRowHeight,
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{virtualItems.map((virtualRow) => {
|
||||||
|
const rowData = resourceRows[virtualRow.index];
|
||||||
|
if (!rowData) return null;
|
||||||
|
const { resource, allocs, isContextResource } = rowData;
|
||||||
|
const inBarMode = displayMode === "bar";
|
||||||
|
const precomputed = assignmentBlocksByResource.get(resource.id);
|
||||||
|
const laneCount = inBarMode ? 1 : (precomputed?.laneCount ?? 1);
|
||||||
|
const rowHeight = inBarMode ? ROW_HEIGHT : Math.max(ROW_HEIGHT, laneCount * SUB_LANE_HEIGHT + 16);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={resource.id}
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
ref={rowVirtualizer.measureElement}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: "100%",
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex border-b border-gray-100 hover:bg-blue-50/20 group transition-colors",
|
||||||
|
dragState.isDragging && isContextResource && "border-l-4 border-l-brand-400",
|
||||||
|
)}
|
||||||
|
style={{ height: rowHeight }}
|
||||||
|
>
|
||||||
|
{/* Label column */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex-shrink-0 border-r border-gray-200 flex items-center px-4 gap-2.5 bg-white sticky left-0 z-30 group-hover:bg-blue-50",
|
||||||
|
dragState.isDragging && isContextResource && "bg-brand-50",
|
||||||
|
)}
|
||||||
|
style={{ width: LABEL_WIDTH }}
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-brand-100 flex items-center justify-center text-xs font-bold text-brand-700 flex-shrink-0">
|
||||||
|
{resource.displayName.slice(0, 2).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-900 truncate">{resource.displayName}</div>
|
||||||
|
<div className="text-xs text-gray-400 truncate">{resource.chapter ?? resource.eid}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row canvas */}
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden touch-none"
|
||||||
|
style={{ width: totalCanvasWidth, height: rowHeight, touchAction: "none" }}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const date = xToDate(e.clientX, rect);
|
||||||
|
onRowMouseDown(e, { resourceId: resource.id, startDate: date });
|
||||||
|
}}
|
||||||
|
onTouchStart={(e) => {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const date = xToDate(e.touches[0]?.clientX ?? 0, rect);
|
||||||
|
onRowTouchStart(e, { resourceId: resource.id, startDate: date });
|
||||||
|
}}
|
||||||
|
onMouseMove={(e) => { handleRowHeatmapMove(e, allocs); handleRowVacationHover(e, resource.id); }}
|
||||||
|
onMouseLeave={clearHoverTooltips}
|
||||||
|
>
|
||||||
|
{gridLines}
|
||||||
|
{inBarMode
|
||||||
|
? renderDailyBars(allocs, rowHeight, CELL_WIDTH, dates, allocDragState, onAllocMouseDown, onAllocTouchStart, toLeft, toWidth, totalCanvasWidth)
|
||||||
|
: renderAllocBlocksFromData(precomputed?.blockData ?? [], allocs, dragState, allocDragState, toLeft, toWidth, CELL_WIDTH, totalCanvasWidth, onAllocMouseDown, onAllocTouchStart)}
|
||||||
|
{renderVacationBlocksForRow(vacationBlocksByResource.get(resource.id) ?? [], rowHeight)}
|
||||||
|
{displayMode === "strip" && renderLoadGraph(allocs, dates, CELL_WIDTH)}
|
||||||
|
{displayMode === "heatmap" && renderHeatmapOverlay(allocs, dates, CELL_WIDTH, heatmapScheme)}
|
||||||
|
{renderRangeOverlay(rangeState, resource.id, rowHeight, toLeft, toWidth, CELL_WIDTH)}
|
||||||
|
|
||||||
|
{dragState.isDragging && dragState.projectId && shiftPreview && !shiftPreview.valid && shiftPreview.conflictCount > 0 && allocs.some((a) => a.projectId === dragState.projectId) && (
|
||||||
|
<ConflictOverlay
|
||||||
|
left={toLeft(dragState.currentStartDate ?? viewStart) + 2}
|
||||||
|
width={toWidth(dragState.currentStartDate ?? viewStart, dragState.currentEndDate ?? viewEnd) - 4}
|
||||||
|
height={rowHeight - 8}
|
||||||
|
type="availability"
|
||||||
|
message={`${shiftPreview.conflictCount} conflict(s)`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Tooltips rendered inside the panel so they live near their data source */}
|
||||||
|
<ResourcePanelTooltips
|
||||||
|
heatmapHover={heatmapHover}
|
||||||
|
vacationHover={vacationHover}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tooltip sub-component (portal-free: positioned fixed) ──────────────────
|
||||||
|
|
||||||
|
function ResourcePanelTooltips({
|
||||||
|
heatmapHover,
|
||||||
|
vacationHover,
|
||||||
|
}: {
|
||||||
|
heatmapHover: {
|
||||||
|
date: Date;
|
||||||
|
totalH: number;
|
||||||
|
pct: number;
|
||||||
|
breakdown: { projectId: string; shortCode: string; projectName: string; orderType: string; hoursPerDay: number; responsiblePerson?: string | null }[];
|
||||||
|
} | null;
|
||||||
|
vacationHover: {
|
||||||
|
type: string; startDate: Date | string; endDate: Date | string; note?: string | null;
|
||||||
|
requestedBy?: { name?: string | null; email: string } | null;
|
||||||
|
approvedBy?: { name?: string | null; email: string } | null;
|
||||||
|
approvedAt?: Date | string | null;
|
||||||
|
} | null;
|
||||||
|
}) {
|
||||||
|
// These tooltips are rendered here but positioned by the parent's native
|
||||||
|
// mousemove handler via ref. The parent passes tooltip refs via the
|
||||||
|
// TimelineView orchestrator. For simplicity, we keep the tooltip DOM
|
||||||
|
// here but expose ref-based positioning from the parent via
|
||||||
|
// data-attributes that the parent's mousemove handler targets.
|
||||||
|
//
|
||||||
|
// NOTE: The actual positioning is still done by the parent TimelineView's
|
||||||
|
// native mousemove event handler using refs. These tooltips are rendered
|
||||||
|
// inside TimelineView's return, not here. This sub-component is a no-op
|
||||||
|
// for tooltip DOM — the parent handles it.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helper types ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface VacationBlockInfo {
|
||||||
|
vacation: VacationEntry;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AllocBlockData {
|
||||||
|
alloc: TimelineAssignmentEntry;
|
||||||
|
lane: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Pure render functions (no hooks, extracted from TimelineView) ───────────
|
||||||
|
|
||||||
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
|
ANNUAL: "bg-orange-400/40",
|
||||||
|
SICK: "bg-red-500/40",
|
||||||
|
PUBLIC_HOLIDAY: "bg-violet-400/40",
|
||||||
|
OTHER: "bg-amber-400/40",
|
||||||
|
};
|
||||||
|
const TYPE_BORDER: Record<string, string> = {
|
||||||
|
ANNUAL: "border-orange-500",
|
||||||
|
SICK: "border-red-600",
|
||||||
|
PUBLIC_HOLIDAY: "border-violet-500",
|
||||||
|
OTHER: "border-amber-500",
|
||||||
|
};
|
||||||
|
const TYPE_LABELS_SHORT: Record<string, string> = {
|
||||||
|
ANNUAL: "Annual",
|
||||||
|
SICK: "Sick",
|
||||||
|
PUBLIC_HOLIDAY: "Holiday",
|
||||||
|
OTHER: "Other",
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderVacationBlocksForRow(blocks: VacationBlockInfo[], rowHeight: number) {
|
||||||
|
if (blocks.length === 0) return null;
|
||||||
|
|
||||||
|
return blocks.map(({ vacation: v, left, width }) => {
|
||||||
|
const colorClass = TYPE_COLORS[v.type] ?? "bg-orange-400/40";
|
||||||
|
const borderClass = TYPE_BORDER[v.type] ?? "border-orange-500";
|
||||||
|
const label = TYPE_LABELS_SHORT[v.type] ?? v.type;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`vac-${v.id}`}
|
||||||
|
className={clsx(
|
||||||
|
"absolute z-[5] flex items-end px-1 pb-0.5 overflow-hidden border-t-2 pointer-events-none",
|
||||||
|
colorClass,
|
||||||
|
borderClass,
|
||||||
|
)}
|
||||||
|
style={{ left: left + 1, width: width - 2, top: 0, height: rowHeight }}
|
||||||
|
>
|
||||||
|
{width > 40 && (
|
||||||
|
<span className="text-[9px] font-bold truncate opacity-70 text-gray-700 dark:text-gray-200 pointer-events-none">
|
||||||
|
🏖 {label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRangeOverlay(
|
||||||
|
rangeState: RangeState,
|
||||||
|
resourceId: string,
|
||||||
|
rowHeight: number,
|
||||||
|
toLeft: (d: Date) => number,
|
||||||
|
toWidth: (s: Date, e: Date) => number,
|
||||||
|
CELL_WIDTH: number,
|
||||||
|
) {
|
||||||
|
if (!rangeState.isSelecting || rangeState.resourceId !== resourceId || !rangeState.startDate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const end = rangeState.currentDate ?? rangeState.startDate;
|
||||||
|
const [selStart, selEnd] =
|
||||||
|
rangeState.startDate <= end
|
||||||
|
? [rangeState.startDate, end]
|
||||||
|
: [end, rangeState.startDate];
|
||||||
|
|
||||||
|
const left = toLeft(selStart);
|
||||||
|
const width = Math.max(CELL_WIDTH, toWidth(selStart, selEnd));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute bg-brand-200/40 border-2 border-brand-400 rounded pointer-events-none z-10"
|
||||||
|
style={{ left, width, top: 4, height: rowHeight - 8 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAllocBlocksFromData(
|
||||||
|
blockData: AllocBlockData[],
|
||||||
|
_allocs: TimelineAssignmentEntry[],
|
||||||
|
dragState: DragState,
|
||||||
|
allocDragState: AllocDragState,
|
||||||
|
toLeft: (d: Date) => number,
|
||||||
|
toWidth: (s: Date, e: Date) => number,
|
||||||
|
CELL_WIDTH: number,
|
||||||
|
totalCanvasWidth: number,
|
||||||
|
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
|
||||||
|
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
|
||||||
|
) {
|
||||||
|
const anyDragActive = dragState.isDragging || allocDragState.isActive;
|
||||||
|
|
||||||
|
return blockData.map(({ alloc, lane }) => {
|
||||||
|
const allocStart = new Date(alloc.startDate);
|
||||||
|
const allocEnd = new Date(alloc.endDate);
|
||||||
|
|
||||||
|
const isProjectShifted = dragState.isDragging && dragState.projectId === alloc.projectId;
|
||||||
|
const isAllocDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
|
||||||
|
const isBeingDragged = isProjectShifted || isAllocDragged;
|
||||||
|
const isOtherDragged = anyDragActive && !isBeingDragged;
|
||||||
|
|
||||||
|
let dispStart = allocStart;
|
||||||
|
let dispEnd = allocEnd;
|
||||||
|
if (isProjectShifted && dragState.currentStartDate && dragState.currentEndDate) {
|
||||||
|
dispStart = dragState.currentStartDate;
|
||||||
|
dispEnd = dragState.currentEndDate;
|
||||||
|
} else if (isAllocDragged && allocDragState.currentStartDate && allocDragState.currentEndDate) {
|
||||||
|
dispStart = allocDragState.currentStartDate;
|
||||||
|
dispEnd = allocDragState.currentEndDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const left = toLeft(dispStart);
|
||||||
|
const width = Math.max(CELL_WIDTH, toWidth(dispStart, dispEnd));
|
||||||
|
if (width <= 0 || left >= totalCanvasWidth) return null;
|
||||||
|
|
||||||
|
const blockTop = 8 + lane * SUB_LANE_HEIGHT;
|
||||||
|
const blockHeight = SUB_LANE_HEIGHT - 8;
|
||||||
|
|
||||||
|
const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? { bg: "bg-gray-400", text: "text-white", light: "" };
|
||||||
|
const HANDLE_W = width >= 48 ? 10 : 0;
|
||||||
|
const hasRecurrence = !!(alloc.metadata as Record<string, unknown> | null)?.recurrence;
|
||||||
|
|
||||||
|
const allocInfo: AllocMouseDownInfo = {
|
||||||
|
mode: "move",
|
||||||
|
allocationId: alloc.id,
|
||||||
|
mutationAllocationId: getPlanningEntryMutationId(alloc),
|
||||||
|
projectId: alloc.projectId,
|
||||||
|
projectName: alloc.project.name,
|
||||||
|
resourceId: alloc.resourceId,
|
||||||
|
startDate: allocStart,
|
||||||
|
endDate: allocEnd,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={alloc.id}
|
||||||
|
className={clsx(
|
||||||
|
"absolute rounded-md flex items-stretch overflow-hidden transition-all duration-75 group/block",
|
||||||
|
colors.bg, colors.text,
|
||||||
|
hasRecurrence && "opacity-80 border-2 border-dashed border-white/60",
|
||||||
|
isBeingDragged
|
||||||
|
? "opacity-90 shadow-2xl ring-2 ring-white ring-offset-1 z-20 scale-[1.01]"
|
||||||
|
: isOtherDragged
|
||||||
|
? "opacity-30 z-[10]"
|
||||||
|
: "hover:ring-2 hover:ring-white hover:ring-offset-1 z-[10]",
|
||||||
|
)}
|
||||||
|
style={{ left: left + 2, width: width - 4, top: blockTop, height: blockHeight }}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{/* Left resize handle */}
|
||||||
|
{HANDLE_W > 0 && (
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 flex items-center justify-center cursor-ew-resize hover:bg-black/20 transition-colors"
|
||||||
|
style={{ width: HANDLE_W }}
|
||||||
|
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" })}
|
||||||
|
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" }); }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0.5 opacity-60 group-hover/block:opacity-100">
|
||||||
|
<div className="w-px h-2.5 bg-white rounded" />
|
||||||
|
<div className="w-px h-2.5 bg-white rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Center -- move */}
|
||||||
|
<div
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 flex items-center gap-1 px-1 min-w-0 select-none",
|
||||||
|
isBeingDragged ? "cursor-grabbing" : "cursor-grab",
|
||||||
|
)}
|
||||||
|
onMouseDown={(e) => onAllocMouseDown(e, allocInfo)}
|
||||||
|
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, allocInfo); }}
|
||||||
|
>
|
||||||
|
{hasRecurrence && width > 28 && <span className="text-[10px] opacity-80 flex-shrink-0">↻</span>}
|
||||||
|
<span className="text-xs font-semibold truncate">{alloc.project.name}</span>
|
||||||
|
{width > 130 && <span className="text-[10px] opacity-75 truncate">{alloc.role}</span>}
|
||||||
|
{width > 190 && <span className="text-[10px] opacity-60 truncate">{alloc.hoursPerDay}h</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right resize handle */}
|
||||||
|
{HANDLE_W > 0 && (
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 flex items-center justify-center cursor-ew-resize hover:bg-black/20 transition-colors"
|
||||||
|
style={{ width: HANDLE_W }}
|
||||||
|
onMouseDown={(e) => onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" })}
|
||||||
|
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-0.5 opacity-60 group-hover/block:opacity-100">
|
||||||
|
<div className="w-px h-2.5 bg-white rounded" />
|
||||||
|
<div className="w-px h-2.5 bg-white rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Strip-mode: daily load graph ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderLoadGraph(allocs: TimelineAssignmentEntry[], dates: Date[], CELL_WIDTH: number) {
|
||||||
|
const GRAPH_H = 12;
|
||||||
|
const REF_H = 8;
|
||||||
|
|
||||||
|
function hoursOnDay(list: TimelineAssignmentEntry[], t: number) {
|
||||||
|
return list.reduce((sum, a) => {
|
||||||
|
const s = new Date(a.startDate); s.setHours(0, 0, 0, 0);
|
||||||
|
const e = new Date(a.endDate); e.setHours(0, 0, 0, 0);
|
||||||
|
return t >= s.getTime() && t <= e.getTime() ? sum + a.hoursPerDay : sum;
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute inset-x-0 bottom-1 pointer-events-none"
|
||||||
|
style={{ height: GRAPH_H }}
|
||||||
|
>
|
||||||
|
{dates.map((date, i) => {
|
||||||
|
const t = date.getTime();
|
||||||
|
const totalH = hoursOnDay(allocs, t);
|
||||||
|
if (totalH === 0) return null;
|
||||||
|
|
||||||
|
const totalBarH = Math.min(GRAPH_H, Math.round((totalH / REF_H) * GRAPH_H));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={clsx(
|
||||||
|
"absolute bottom-0 rounded-t-sm",
|
||||||
|
totalH > 12 ? "bg-red-500 opacity-80"
|
||||||
|
: totalH > 8 ? "bg-amber-400 opacity-80"
|
||||||
|
: "bg-brand-500 opacity-80",
|
||||||
|
)}
|
||||||
|
style={{ left: i * CELL_WIDTH + 3, width: CELL_WIDTH - 6, height: totalBarH }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Heatmap-mode: utilisation colour overlay ────────────────────────────────
|
||||||
|
|
||||||
|
function renderHeatmapOverlay(allocs: TimelineAssignmentEntry[], dates: Date[], CELL_WIDTH: number, heatmapScheme: HeatmapColorScheme) {
|
||||||
|
const REF_H = 8;
|
||||||
|
return dates.map((date, i) => {
|
||||||
|
const t = date.getTime();
|
||||||
|
const totalH = allocs.reduce((sum, a) => {
|
||||||
|
const s = new Date(a.startDate); s.setHours(0, 0, 0, 0);
|
||||||
|
const e = new Date(a.endDate); e.setHours(0, 0, 0, 0);
|
||||||
|
return t >= s.getTime() && t <= e.getTime() ? sum + a.hoursPerDay : sum;
|
||||||
|
}, 0);
|
||||||
|
const bg = heatmapBgColor((totalH / REF_H) * 100, heatmapScheme);
|
||||||
|
if (!bg) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`hm-${i}`}
|
||||||
|
className="absolute top-0 bottom-0 pointer-events-none z-10"
|
||||||
|
style={{ left: i * CELL_WIDTH, width: CELL_WIDTH, backgroundColor: bg }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bar-mode: stacked daily bars ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function renderDailyBars(
|
||||||
|
allocs: TimelineAssignmentEntry[],
|
||||||
|
rowHeight: number,
|
||||||
|
CELL_WIDTH: number,
|
||||||
|
dates: Date[],
|
||||||
|
allocDragState: AllocDragState,
|
||||||
|
onAllocMouseDown: (e: React.MouseEvent, info: AllocMouseDownInfo) => void,
|
||||||
|
onAllocTouchStart: (e: React.TouchEvent, info: AllocMouseDownInfo) => void,
|
||||||
|
toLeft: (d: Date) => number,
|
||||||
|
toWidth: (s: Date, e: Date) => number,
|
||||||
|
totalCanvasWidth: number,
|
||||||
|
) {
|
||||||
|
const BAR_AREA = rowHeight - 8;
|
||||||
|
const REF_H = 8;
|
||||||
|
|
||||||
|
return dates.flatMap((date, i) => {
|
||||||
|
const t = date.getTime();
|
||||||
|
|
||||||
|
const covering = allocs.filter((a) => {
|
||||||
|
const isDragged = allocDragState.isActive && allocDragState.allocationId === a.id;
|
||||||
|
const s = new Date(isDragged && allocDragState.currentStartDate ? allocDragState.currentStartDate : a.startDate);
|
||||||
|
const e = new Date(isDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : a.endDate);
|
||||||
|
s.setHours(0, 0, 0, 0); e.setHours(0, 0, 0, 0);
|
||||||
|
return t >= s.getTime() && t <= e.getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (covering.length === 0) return [];
|
||||||
|
|
||||||
|
const totalH = covering.reduce((sum, a) => sum + a.hoursPerDay, 0);
|
||||||
|
const isOver = totalH > REF_H;
|
||||||
|
let stackedH = 0;
|
||||||
|
|
||||||
|
const segs: React.ReactNode[] = covering.map((alloc) => {
|
||||||
|
const colors = ORDER_TYPE_COLORS[alloc.project.orderType] ?? { bg: "bg-gray-400", text: "text-white", light: "" };
|
||||||
|
const segH = Math.max(2, Math.min(
|
||||||
|
BAR_AREA - stackedH,
|
||||||
|
Math.round((alloc.hoursPerDay / REF_H) * BAR_AREA),
|
||||||
|
));
|
||||||
|
const bottom = 4 + stackedH;
|
||||||
|
stackedH += segH;
|
||||||
|
const isBeingDragged = allocDragState.isActive && allocDragState.allocationId === alloc.id;
|
||||||
|
|
||||||
|
const dispStart = new Date(isBeingDragged && allocDragState.currentStartDate ? allocDragState.currentStartDate : alloc.startDate);
|
||||||
|
const dispEnd = new Date(isBeingDragged && allocDragState.currentEndDate ? allocDragState.currentEndDate : alloc.endDate);
|
||||||
|
dispStart.setHours(0, 0, 0, 0); dispEnd.setHours(0, 0, 0, 0);
|
||||||
|
const isFirstDay = t === dispStart.getTime();
|
||||||
|
const isLastDay = t === dispEnd.getTime();
|
||||||
|
const EDGE_W = CELL_WIDTH >= 16 ? 4 : 0;
|
||||||
|
|
||||||
|
const allocInfo: AllocMouseDownInfo = {
|
||||||
|
mode: "move",
|
||||||
|
allocationId: alloc.id,
|
||||||
|
mutationAllocationId: getPlanningEntryMutationId(alloc),
|
||||||
|
projectId: alloc.projectId,
|
||||||
|
projectName: alloc.project.name,
|
||||||
|
resourceId: alloc.resourceId,
|
||||||
|
startDate: new Date(alloc.startDate),
|
||||||
|
endDate: new Date(alloc.endDate),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`bar-${i}-${alloc.id}`}
|
||||||
|
className={clsx(
|
||||||
|
"absolute rounded-sm transition-all duration-75 flex items-stretch overflow-hidden",
|
||||||
|
colors.bg,
|
||||||
|
isBeingDragged
|
||||||
|
? "opacity-90 ring-2 ring-white ring-offset-1 z-20"
|
||||||
|
: "hover:opacity-80 z-[10]",
|
||||||
|
)}
|
||||||
|
style={{ left: i * CELL_WIDTH + 2, width: CELL_WIDTH - 4, height: segH, bottom }}
|
||||||
|
onContextMenu={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
{isFirstDay && EDGE_W > 0 && (
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 cursor-ew-resize hover:bg-black/20"
|
||||||
|
style={{ width: EDGE_W }}
|
||||||
|
onMouseDown={(e) => { e.stopPropagation(); onAllocMouseDown(e, { ...allocInfo, mode: "resize-start" }); }}
|
||||||
|
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-start" }); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={clsx("flex-1 min-w-0", "cursor-grab")}
|
||||||
|
onMouseDown={(e) => { e.stopPropagation(); onAllocMouseDown(e, allocInfo); }}
|
||||||
|
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, allocInfo); }}
|
||||||
|
/>
|
||||||
|
{isLastDay && EDGE_W > 0 && (
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 cursor-ew-resize hover:bg-black/20"
|
||||||
|
style={{ width: EDGE_W }}
|
||||||
|
onMouseDown={(e) => { e.stopPropagation(); onAllocMouseDown(e, { ...allocInfo, mode: "resize-end" }); }}
|
||||||
|
onTouchStart={(e) => { e.stopPropagation(); onAllocTouchStart(e, { ...allocInfo, mode: "resize-end" }); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isOver) {
|
||||||
|
segs.push(
|
||||||
|
<div
|
||||||
|
key={`bar-${i}-over`}
|
||||||
|
className="absolute bg-red-500/70 rounded-t-sm pointer-events-none z-30"
|
||||||
|
style={{ left: i * CELL_WIDTH + 2, width: CELL_WIDTH - 4, top: 4, height: 3 }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return segs;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Re-export tooltip types for the parent ─────────────────────────────────
|
||||||
|
export type { VacationBlockInfo, AllocBlockData };
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -7,20 +7,7 @@ import { VacationModal } from "./VacationModal.js";
|
|||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
import { BalanceCard } from "./BalanceCard.js";
|
import { BalanceCard } from "./BalanceCard.js";
|
||||||
import { VacationCalendar } from "./VacationCalendar.js";
|
import { VacationCalendar } from "./VacationCalendar.js";
|
||||||
|
import { VACATION_STATUS_BADGE as STATUS_BADGE, VACATION_TYPE_LABELS as TYPE_LABELS } from "~/lib/status-styles.js";
|
||||||
const STATUS_BADGE: Record<string, string> = {
|
|
||||||
PENDING: "bg-amber-100 text-amber-700 dark:bg-yellow-900/30 dark:text-yellow-400",
|
|
||||||
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-green-900/30 dark:text-green-400",
|
|
||||||
REJECTED: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
|
||||||
CANCELLED: "bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
const TYPE_LABELS: Record<string, string> = {
|
|
||||||
ANNUAL: "Annual Leave",
|
|
||||||
SICK: "Sick Leave",
|
|
||||||
PUBLIC_HOLIDAY: "Public Holiday",
|
|
||||||
OTHER: "Other",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function MyVacationsClient() {
|
export function MyVacationsClient() {
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|||||||
@@ -10,20 +10,7 @@ import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
|||||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||||
|
import { VACATION_STATUS_BADGE as STATUS_BADGE, VACATION_TYPE_LABELS as TYPE_LABELS } from "~/lib/status-styles.js";
|
||||||
const STATUS_BADGE: Record<VacationStatus, string> = {
|
|
||||||
PENDING: "bg-amber-100 text-amber-700 dark:bg-yellow-900/30 dark:text-yellow-400",
|
|
||||||
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-green-900/30 dark:text-green-400",
|
|
||||||
REJECTED: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
|
||||||
CANCELLED: "bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
const TYPE_LABELS: Record<VacationType, string> = {
|
|
||||||
ANNUAL: "Annual Leave",
|
|
||||||
SICK: "Sick Leave",
|
|
||||||
PUBLIC_HOLIDAY: "Public Holiday",
|
|
||||||
OTHER: "Other",
|
|
||||||
};
|
|
||||||
|
|
||||||
type VacationStatusFilter = VacationStatus | "ALL";
|
type VacationStatusFilter = VacationStatus | "ALL";
|
||||||
type VacationTypeFilter = VacationType | "ALL";
|
type VacationTypeFilter = VacationType | "ALL";
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { trpc } from "~/lib/trpc/client.js";
|
||||||
|
|
||||||
|
export function useInvalidatePlanningViews() {
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
void utils.allocation.list.invalidate();
|
||||||
|
void (
|
||||||
|
utils as {
|
||||||
|
allocation: { listView: { invalidate: () => Promise<unknown> } };
|
||||||
|
}
|
||||||
|
).allocation.listView.invalidate();
|
||||||
|
void utils.allocation.listDemands.invalidate();
|
||||||
|
void utils.allocation.listAssignments.invalidate();
|
||||||
|
void utils.timeline.getEntries.invalidate();
|
||||||
|
void utils.timeline.getEntriesView.invalidate();
|
||||||
|
void utils.timeline.getProjectContext.invalidate();
|
||||||
|
void utils.timeline.getBudgetStatus.invalidate();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -27,3 +27,21 @@ export function formatMonthYear(d: Date | string): string {
|
|||||||
export function formatDateLong(d: Date | string): string {
|
export function formatDateLong(d: Date | string): string {
|
||||||
return new Date(d).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" });
|
return new Date(d).toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format integer cents as a currency string (e.g. "1.234 €").
|
||||||
|
* Defaults to EUR with no decimal places.
|
||||||
|
*/
|
||||||
|
export function formatMoney(cents: number | null | undefined, currency = "EUR"): string {
|
||||||
|
const value = (cents ?? 0) / 100;
|
||||||
|
return new Intl.NumberFormat("de-DE", { style: "currency", currency, maximumFractionDigits: 0 }).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format integer cents as a plain decimal string (e.g. "12,34").
|
||||||
|
* Returns "-" for null/undefined.
|
||||||
|
*/
|
||||||
|
export function formatCents(cents: number | null | undefined): string {
|
||||||
|
if (cents == null) return "-";
|
||||||
|
return (cents / 100).toLocaleString("de-DE", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* Consolidated badge / pill style maps for status and type columns.
|
||||||
|
* Single source of truth — imported by every table that renders status badges.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ALLOCATION_STATUS_BADGE: Record<string, string> = {
|
||||||
|
ACTIVE: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||||
|
PROPOSED: "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
|
||||||
|
CONFIRMED: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||||
|
COMPLETED: "bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400",
|
||||||
|
CANCELLED: "bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VACATION_STATUS_BADGE: Record<string, string> = {
|
||||||
|
PENDING: "bg-amber-100 text-amber-700 dark:bg-yellow-900/30 dark:text-yellow-400",
|
||||||
|
APPROVED: "bg-emerald-100 text-emerald-700 dark:bg-green-900/30 dark:text-green-400",
|
||||||
|
REJECTED: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||||||
|
CANCELLED: "bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const VACATION_TYPE_LABELS: Record<string, string> = {
|
||||||
|
ANNUAL: "Annual Leave",
|
||||||
|
SICK: "Sick Leave",
|
||||||
|
PUBLIC_HOLIDAY: "Public Holiday",
|
||||||
|
OTHER: "Other",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PROJECT_STATUS_BADGE: Record<string, string> = {
|
||||||
|
DRAFT: "bg-gray-100 text-gray-700",
|
||||||
|
ACTIVE: "bg-green-100 text-green-700",
|
||||||
|
ON_HOLD: "bg-yellow-100 text-yellow-700",
|
||||||
|
COMPLETED: "bg-blue-100 text-blue-700",
|
||||||
|
CANCELLED: "bg-red-100 text-red-700",
|
||||||
|
};
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
# Planarchy v2 Refactoring Plan
|
||||||
|
|
||||||
|
**Date:** 2026-03-14
|
||||||
|
**Status:** Proposed
|
||||||
|
**Purpose:** Consolidate duplicated patterns, optimize timeline performance, and simplify component architecture for long-term maintainability.
|
||||||
|
|
||||||
|
## Guiding Principles
|
||||||
|
|
||||||
|
- No feature changes — behavior stays identical before and after each phase.
|
||||||
|
- Each phase is independently shippable and ends with green tests.
|
||||||
|
- Phases are ordered by risk-adjusted impact: quick wins first, structural changes last.
|
||||||
|
- Every extraction must reduce net LOC or measurably improve a metric.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Quick Wins (Consolidation)
|
||||||
|
|
||||||
|
**Goal:** Remove the most common code duplication across the codebase without changing any component boundaries.
|
||||||
|
|
||||||
|
### 1.1 Centralize `formatMoney()`
|
||||||
|
|
||||||
|
**Problem:** 7 near-identical `formatMoney(cents, currency?)` implementations scattered across estimate and dashboard components.
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `apps/web/src/components/estimates/EstimateWorkspaceClient.tsx`
|
||||||
|
- `apps/web/src/components/estimates/EstimateWorkspaceDraftEditor.tsx`
|
||||||
|
- `apps/web/src/components/estimates/VersionCompare.tsx`
|
||||||
|
- `apps/web/src/components/estimates/CommercialTermsEditor.tsx`
|
||||||
|
- `apps/web/src/components/estimates/EstimateWizard.tsx`
|
||||||
|
- `apps/web/src/app/(app)/estimates/EstimatesClient.tsx`
|
||||||
|
- `apps/web/src/components/dashboard/widgets/StatCardsWidget.tsx`
|
||||||
|
|
||||||
|
**Target:** `apps/web/src/lib/format.ts` — add `formatMoney(cents: number, currency?: string): string`
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- [x] `formatMoney` exported from `~/lib/format.ts`
|
||||||
|
- [x] All 7 local copies removed, replaced by import from `~/lib/format.ts`
|
||||||
|
- [x] `pnpm --filter @planarchy/web exec tsc --noEmit` passes
|
||||||
|
- [x] Visual output unchanged (same `de-DE` locale, same `maximumFractionDigits: 0`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.2 Extract `findUniqueOrThrow()` helper
|
||||||
|
|
||||||
|
**Problem:** 19+ router files repeat the same `findUnique → if (!result) throw NOT_FOUND` pattern.
|
||||||
|
|
||||||
|
**Target:** `packages/api/src/db/helpers.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export async function findUniqueOrThrow<T>(
|
||||||
|
query: Promise<T | null>,
|
||||||
|
entityName: string,
|
||||||
|
): Promise<T> {
|
||||||
|
const result = await query;
|
||||||
|
if (!result) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: `${entityName} not found` });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Files affected:** All router files in `packages/api/src/router/` that use the pattern.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- [x] Helper exported from `packages/api/src/db/helpers.ts`
|
||||||
|
- [x] At least 15 router files migrated to use the helper (19 files migrated)
|
||||||
|
- [x] `pnpm --filter @planarchy/api exec vitest run` passes (191 tests)
|
||||||
|
- [x] `pnpm --filter @planarchy/api exec tsc --noEmit` passes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.3 Shared Prisma select constants
|
||||||
|
|
||||||
|
**Problem:** `role: { select: { id: true, name: true, color: true } }` appears 5+ times. Similar patterns for project and resource selects.
|
||||||
|
|
||||||
|
**Target:** `packages/api/src/db/selects.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const ROLE_SELECT = { id: true, name: true, color: true } as const;
|
||||||
|
export const PROJECT_BRIEF_SELECT = { id: true, name: true, shortCode: true, status: true } as const;
|
||||||
|
export const RESOURCE_BRIEF_SELECT = { id: true, displayName: true, eid: true, chapter: true } as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- [x] Select constants exported from `packages/api/src/db/selects.ts`
|
||||||
|
- [x] All routers using inline `{ id: true, name: true, color: true }` for roles migrated
|
||||||
|
- [x] TypeScript compiles, tests pass
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.4 `useInvalidatePlanningViews()` hook
|
||||||
|
|
||||||
|
**Problem:** 3 modal components copy the same 8-line `invalidatePlanningViews()` block.
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `apps/web/src/components/allocations/AllocationModal.tsx`
|
||||||
|
- `apps/web/src/components/resources/ResourceModal.tsx`
|
||||||
|
- `apps/web/src/components/projects/ProjectModal.tsx`
|
||||||
|
|
||||||
|
**Target:** `apps/web/src/hooks/useInvalidatePlanningViews.ts`
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- [x] Hook exported from `~/hooks/useInvalidatePlanningViews.ts`
|
||||||
|
- [x] AllocationModal uses the hook (ResourceModal/ProjectModal only have single-line invalidations — not candidates)
|
||||||
|
- [x] TypeScript compiles, invalidation behavior unchanged
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.5 Status badge style consolidation
|
||||||
|
|
||||||
|
**Problem:** 4+ components define their own `STATUS_BADGE: Record<string, string>` color maps.
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `apps/web/src/components/allocations/AllocationsClient.tsx`
|
||||||
|
- `apps/web/src/components/vacations/VacationClient.tsx`
|
||||||
|
- `apps/web/src/components/vacations/MyVacationsClient.tsx`
|
||||||
|
- `apps/web/src/components/dashboard/widgets/ProjectTableWidget.tsx`
|
||||||
|
|
||||||
|
**Target:** `apps/web/src/lib/status-styles.ts` (or `packages/ui/src/statusStyles.ts` if cross-package)
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- [x] Typed status style maps exported from a single source (`~/lib/status-styles.ts`)
|
||||||
|
- [x] All 4 consumers import from the shared module
|
||||||
|
- [x] Dark mode classes preserved exactly
|
||||||
|
- [x] TypeScript compiles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1.6 Add missing database indexes
|
||||||
|
|
||||||
|
**Problem:** Common query filter combinations lack composite indexes, causing sequential scans on large datasets.
|
||||||
|
|
||||||
|
**Target:** `packages/db/prisma/schema.prisma`
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
model Assignment {
|
||||||
|
// existing indexes...
|
||||||
|
@@index([resourceId, status, startDate])
|
||||||
|
@@index([projectId, startDate, endDate])
|
||||||
|
}
|
||||||
|
|
||||||
|
model DemandRequirement {
|
||||||
|
// existing indexes...
|
||||||
|
@@index([projectId, status, startDate])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Vacation {
|
||||||
|
// existing indexes...
|
||||||
|
@@index([resourceId, status, startDate, endDate])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- [x] Indexes added to schema
|
||||||
|
- [x] `pnpm db:push` succeeds
|
||||||
|
- [x] No existing queries broken (191 API tests pass, 254 engine tests pass)
|
||||||
|
- [ ] Timeline list query explain plan shows index usage (manual verification on staging)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Timeline Component Split
|
||||||
|
|
||||||
|
**Goal:** Break `TimelineView.tsx` (1,903 lines) into focused sub-components and reduce re-render scope.
|
||||||
|
|
||||||
|
### 2.1 Create `TimelineContext`
|
||||||
|
|
||||||
|
**Problem:** 20+ values passed via props through 5+ component levels. Any prop change re-renders the entire tree.
|
||||||
|
|
||||||
|
**Target:** `apps/web/src/components/timeline/TimelineContext.tsx`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TimelineContextValue {
|
||||||
|
assignments: AllocationEntry[];
|
||||||
|
demands: AllocationEntry[];
|
||||||
|
vacationsByResource: Map<string, VacationEntry[]>;
|
||||||
|
resources: ResourceBrief[];
|
||||||
|
resourceMap: Map<string, ResourceBrief>;
|
||||||
|
viewStart: Date;
|
||||||
|
viewEnd: Date;
|
||||||
|
dayWidth: number;
|
||||||
|
filter: TimelineFilter;
|
||||||
|
canEdit: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- [x] `TimelineContext` created with `useTimelineContext()` hook
|
||||||
|
- [x] All data-fetching and filter state lives in the context provider
|
||||||
|
- [x] Child components access data via `useTimelineContext()` instead of props
|
||||||
|
- [x] No behavioral change — identical rendering output
|
||||||
|
- [x] TypeScript compiles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.2 Extract `TimelineResourcePanel`
|
||||||
|
|
||||||
|
**Problem:** Resource-view rendering logic (~700 lines within TimelineView) is interleaved with project-view logic.
|
||||||
|
|
||||||
|
**Source lines:** Approximately lines 1304–1430+ of current `TimelineView.tsx` (the `resourceViewContent` useMemo and its renderers).
|
||||||
|
|
||||||
|
**Target:** `apps/web/src/components/timeline/TimelineResourcePanel.tsx`
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- [x] Resource row rendering extracted into `TimelineResourcePanel`
|
||||||
|
- [x] Uses `useTimelineContext()` for data access
|
||||||
|
- [x] `TimelineView.tsx` reduced from 1,903 to 538 lines (72% reduction)
|
||||||
|
- [x] Virtualizer (`@tanstack/react-virtual`) works correctly in the extracted component
|
||||||
|
- [x] Drag-to-shift interactions still work
|
||||||
|
- [x] Visual regression: no layout or behavior change
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.3 Extract `TimelineProjectPanel`
|
||||||
|
|
||||||
|
**Problem:** Project-group rendering logic (~600 lines) is embedded in `TimelineView.tsx`.
|
||||||
|
|
||||||
|
**Target:** `apps/web/src/components/timeline/TimelineProjectPanel.tsx`
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- [x] Project group rows and open-demand blocks extracted into `TimelineProjectPanel`
|
||||||
|
- [x] Uses `useTimelineContext()` for data access
|
||||||
|
- [x] `TimelineView.tsx` reduced to 538 lines (orchestrator + drag/tooltip/popover)
|
||||||
|
- [x] Visual regression: no layout or behavior change
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.4 Split oversized `useMemo` chains
|
||||||
|
|
||||||
|
**Problem:** `resourceViewContent` useMemo has 28 dependencies — any change invalidates the entire memo.
|
||||||
|
|
||||||
|
**Target:** Replace with 3 smaller memos inside `TimelineResourcePanel`:
|
||||||
|
|
||||||
|
| Memo | Dependencies | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `resourceRows` | resources, filter, allocsByResource | Which rows to render |
|
||||||
|
| `vacationBlocks` | vacationsByResource, viewStart, viewEnd, dayWidth | Vacation bar positions |
|
||||||
|
| `assignmentBlocks` | allocsByResource, viewStart, viewEnd, dayWidth | Assignment bar positions |
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- [x] No single `useMemo` has more than 10 dependencies (max 6)
|
||||||
|
- [ ] React DevTools Profiler shows reduced re-render count on filter toggle (manual verification)
|
||||||
|
- [x] Identical visual output
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Timeline Query Performance
|
||||||
|
|
||||||
|
**Goal:** Reduce data payload and move filtering from client to server.
|
||||||
|
|
||||||
|
### 3.1 Server-side filtering for `getEntriesView`
|
||||||
|
|
||||||
|
**Problem:** The timeline loads ALL entries for a date range, then filters by resource/project/status client-side. On large datasets (500+ resources, 2000+ assignments) this sends megabytes of unnecessary data.
|
||||||
|
|
||||||
|
**Target:** `packages/api/src/router/timeline.ts` — extend `getEntriesView` input:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
getEntriesView.input(z.object({
|
||||||
|
startDate: z.coerce.date(),
|
||||||
|
endDate: z.coerce.date(),
|
||||||
|
resourceIds: z.array(z.string()).optional(), // NEW
|
||||||
|
projectIds: z.array(z.string()).optional(), // NEW
|
||||||
|
chapters: z.array(z.string()).optional(), // NEW
|
||||||
|
}))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- [x] `getEntriesView` accepts optional filter arrays (resourceIds, projectIds, chapters, eids)
|
||||||
|
- [x] Prisma `where` clause includes filters when provided
|
||||||
|
- [x] Client passes active filters from `TimelineFilter` state
|
||||||
|
- [ ] Payload size reduced by 50%+ when filters are active (manual verification)
|
||||||
|
- [x] Unfiltered queries behave exactly as before (backward compatible)
|
||||||
|
- [x] TypeScript compiles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.2 Remove `availability` from default timeline resource select
|
||||||
|
|
||||||
|
**Problem:** `availability` is a JSONB field (weekly hour maps) loaded for every resource in timeline queries but only used in capacity analysis views.
|
||||||
|
|
||||||
|
**Target:** `packages/application/src/use-cases/allocation/project-planning-read-model.ts`
|
||||||
|
|
||||||
|
Remove `availability` from `PROJECT_PLANNING_ASSIGNMENT_INCLUDE.resource.select`. Load it separately only in capacity-analysis queries.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- [x] `availability` removed from timeline resource select (new `TIMELINE_ASSIGNMENT_INCLUDE`)
|
||||||
|
- [x] Staffing/capacity queries still load `availability` where needed (`PROJECT_PLANNING_ASSIGNMENT_INCLUDE`)
|
||||||
|
- [x] Timeline rendering unchanged (availability was never used in rendering)
|
||||||
|
- [ ] Payload size reduced (manual verification)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3.3 SSE event debouncing
|
||||||
|
|
||||||
|
**Problem:** Rapid allocation updates (e.g., batch status change) emit one SSE event per record, causing N re-fetches.
|
||||||
|
|
||||||
|
**Target:** `packages/api/src/sse/event-bus.ts`
|
||||||
|
|
||||||
|
Add a 50ms debounce buffer: batch events within the window, then emit a single aggregated event.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- [x] Batch operations emit 1 SSE event instead of N (50ms debounce buffer)
|
||||||
|
- [x] Single-record operations still emit immediately (50ms imperceptible)
|
||||||
|
- [x] Timeline re-fetches only once for batch operations
|
||||||
|
- [x] SSE tests updated (7 new tests in event-bus-debounce.test.ts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Estimate Workspace Simplification
|
||||||
|
|
||||||
|
**Goal:** Break `EstimateWorkspaceClient.tsx` (1,298 lines) and `EstimateWorkspaceDraftEditor.tsx` (1,205 lines) into focused tab components.
|
||||||
|
|
||||||
|
### 4.1 Extract tab content components from `EstimateWorkspaceClient`
|
||||||
|
|
||||||
|
**Problem:** 6 inline tab components (`OverviewTab`, `AssumptionsTab`, `ScopeTab`, `StaffingTab`, `FinancialsTab`, `VersionsTab`) are defined inside a single file.
|
||||||
|
|
||||||
|
**Target:** Create individual files:
|
||||||
|
- `apps/web/src/components/estimates/tabs/OverviewTab.tsx`
|
||||||
|
- `apps/web/src/components/estimates/tabs/AssumptionsTab.tsx`
|
||||||
|
- `apps/web/src/components/estimates/tabs/ScopeTab.tsx`
|
||||||
|
- `apps/web/src/components/estimates/tabs/StaffingTab.tsx`
|
||||||
|
- `apps/web/src/components/estimates/tabs/FinancialsTab.tsx`
|
||||||
|
- `apps/web/src/components/estimates/tabs/VersionsTab.tsx`
|
||||||
|
- `apps/web/src/components/estimates/tabs/ExportsTab.tsx`
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- [x] Each tab is a separate file with its own imports (7 tab files created)
|
||||||
|
- [x] `EstimateWorkspaceClient.tsx` reduced to 306 lines (tab orchestrator + header + status bar)
|
||||||
|
- [x] No behavioral change
|
||||||
|
- [x] TypeScript compiles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 Extract draft editor section editors
|
||||||
|
|
||||||
|
**Problem:** `EstimateWorkspaceDraftEditor.tsx` (1,205 lines) contains inline editors for assumptions, scope items, and demand lines.
|
||||||
|
|
||||||
|
**Target:** Create individual editor components:
|
||||||
|
- `apps/web/src/components/estimates/editors/AssumptionEditor.tsx`
|
||||||
|
- `apps/web/src/components/estimates/editors/ScopeItemEditor.tsx`
|
||||||
|
- `apps/web/src/components/estimates/editors/DemandLineEditor.tsx`
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- [x] Each editor is a separate file (3 editor files created)
|
||||||
|
- [x] `EstimateWorkspaceDraftEditor.tsx` reduced to 581 lines (orchestrator + save logic)
|
||||||
|
- [x] Edit/save flow unchanged
|
||||||
|
- [x] TypeScript compiles
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5 — Package Boundary Cleanup
|
||||||
|
|
||||||
|
**Goal:** Ensure each package has a clear single responsibility.
|
||||||
|
|
||||||
|
### 5.1 Move seed and migration tooling to `packages/db`
|
||||||
|
|
||||||
|
**Problem:** `update-blueprints.ts` (1,272 lines) and `seed.ts` (1,228 lines) live in `packages/application` but are database-specific operations.
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- [x] Both files already in `packages/db/src/` (no move needed)
|
||||||
|
- [x] Import paths correct
|
||||||
|
- [x] `pnpm db:seed` works
|
||||||
|
- [x] No circular dependencies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 Split `commit-dispo-import-batch.ts`
|
||||||
|
|
||||||
|
**Problem:** Single 1,112-line file doing validation, map building, placement, and persistence.
|
||||||
|
|
||||||
|
**Target:** Split into:
|
||||||
|
- `validate-dispo-batch.ts` — input validation and denormalization checks
|
||||||
|
- `build-dispo-maps.ts` — chargeability and reference data map construction
|
||||||
|
- `determine-placement.ts` — placement context and assignment logic
|
||||||
|
- `commit-dispo-import-batch.ts` — orchestrator (300 lines max)
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- [x] Each extracted module has a clear single responsibility (3 modules: validate, build-maps, determine-placement)
|
||||||
|
- [x] `commit-dispo-import-batch.ts` orchestrates via function calls (1,112 → 573 lines)
|
||||||
|
- [x] All 67 application tests pass
|
||||||
|
- [x] No behavioral change
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 Extract shared pagination helper
|
||||||
|
|
||||||
|
**Problem:** 20+ router procedures duplicate cursor-based pagination logic.
|
||||||
|
|
||||||
|
**Target:** `packages/api/src/db/pagination.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface PaginationInput {
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
cursor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
nextCursor?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function paginate<T extends { id: string }>(
|
||||||
|
findMany: () => Promise<T[]>,
|
||||||
|
count: () => Promise<number>,
|
||||||
|
input: PaginationInput,
|
||||||
|
): Promise<PaginatedResult<T>>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Acceptance criteria:**
|
||||||
|
- [x] Pagination helper exported (`paginate` + `paginateCursor` in `packages/api/src/db/pagination.ts`)
|
||||||
|
- [x] 2 router procedures migrated (project.list, project.listWithCosts — others use custom patterns)
|
||||||
|
- [x] All 209 API tests pass (11 new pagination tests)
|
||||||
|
- [x] Pagination behavior unchanged
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Constraints
|
||||||
|
|
||||||
|
| Rule | Rationale |
|
||||||
|
|---|---|
|
||||||
|
| One phase at a time | Prevents merge conflicts across structural changes |
|
||||||
|
| Green tests before moving to next phase | Each phase is independently safe to ship |
|
||||||
|
| No feature additions during refactoring | Scope creep defeats the purpose |
|
||||||
|
| Phase 2 and 3 may run in parallel | Timeline component split (frontend) is independent of query optimization (backend) |
|
||||||
|
| Phase 4 and 5 may run in parallel | Estimate components and package boundaries don't overlap |
|
||||||
|
|
||||||
|
## Verification Checklist (Per Phase)
|
||||||
|
|
||||||
|
- [ ] `pnpm --filter @planarchy/engine exec vitest run` — 254+ tests pass
|
||||||
|
- [ ] `pnpm --filter @planarchy/api exec vitest run` — 187+ tests pass
|
||||||
|
- [ ] `pnpm --filter @planarchy/application exec vitest run` — 67+ tests pass
|
||||||
|
- [ ] `pnpm --filter @planarchy/web exec tsc --noEmit` — zero errors
|
||||||
|
- [ ] `pnpm --filter @planarchy/api exec tsc --noEmit` — zero errors (excluding pre-existing dispo-import issues if Phase 5.2 not yet done)
|
||||||
|
- [ ] Dev server starts and serves pages without 500 errors
|
||||||
|
- [ ] Manual smoke test: timeline renders, estimate workspace tabs work, allocation CRUD works
|
||||||
|
|
||||||
|
## Metrics to Track
|
||||||
|
|
||||||
|
| Metric | Current | Target (Post-Refactor) |
|
||||||
|
|---|---|---|
|
||||||
|
| `TimelineView.tsx` lines | 1,903 | < 350 |
|
||||||
|
| `EstimateWorkspaceClient.tsx` lines | 1,298 | < 250 |
|
||||||
|
| `formatMoney` copies | 7 | 1 |
|
||||||
|
| Timeline payload size (filtered, 50 resources) | ~2 MB (estimated) | < 500 KB |
|
||||||
|
| `useMemo` max dependency count | 28 | < 10 |
|
||||||
|
| API test count | 187 | 200+ (add tests for new helpers) |
|
||||||
|
| Total test count | 508 | 520+ |
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { SSE_EVENT_TYPES } from "@planarchy/shared";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
cancelPendingEvents,
|
||||||
|
eventBus,
|
||||||
|
flushPendingEvents,
|
||||||
|
type SseEvent,
|
||||||
|
} from "../sse/event-bus.js";
|
||||||
|
|
||||||
|
// Mock Redis so the module loads without a real connection.
|
||||||
|
// publish() throws so the event bus falls back to local-only delivery,
|
||||||
|
// which is the path that exercises the debounce buffer.
|
||||||
|
vi.mock("ioredis", () => {
|
||||||
|
const RedisMock = vi.fn().mockImplementation(() => ({
|
||||||
|
on: vi.fn(),
|
||||||
|
subscribe: vi.fn().mockResolvedValue(undefined),
|
||||||
|
publish: vi.fn().mockImplementation(() => {
|
||||||
|
throw new Error("Redis unavailable (test)");
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
return { Redis: RedisMock };
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("event-bus debounce", () => {
|
||||||
|
let received: SseEvent[];
|
||||||
|
let unsubscribe: () => void;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
received = [];
|
||||||
|
unsubscribe = eventBus.subscribe((event) => {
|
||||||
|
received.push(event);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
unsubscribe();
|
||||||
|
cancelPendingEvents();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("delivers a single event after the debounce window", () => {
|
||||||
|
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, { id: "a1" });
|
||||||
|
|
||||||
|
// Not yet delivered — still in the debounce window
|
||||||
|
expect(received).toHaveLength(0);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
// Now delivered
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
expect(received[0]!.type).toBe(SSE_EVENT_TYPES.ALLOCATION_CREATED);
|
||||||
|
expect(received[0]!.payload).toEqual({ id: "a1" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aggregates multiple events of the same type into a single _batch event", () => {
|
||||||
|
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, { id: "a1" });
|
||||||
|
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, { id: "a2" });
|
||||||
|
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, { id: "a3" });
|
||||||
|
|
||||||
|
expect(received).toHaveLength(0);
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
expect(received[0]!.type).toBe(SSE_EVENT_TYPES.ALLOCATION_UPDATED);
|
||||||
|
expect(received[0]!.payload).toEqual({
|
||||||
|
_batch: [{ id: "a1" }, { id: "a2" }, { id: "a3" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps different event types separate", () => {
|
||||||
|
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, { id: "a1" });
|
||||||
|
eventBus.emit(SSE_EVENT_TYPES.ROLE_UPDATED, { id: "r1" });
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
expect(received).toHaveLength(2);
|
||||||
|
const types = received.map((e) => e.type);
|
||||||
|
expect(types).toContain(SSE_EVENT_TYPES.ALLOCATION_CREATED);
|
||||||
|
expect(types).toContain(SSE_EVENT_TYPES.ROLE_UPDATED);
|
||||||
|
|
||||||
|
// Both should be single payloads (not batched)
|
||||||
|
for (const event of received) {
|
||||||
|
expect(event.payload).not.toHaveProperty("_batch");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets the debounce timer when new events arrive within the window", () => {
|
||||||
|
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, { id: "a1" });
|
||||||
|
|
||||||
|
// Advance 30ms (still within window)
|
||||||
|
vi.advanceTimersByTime(30);
|
||||||
|
expect(received).toHaveLength(0);
|
||||||
|
|
||||||
|
// Emit another — this resets the timer
|
||||||
|
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, { id: "a2" });
|
||||||
|
|
||||||
|
// Advance another 30ms (60ms total from first, but only 30ms from second)
|
||||||
|
vi.advanceTimersByTime(30);
|
||||||
|
expect(received).toHaveLength(0);
|
||||||
|
|
||||||
|
// Advance remaining 20ms (now 50ms from second event)
|
||||||
|
vi.advanceTimersByTime(20);
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
expect(received[0]!.payload).toEqual({
|
||||||
|
_batch: [{ id: "a1" }, { id: "a2" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("flushPendingEvents delivers immediately", () => {
|
||||||
|
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, { id: "a1" });
|
||||||
|
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, { id: "a2" });
|
||||||
|
|
||||||
|
expect(received).toHaveLength(0);
|
||||||
|
|
||||||
|
flushPendingEvents();
|
||||||
|
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
expect(received[0]!.payload).toEqual({
|
||||||
|
_batch: [{ id: "a1" }, { id: "a2" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timer should not fire again
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancelPendingEvents discards events without delivering", () => {
|
||||||
|
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, { id: "a1" });
|
||||||
|
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_CREATED, { id: "a2" });
|
||||||
|
|
||||||
|
cancelPendingEvents();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(100);
|
||||||
|
expect(received).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the timestamp of the first event in a batch", () => {
|
||||||
|
const before = new Date().toISOString();
|
||||||
|
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, { id: "a1" });
|
||||||
|
|
||||||
|
// Advance a bit, emit another
|
||||||
|
vi.advanceTimersByTime(10);
|
||||||
|
eventBus.emit(SSE_EVENT_TYPES.ALLOCATION_UPDATED, { id: "a2" });
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(50);
|
||||||
|
|
||||||
|
expect(received).toHaveLength(1);
|
||||||
|
// The timestamp should be from the first event (not later)
|
||||||
|
expect(received[0]!.timestamp).toBe(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { paginate, paginateCursor } from "../db/pagination.js";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Test data
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function makeItems(n: number) {
|
||||||
|
return Array.from({ length: n }, (_, i) => ({
|
||||||
|
id: `id_${String(i + 1).padStart(3, "0")}`,
|
||||||
|
name: `Item ${i + 1}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALL_ITEMS = makeItems(55);
|
||||||
|
|
||||||
|
function mockFindMany(items: typeof ALL_ITEMS) {
|
||||||
|
return async ({ skip, take }: { skip: number; take: number }) =>
|
||||||
|
items.slice(skip, skip + take);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockCount(items: typeof ALL_ITEMS) {
|
||||||
|
return async () => items.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// paginate()
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("paginate", () => {
|
||||||
|
it("returns first page with correct metadata", async () => {
|
||||||
|
const result = await paginate(
|
||||||
|
mockFindMany(ALL_ITEMS),
|
||||||
|
mockCount(ALL_ITEMS),
|
||||||
|
{ page: 1, limit: 20 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(20);
|
||||||
|
expect(result.total).toBe(55);
|
||||||
|
expect(result.page).toBe(1);
|
||||||
|
expect(result.limit).toBe(20);
|
||||||
|
expect(result.nextCursor).toBe("id_020");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns last page without nextCursor", async () => {
|
||||||
|
const result = await paginate(
|
||||||
|
mockFindMany(ALL_ITEMS),
|
||||||
|
mockCount(ALL_ITEMS),
|
||||||
|
{ page: 3, limit: 20 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(15);
|
||||||
|
expect(result.total).toBe(55);
|
||||||
|
expect(result.nextCursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips zero when cursor is provided", async () => {
|
||||||
|
let capturedSkip = -1;
|
||||||
|
const result = await paginate(
|
||||||
|
async ({ skip, take }) => {
|
||||||
|
capturedSkip = skip;
|
||||||
|
return ALL_ITEMS.slice(skip, skip + take);
|
||||||
|
},
|
||||||
|
mockCount(ALL_ITEMS),
|
||||||
|
{ page: 3, limit: 10, cursor: "irrelevant" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(capturedSkip).toBe(0);
|
||||||
|
// When cursor is given skip=0, so we get from the start of the pre-filtered data
|
||||||
|
expect(result.items).toHaveLength(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses defaults when page and limit omitted", async () => {
|
||||||
|
const result = await paginate(
|
||||||
|
mockFindMany(ALL_ITEMS),
|
||||||
|
mockCount(ALL_ITEMS),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.page).toBe(1);
|
||||||
|
expect(result.limit).toBe(50);
|
||||||
|
expect(result.items).toHaveLength(50);
|
||||||
|
expect(result.nextCursor).toBe("id_050");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty result set", async () => {
|
||||||
|
const result = await paginate(
|
||||||
|
mockFindMany([]),
|
||||||
|
mockCount([]),
|
||||||
|
{ page: 1, limit: 20 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(0);
|
||||||
|
expect(result.total).toBe(0);
|
||||||
|
expect(result.nextCursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns no nextCursor when items exactly equal limit", async () => {
|
||||||
|
const items = makeItems(20);
|
||||||
|
const result = await paginate(
|
||||||
|
mockFindMany(items),
|
||||||
|
mockCount(items),
|
||||||
|
{ page: 1, limit: 20 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(20);
|
||||||
|
expect(result.nextCursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports custom getId", async () => {
|
||||||
|
const items = [{ uuid: "abc" }, { uuid: "def" }, { uuid: "ghi" }];
|
||||||
|
const result = await paginate(
|
||||||
|
async ({ skip, take }) => items.slice(skip, skip + take),
|
||||||
|
async () => items.length,
|
||||||
|
{ page: 1, limit: 2 },
|
||||||
|
(item) => item.uuid,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.nextCursor).toBe("def");
|
||||||
|
expect(result.items).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// paginateCursor()
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe("paginateCursor", () => {
|
||||||
|
it("returns items and nextCursor when more exist", async () => {
|
||||||
|
const result = await paginateCursor(
|
||||||
|
async ({ take }) => ALL_ITEMS.slice(0, take),
|
||||||
|
{ limit: 10 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(10);
|
||||||
|
expect(result.nextCursor).toBe("id_010");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null cursor when no more items", async () => {
|
||||||
|
const items = makeItems(5);
|
||||||
|
const result = await paginateCursor(
|
||||||
|
async ({ take }) => items.slice(0, take),
|
||||||
|
{ limit: 10 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(5);
|
||||||
|
expect(result.nextCursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty result", async () => {
|
||||||
|
const result = await paginateCursor(
|
||||||
|
async () => [],
|
||||||
|
{ limit: 10 },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(0);
|
||||||
|
expect(result.nextCursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses default limit", async () => {
|
||||||
|
const result = await paginateCursor(
|
||||||
|
async ({ take }) => ALL_ITEMS.slice(0, take),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(50);
|
||||||
|
expect(result.nextCursor).toBe("id_050");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
export async function findUniqueOrThrow<T>(
|
||||||
|
query: Promise<T | null>,
|
||||||
|
entityName: string,
|
||||||
|
): Promise<T> {
|
||||||
|
const result = await query;
|
||||||
|
if (!result) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: `${entityName} not found` });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Input schema — merge into any tRPC procedure input with `.merge()` or spread
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const PaginationInputSchema = z.object({
|
||||||
|
page: z.number().int().min(1).default(1),
|
||||||
|
limit: z.number().int().min(1).max(500).default(50),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type PaginationInput = z.infer<typeof PaginationInputSchema>;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Result envelope
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface PaginatedResult<T> {
|
||||||
|
items: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
nextCursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Core paginate helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a Prisma-style `findMany` + `count` with the project's standard
|
||||||
|
* offset+cursor hybrid pagination and the `take: limit + 1` sentinel trick.
|
||||||
|
*
|
||||||
|
* @param findMany Prisma `findMany` (or compatible) that receives `{ skip, take }`.
|
||||||
|
* The caller is responsible for applying `where`/`orderBy`/`include`
|
||||||
|
* and for injecting the cursor condition into `where` when
|
||||||
|
* `cursorWhere` is provided.
|
||||||
|
* @param count Prisma `count` for the *base* where clause (without cursor).
|
||||||
|
* @param input The validated pagination input fields.
|
||||||
|
* @param getId Optional — extract the cursor id from the last item.
|
||||||
|
* Defaults to `item.id` for items that have an `id: string`.
|
||||||
|
*/
|
||||||
|
export async function paginate<T>(
|
||||||
|
findMany: (args: { skip: number; take: number }) => Promise<T[]>,
|
||||||
|
count: () => Promise<number>,
|
||||||
|
input: PaginationInput,
|
||||||
|
getId: (item: T) => string = (item) => (item as unknown as { id: string }).id,
|
||||||
|
): Promise<PaginatedResult<T>> {
|
||||||
|
const page = input.page ?? 1;
|
||||||
|
const limit = input.limit ?? 50;
|
||||||
|
const skip = input.cursor ? 0 : (page - 1) * limit;
|
||||||
|
|
||||||
|
const [rawItems, total] = await Promise.all([
|
||||||
|
findMany({ skip, take: limit + 1 }),
|
||||||
|
count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const hasMore = rawItems.length > limit;
|
||||||
|
const items = hasMore ? rawItems.slice(0, limit) : rawItems;
|
||||||
|
const nextCursor = hasMore && items.length > 0 ? getId(items[items.length - 1]!) : null;
|
||||||
|
|
||||||
|
return { items, total, page, limit, nextCursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Cursor-only variant (no total count, lighter)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CursorResult<T> {
|
||||||
|
items: T[];
|
||||||
|
nextCursor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CursorInputSchema = z.object({
|
||||||
|
limit: z.number().int().min(1).max(500).default(50),
|
||||||
|
cursor: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CursorInput = z.infer<typeof CursorInputSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cursor-only pagination without a total count query.
|
||||||
|
* Useful for large tables or infinite-scroll UIs that don't need a total.
|
||||||
|
*/
|
||||||
|
export async function paginateCursor<T>(
|
||||||
|
findMany: (args: { take: number }) => Promise<T[]>,
|
||||||
|
input: CursorInput,
|
||||||
|
getId: (item: T) => string = (item) => (item as unknown as { id: string }).id,
|
||||||
|
): Promise<CursorResult<T>> {
|
||||||
|
const limit = input.limit ?? 50;
|
||||||
|
|
||||||
|
const rawItems = await findMany({ take: limit + 1 });
|
||||||
|
|
||||||
|
const hasMore = rawItems.length > limit;
|
||||||
|
const items = hasMore ? rawItems.slice(0, limit) : rawItems;
|
||||||
|
const nextCursor = hasMore && items.length > 0 ? getId(items[items.length - 1]!) : null;
|
||||||
|
|
||||||
|
return { items, nextCursor };
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const ROLE_BRIEF_SELECT = { id: true, name: true, color: true } as const;
|
||||||
|
export const PROJECT_BRIEF_SELECT = { id: true, name: true, shortCode: true, status: true, endDate: true } as const;
|
||||||
|
export const RESOURCE_BRIEF_SELECT = { id: true, displayName: true, eid: true, lcrCents: true } as const;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export { appRouter, type AppRouter } from "./router/index.js";
|
export { appRouter, type AppRouter } from "./router/index.js";
|
||||||
export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedure, protectedProcedure, managerProcedure, controllerProcedure, adminProcedure, requirePermission } from "./trpc.js";
|
export { createTRPCContext, createTRPCRouter, createCallerFactory, publicProcedure, protectedProcedure, managerProcedure, controllerProcedure, adminProcedure, requirePermission } from "./trpc.js";
|
||||||
export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning } from "./sse/event-bus.js";
|
export { eventBus, emitAllocationCreated, emitAllocationUpdated, emitAllocationDeleted, emitProjectShifted, emitBudgetWarning, flushPendingEvents, cancelPendingEvents } from "./sse/event-bus.js";
|
||||||
|
export { anonymizeResource, anonymizeResources, anonymizeUser, getAnonymizationConfig, getAnonymizationDirectory } from "./lib/anonymization.js";
|
||||||
|
|||||||
@@ -26,25 +26,27 @@ import {
|
|||||||
} from "@planarchy/shared";
|
} from "@planarchy/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated } from "../sse/event-bus.js";
|
import { emitAllocationCreated, emitAllocationDeleted, emitAllocationUpdated } from "../sse/event-bus.js";
|
||||||
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||||
|
import { PROJECT_BRIEF_SELECT, RESOURCE_BRIEF_SELECT, ROLE_BRIEF_SELECT } from "../db/selects.js";
|
||||||
|
|
||||||
const DEMAND_INCLUDE = {
|
const DEMAND_INCLUDE = {
|
||||||
project: { select: { id: true, name: true, shortCode: true, status: true, endDate: true } },
|
project: { select: PROJECT_BRIEF_SELECT },
|
||||||
roleEntity: { select: { id: true, name: true, color: true } },
|
roleEntity: { select: ROLE_BRIEF_SELECT },
|
||||||
assignments: {
|
assignments: {
|
||||||
include: {
|
include: {
|
||||||
resource: { select: { id: true, displayName: true, eid: true, lcrCents: true } },
|
resource: { select: RESOURCE_BRIEF_SELECT },
|
||||||
project: { select: { id: true, name: true, shortCode: true, status: true, endDate: true } },
|
project: { select: PROJECT_BRIEF_SELECT },
|
||||||
roleEntity: { select: { id: true, name: true, color: true } },
|
roleEntity: { select: ROLE_BRIEF_SELECT },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const ASSIGNMENT_INCLUDE = {
|
const ASSIGNMENT_INCLUDE = {
|
||||||
resource: { select: { id: true, displayName: true, eid: true, lcrCents: true } },
|
resource: { select: RESOURCE_BRIEF_SELECT },
|
||||||
project: { select: { id: true, name: true, shortCode: true, status: true, endDate: true } },
|
project: { select: PROJECT_BRIEF_SELECT },
|
||||||
roleEntity: { select: { id: true, name: true, color: true } },
|
roleEntity: { select: ROLE_BRIEF_SELECT },
|
||||||
demandRequirement: {
|
demandRequirement: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -358,14 +360,13 @@ export const allocationRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||||
|
|
||||||
const existing = await ctx.db.demandRequirement.findUnique({
|
const existing = await findUniqueOrThrow(
|
||||||
|
ctx.db.demandRequirement.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: DEMAND_INCLUDE,
|
include: DEMAND_INCLUDE,
|
||||||
});
|
}),
|
||||||
|
"Demand requirement",
|
||||||
if (!existing) {
|
);
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Demand requirement not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.db.$transaction(async (tx) => {
|
await ctx.db.$transaction(async (tx) => {
|
||||||
await deleteDemandRequirement(
|
await deleteDemandRequirement(
|
||||||
@@ -473,14 +474,13 @@ export const allocationRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
requirePermission(ctx, PermissionKey.MANAGE_ALLOCATIONS);
|
||||||
|
|
||||||
const existing = await ctx.db.assignment.findUnique({
|
const existing = await findUniqueOrThrow(
|
||||||
|
ctx.db.assignment.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: ASSIGNMENT_INCLUDE,
|
include: ASSIGNMENT_INCLUDE,
|
||||||
});
|
}),
|
||||||
|
"Assignment",
|
||||||
if (!existing) {
|
);
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Assignment not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
await ctx.db.$transaction(async (tx) => {
|
await ctx.db.$transaction(async (tx) => {
|
||||||
await deleteAssignment(
|
await deleteAssignment(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { validateCustomFields } from "@planarchy/engine";
|
import { validateCustomFields } from "@planarchy/engine";
|
||||||
import { BlueprintTarget, type BlueprintFieldDefinition } from "@planarchy/shared";
|
import { BlueprintTarget, type BlueprintFieldDefinition } from "@planarchy/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
|
|
||||||
interface BlueprintLookup {
|
interface BlueprintLookup {
|
||||||
blueprint: {
|
blueprint: {
|
||||||
@@ -26,14 +27,13 @@ export async function assertBlueprintDynamicFields({
|
|||||||
}: AssertBlueprintDynamicFieldsInput): Promise<void> {
|
}: AssertBlueprintDynamicFieldsInput): Promise<void> {
|
||||||
if (!blueprintId) return;
|
if (!blueprintId) return;
|
||||||
|
|
||||||
const blueprint = await db.blueprint.findUnique({
|
const blueprint = await findUniqueOrThrow(
|
||||||
|
db.blueprint.findUnique({
|
||||||
where: { id: blueprintId },
|
where: { id: blueprintId },
|
||||||
select: { fieldDefs: true, target: true },
|
select: { fieldDefs: true, target: true },
|
||||||
});
|
}),
|
||||||
|
"Blueprint",
|
||||||
if (!blueprint) {
|
);
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Blueprint not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (blueprint.target !== target) {
|
if (blueprint.target !== target) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BlueprintTarget, CreateBlueprintSchema, UpdateBlueprintSchema, type BlueprintFieldDefinition } from "@planarchy/shared";
|
import { BlueprintTarget, CreateBlueprintSchema, UpdateBlueprintSchema, type BlueprintFieldDefinition } from "@planarchy/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
|
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||||
|
|
||||||
export const blueprintRouter = createTRPCRouter({
|
export const blueprintRouter = createTRPCRouter({
|
||||||
@@ -24,10 +25,10 @@ export const blueprintRouter = createTRPCRouter({
|
|||||||
getById: protectedProcedure
|
getById: protectedProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const blueprint = await ctx.db.blueprint.findUnique({ where: { id: input.id } });
|
const blueprint = await findUniqueOrThrow(
|
||||||
if (!blueprint) {
|
ctx.db.blueprint.findUnique({ where: { id: input.id } }),
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Blueprint not found" });
|
"Blueprint",
|
||||||
}
|
);
|
||||||
return blueprint;
|
return blueprint;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -49,10 +50,10 @@ export const blueprintRouter = createTRPCRouter({
|
|||||||
update: adminProcedure
|
update: adminProcedure
|
||||||
.input(z.object({ id: z.string(), data: UpdateBlueprintSchema }))
|
.input(z.object({ id: z.string(), data: UpdateBlueprintSchema }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.blueprint.findUnique({ where: { id: input.id } });
|
await findUniqueOrThrow(
|
||||||
if (!existing) {
|
ctx.db.blueprint.findUnique({ where: { id: input.id } }),
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Blueprint not found" });
|
"Blueprint",
|
||||||
}
|
);
|
||||||
|
|
||||||
return ctx.db.blueprint.update({
|
return ctx.db.blueprint.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
@@ -70,10 +71,10 @@ export const blueprintRouter = createTRPCRouter({
|
|||||||
updateRolePresets: adminProcedure
|
updateRolePresets: adminProcedure
|
||||||
.input(z.object({ id: z.string(), rolePresets: z.array(z.unknown()) }))
|
.input(z.object({ id: z.string(), rolePresets: z.array(z.unknown()) }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.blueprint.findUnique({ where: { id: input.id } });
|
await findUniqueOrThrow(
|
||||||
if (!existing) {
|
ctx.db.blueprint.findUnique({ where: { id: input.id } }),
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Blueprint not found" });
|
"Blueprint",
|
||||||
}
|
);
|
||||||
return ctx.db.blueprint.update({
|
return ctx.db.blueprint.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
data: { rolePresets: input.rolePresets as unknown as import("@planarchy/db").Prisma.InputJsonValue },
|
data: { rolePresets: input.rolePresets as unknown as import("@planarchy/db").Prisma.InputJsonValue },
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CreateClientSchema, UpdateClientSchema } from "@planarchy/shared";
|
import { CreateClientSchema, UpdateClientSchema } from "@planarchy/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
|
import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js";
|
||||||
|
|
||||||
import type { ClientTree } from "@planarchy/shared";
|
import type { ClientTree } from "@planarchy/shared";
|
||||||
@@ -64,15 +65,17 @@ export const clientRouter = createTRPCRouter({
|
|||||||
getById: protectedProcedure
|
getById: protectedProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const client = await ctx.db.client.findUnique({
|
const client = await findUniqueOrThrow(
|
||||||
|
ctx.db.client.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: {
|
include: {
|
||||||
parent: true,
|
parent: true,
|
||||||
children: { orderBy: { sortOrder: "asc" } },
|
children: { orderBy: { sortOrder: "asc" } },
|
||||||
_count: { select: { projects: true, children: true } },
|
_count: { select: { projects: true, children: true } },
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
if (!client) throw new TRPCError({ code: "NOT_FOUND", message: "Client not found" });
|
"Client",
|
||||||
|
);
|
||||||
return client;
|
return client;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -80,8 +83,10 @@ export const clientRouter = createTRPCRouter({
|
|||||||
.input(CreateClientSchema)
|
.input(CreateClientSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
if (input.parentId) {
|
if (input.parentId) {
|
||||||
const parent = await ctx.db.client.findUnique({ where: { id: input.parentId } });
|
await findUniqueOrThrow(
|
||||||
if (!parent) throw new TRPCError({ code: "NOT_FOUND", message: "Parent client not found" });
|
ctx.db.client.findUnique({ where: { id: input.parentId } }),
|
||||||
|
"Parent client",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.code) {
|
if (input.code) {
|
||||||
@@ -104,8 +109,10 @@ export const clientRouter = createTRPCRouter({
|
|||||||
update: managerProcedure
|
update: managerProcedure
|
||||||
.input(z.object({ id: z.string(), data: UpdateClientSchema }))
|
.input(z.object({ id: z.string(), data: UpdateClientSchema }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.client.findUnique({ where: { id: input.id } });
|
const existing = await findUniqueOrThrow(
|
||||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Client not found" });
|
ctx.db.client.findUnique({ where: { id: input.id } }),
|
||||||
|
"Client",
|
||||||
|
);
|
||||||
|
|
||||||
if (input.data.code && input.data.code !== existing.code) {
|
if (input.data.code && input.data.code !== existing.code) {
|
||||||
const conflict = await ctx.db.client.findUnique({ where: { code: input.data.code } });
|
const conflict = await ctx.db.client.findUnique({ where: { code: input.data.code } });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { Prisma } from "@planarchy/db";
|
import { Prisma } from "@planarchy/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
|
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||||
|
|
||||||
/** Convert nullable JSON to Prisma-compatible value (null → Prisma.JsonNull). */
|
/** Convert nullable JSON to Prisma-compatible value (null → Prisma.JsonNull). */
|
||||||
@@ -31,14 +32,16 @@ export const countryRouter = createTRPCRouter({
|
|||||||
getById: protectedProcedure
|
getById: protectedProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const country = await ctx.db.country.findUnique({
|
const country = await findUniqueOrThrow(
|
||||||
|
ctx.db.country.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: {
|
include: {
|
||||||
metroCities: { orderBy: { name: "asc" } },
|
metroCities: { orderBy: { name: "asc" } },
|
||||||
_count: { select: { resources: true } },
|
_count: { select: { resources: true } },
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
if (!country) throw new TRPCError({ code: "NOT_FOUND", message: "Country not found" });
|
"Country",
|
||||||
|
);
|
||||||
return country;
|
return country;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -63,8 +66,10 @@ export const countryRouter = createTRPCRouter({
|
|||||||
update: adminProcedure
|
update: adminProcedure
|
||||||
.input(z.object({ id: z.string(), data: UpdateCountrySchema }))
|
.input(z.object({ id: z.string(), data: UpdateCountrySchema }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.country.findUnique({ where: { id: input.id } });
|
const existing = await findUniqueOrThrow(
|
||||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Country not found" });
|
ctx.db.country.findUnique({ where: { id: input.id } }),
|
||||||
|
"Country",
|
||||||
|
);
|
||||||
|
|
||||||
if (input.data.code && input.data.code !== existing.code) {
|
if (input.data.code && input.data.code !== existing.code) {
|
||||||
const conflict = await ctx.db.country.findUnique({ where: { code: input.data.code } });
|
const conflict = await ctx.db.country.findUnique({ where: { code: input.data.code } });
|
||||||
@@ -91,8 +96,10 @@ export const countryRouter = createTRPCRouter({
|
|||||||
createCity: adminProcedure
|
createCity: adminProcedure
|
||||||
.input(CreateMetroCitySchema)
|
.input(CreateMetroCitySchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const country = await ctx.db.country.findUnique({ where: { id: input.countryId } });
|
await findUniqueOrThrow(
|
||||||
if (!country) throw new TRPCError({ code: "NOT_FOUND", message: "Country not found" });
|
ctx.db.country.findUnique({ where: { id: input.countryId } }),
|
||||||
|
"Country",
|
||||||
|
);
|
||||||
|
|
||||||
return ctx.db.metroCity.create({
|
return ctx.db.metroCity.create({
|
||||||
data: { name: input.name, countryId: input.countryId },
|
data: { name: input.name, countryId: input.countryId },
|
||||||
@@ -111,11 +118,13 @@ export const countryRouter = createTRPCRouter({
|
|||||||
deleteCity: adminProcedure
|
deleteCity: adminProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const city = await ctx.db.metroCity.findUnique({
|
const city = await findUniqueOrThrow(
|
||||||
|
ctx.db.metroCity.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: { _count: { select: { resources: true } } },
|
include: { _count: { select: { resources: true } } },
|
||||||
});
|
}),
|
||||||
if (!city) throw new TRPCError({ code: "NOT_FOUND", message: "Metro city not found" });
|
"Metro city",
|
||||||
|
);
|
||||||
if (city._count.resources > 0) {
|
if (city._count.resources > 0) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "PRECONDITION_FAILED",
|
code: "PRECONDITION_FAILED",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "@planarchy/shared";
|
} from "@planarchy/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
|
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
|
||||||
|
|
||||||
const ruleInclude = {
|
const ruleInclude = {
|
||||||
@@ -28,11 +29,13 @@ export const effortRuleRouter = createTRPCRouter({
|
|||||||
getById: controllerProcedure
|
getById: controllerProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const ruleSet = await ctx.db.effortRuleSet.findUnique({
|
const ruleSet = await findUniqueOrThrow(
|
||||||
|
ctx.db.effortRuleSet.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: ruleInclude,
|
include: ruleInclude,
|
||||||
});
|
}),
|
||||||
if (!ruleSet) throw new TRPCError({ code: "NOT_FOUND", message: "Effort rule set not found" });
|
"Effort rule set",
|
||||||
|
);
|
||||||
return ruleSet;
|
return ruleSet;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -71,8 +74,10 @@ export const effortRuleRouter = createTRPCRouter({
|
|||||||
update: managerProcedure
|
update: managerProcedure
|
||||||
.input(UpdateEffortRuleSetSchema)
|
.input(UpdateEffortRuleSetSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.effortRuleSet.findUnique({ where: { id: input.id } });
|
await findUniqueOrThrow(
|
||||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Effort rule set not found" });
|
ctx.db.effortRuleSet.findUnique({ where: { id: input.id } }),
|
||||||
|
"Effort rule set",
|
||||||
|
);
|
||||||
|
|
||||||
// If setting as default, unset others
|
// If setting as default, unset others
|
||||||
if (input.isDefault) {
|
if (input.isDefault) {
|
||||||
@@ -113,8 +118,10 @@ export const effortRuleRouter = createTRPCRouter({
|
|||||||
delete: managerProcedure
|
delete: managerProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.effortRuleSet.findUnique({ where: { id: input.id } });
|
await findUniqueOrThrow(
|
||||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Effort rule set not found" });
|
ctx.db.effortRuleSet.findUnique({ where: { id: input.id } }),
|
||||||
|
"Effort rule set",
|
||||||
|
);
|
||||||
await ctx.db.effortRuleSet.delete({ where: { id: input.id } });
|
await ctx.db.effortRuleSet.delete({ where: { id: input.id } });
|
||||||
return { id: input.id };
|
return { id: input.id };
|
||||||
}),
|
}),
|
||||||
@@ -127,6 +134,7 @@ export const effortRuleRouter = createTRPCRouter({
|
|||||||
}))
|
}))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const [estimate, ruleSet] = await Promise.all([
|
const [estimate, ruleSet] = await Promise.all([
|
||||||
|
findUniqueOrThrow(
|
||||||
ctx.db.estimate.findUnique({
|
ctx.db.estimate.findUnique({
|
||||||
where: { id: input.estimateId },
|
where: { id: input.estimateId },
|
||||||
include: {
|
include: {
|
||||||
@@ -137,15 +145,17 @@ export const effortRuleRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
"Estimate",
|
||||||
|
),
|
||||||
|
findUniqueOrThrow(
|
||||||
ctx.db.effortRuleSet.findUnique({
|
ctx.db.effortRuleSet.findUnique({
|
||||||
where: { id: input.ruleSetId },
|
where: { id: input.ruleSetId },
|
||||||
include: ruleInclude,
|
include: ruleInclude,
|
||||||
}),
|
}),
|
||||||
|
"Effort rule set",
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!estimate) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
|
|
||||||
if (!ruleSet) throw new TRPCError({ code: "NOT_FOUND", message: "Effort rule set not found" });
|
|
||||||
|
|
||||||
const version = estimate.versions[0];
|
const version = estimate.versions[0];
|
||||||
if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" });
|
if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" });
|
||||||
|
|
||||||
@@ -178,10 +188,11 @@ export const effortRuleRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
/** Apply effort rules to generate demand lines on the working version */
|
/** Apply effort rules to generate demand lines on the working version */
|
||||||
apply: managerProcedure
|
applyRules: managerProcedure
|
||||||
.input(ApplyEffortRulesSchema)
|
.input(ApplyEffortRulesSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const [estimate, ruleSet] = await Promise.all([
|
const [estimate, ruleSet] = await Promise.all([
|
||||||
|
findUniqueOrThrow(
|
||||||
ctx.db.estimate.findUnique({
|
ctx.db.estimate.findUnique({
|
||||||
where: { id: input.estimateId },
|
where: { id: input.estimateId },
|
||||||
include: {
|
include: {
|
||||||
@@ -195,15 +206,17 @@ export const effortRuleRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
"Estimate",
|
||||||
|
),
|
||||||
|
findUniqueOrThrow(
|
||||||
ctx.db.effortRuleSet.findUnique({
|
ctx.db.effortRuleSet.findUnique({
|
||||||
where: { id: input.ruleSetId },
|
where: { id: input.ruleSetId },
|
||||||
include: ruleInclude,
|
include: ruleInclude,
|
||||||
}),
|
}),
|
||||||
|
"Effort rule set",
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!estimate) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
|
|
||||||
if (!ruleSet) throw new TRPCError({ code: "NOT_FOUND", message: "Effort rule set not found" });
|
|
||||||
|
|
||||||
const version = estimate.versions[0];
|
const version = estimate.versions[0];
|
||||||
if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" });
|
if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" });
|
||||||
if (version.status !== "WORKING") {
|
if (version.status !== "WORKING") {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
ApproveEstimateVersionSchema,
|
ApproveEstimateVersionSchema,
|
||||||
CloneEstimateSchema,
|
CloneEstimateSchema,
|
||||||
|
CommercialTermsSchema,
|
||||||
CreateEstimateExportSchema,
|
CreateEstimateExportSchema,
|
||||||
CreateEstimatePlanningHandoffSchema,
|
CreateEstimatePlanningHandoffSchema,
|
||||||
CreateEstimateSchema,
|
CreateEstimateSchema,
|
||||||
@@ -30,10 +31,12 @@ import {
|
|||||||
GenerateWeeklyPhasingSchema,
|
GenerateWeeklyPhasingSchema,
|
||||||
PermissionKey,
|
PermissionKey,
|
||||||
SubmitEstimateVersionSchema,
|
SubmitEstimateVersionSchema,
|
||||||
|
UpdateCommercialTermsSchema,
|
||||||
UpdateEstimateDraftSchema,
|
UpdateEstimateDraftSchema,
|
||||||
} from "@planarchy/shared";
|
} from "@planarchy/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import {
|
import {
|
||||||
controllerProcedure,
|
controllerProcedure,
|
||||||
createTRPCRouter,
|
createTRPCRouter,
|
||||||
@@ -151,15 +154,14 @@ export const estimateRouter = createTRPCRouter({
|
|||||||
getById: controllerProcedure
|
getById: controllerProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const estimate = await getEstimateById(
|
const estimate = await findUniqueOrThrow(
|
||||||
|
getEstimateById(
|
||||||
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
|
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
|
||||||
input.id,
|
input.id,
|
||||||
|
),
|
||||||
|
"Estimate",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!estimate) {
|
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return estimate;
|
return estimate;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -169,14 +171,13 @@ export const estimateRouter = createTRPCRouter({
|
|||||||
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||||
|
|
||||||
if (input.projectId) {
|
if (input.projectId) {
|
||||||
const project = await ctx.db.project.findUnique({
|
await findUniqueOrThrow(
|
||||||
|
ctx.db.project.findUnique({
|
||||||
where: { id: input.projectId },
|
where: { id: input.projectId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
}),
|
||||||
|
"Project",
|
||||||
if (!project) {
|
);
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const estimate = await createEstimate(
|
const estimate = await createEstimate(
|
||||||
@@ -253,14 +254,13 @@ export const estimateRouter = createTRPCRouter({
|
|||||||
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||||
|
|
||||||
if (input.projectId) {
|
if (input.projectId) {
|
||||||
const project = await ctx.db.project.findUnique({
|
await findUniqueOrThrow(
|
||||||
|
ctx.db.project.findUnique({
|
||||||
where: { id: input.projectId },
|
where: { id: input.projectId },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
}),
|
||||||
|
"Project",
|
||||||
if (!project) {
|
);
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let estimate;
|
let estimate;
|
||||||
@@ -592,15 +592,14 @@ export const estimateRouter = createTRPCRouter({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||||
|
|
||||||
const estimate = await getEstimateById(
|
const estimate = await findUniqueOrThrow(
|
||||||
|
getEstimateById(
|
||||||
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
|
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
|
||||||
input.estimateId,
|
input.estimateId,
|
||||||
|
),
|
||||||
|
"Estimate",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!estimate) {
|
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const workingVersion = estimate.versions.find(
|
const workingVersion = estimate.versions.find(
|
||||||
(v) => v.status === "WORKING",
|
(v) => v.status === "WORKING",
|
||||||
);
|
);
|
||||||
@@ -668,15 +667,14 @@ export const estimateRouter = createTRPCRouter({
|
|||||||
getWeeklyPhasing: controllerProcedure
|
getWeeklyPhasing: controllerProcedure
|
||||||
.input(z.object({ estimateId: z.string() }))
|
.input(z.object({ estimateId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const estimate = await getEstimateById(
|
const estimate = await findUniqueOrThrow(
|
||||||
|
getEstimateById(
|
||||||
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
|
ctx.db as unknown as Parameters<typeof getEstimateById>[0],
|
||||||
input.estimateId,
|
input.estimateId,
|
||||||
|
),
|
||||||
|
"Estimate",
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!estimate) {
|
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the latest version (first in the sorted array)
|
// Get the latest version (first in the sorted array)
|
||||||
const version = estimate.versions[0];
|
const version = estimate.versions[0];
|
||||||
|
|
||||||
@@ -754,4 +752,89 @@ export const estimateRouter = createTRPCRouter({
|
|||||||
chapterAggregation,
|
chapterAggregation,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// ─── Commercial Terms ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
getCommercialTerms: controllerProcedure
|
||||||
|
.input(z.object({ estimateId: z.string(), versionId: z.string().optional() }))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
const estimate = await ctx.db.estimate.findUnique({
|
||||||
|
where: { id: input.estimateId },
|
||||||
|
include: {
|
||||||
|
versions: {
|
||||||
|
...(input.versionId
|
||||||
|
? { where: { id: input.versionId } }
|
||||||
|
: { orderBy: { versionNumber: "desc" as const }, take: 1 }),
|
||||||
|
select: { id: true, commercialTerms: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!estimate || estimate.versions.length === 0) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = estimate.versions[0]!;
|
||||||
|
const raw = version.commercialTerms;
|
||||||
|
|
||||||
|
// Parse stored JSON through Zod for type safety, fall back to defaults
|
||||||
|
const terms = raw
|
||||||
|
? CommercialTermsSchema.parse(raw)
|
||||||
|
: CommercialTermsSchema.parse({});
|
||||||
|
|
||||||
|
return { versionId: version.id, terms };
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateCommercialTerms: managerProcedure
|
||||||
|
.input(UpdateCommercialTermsSchema)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||||
|
|
||||||
|
const estimate = await ctx.db.estimate.findUnique({
|
||||||
|
where: { id: input.estimateId },
|
||||||
|
include: {
|
||||||
|
versions: {
|
||||||
|
...(input.versionId
|
||||||
|
? { where: { id: input.versionId } }
|
||||||
|
: { where: { status: "WORKING" }, take: 1 }),
|
||||||
|
select: { id: true, status: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!estimate || estimate.versions.length === 0) {
|
||||||
|
throw new TRPCError({ code: "NOT_FOUND", message: "Estimate version not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = estimate.versions[0]!;
|
||||||
|
|
||||||
|
if (version.status !== "WORKING") {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "PRECONDITION_FAILED",
|
||||||
|
message: "Commercial terms can only be edited on working versions",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const validated = CommercialTermsSchema.parse(input.terms);
|
||||||
|
|
||||||
|
await ctx.db.estimateVersion.update({
|
||||||
|
where: { id: version.id },
|
||||||
|
data: { commercialTerms: validated as unknown as Prisma.InputJsonValue },
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.auditLog.create({
|
||||||
|
data: {
|
||||||
|
entityType: "Estimate",
|
||||||
|
entityId: estimate.id,
|
||||||
|
action: "UPDATE",
|
||||||
|
...(ctx.dbUser?.id ? { userId: ctx.dbUser.id } : {}),
|
||||||
|
changes: {
|
||||||
|
field: "commercialTerms",
|
||||||
|
after: validated,
|
||||||
|
} as Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { versionId: version.id, terms: validated };
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "@planarchy/shared";
|
} from "@planarchy/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
|
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
|
||||||
|
|
||||||
const ruleInclude = {
|
const ruleInclude = {
|
||||||
@@ -51,11 +52,13 @@ export const experienceMultiplierRouter = createTRPCRouter({
|
|||||||
getById: controllerProcedure
|
getById: controllerProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const set = await ctx.db.experienceMultiplierSet.findUnique({
|
const set = await findUniqueOrThrow(
|
||||||
|
ctx.db.experienceMultiplierSet.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: ruleInclude,
|
include: ruleInclude,
|
||||||
});
|
}),
|
||||||
if (!set) throw new TRPCError({ code: "NOT_FOUND", message: "Experience multiplier set not found" });
|
"Experience multiplier set",
|
||||||
|
);
|
||||||
return set;
|
return set;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -95,8 +98,10 @@ export const experienceMultiplierRouter = createTRPCRouter({
|
|||||||
update: managerProcedure
|
update: managerProcedure
|
||||||
.input(UpdateExperienceMultiplierSetSchema)
|
.input(UpdateExperienceMultiplierSetSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id } });
|
await findUniqueOrThrow(
|
||||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Experience multiplier set not found" });
|
ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id } }),
|
||||||
|
"Experience multiplier set",
|
||||||
|
);
|
||||||
|
|
||||||
if (input.isDefault) {
|
if (input.isDefault) {
|
||||||
await ctx.db.experienceMultiplierSet.updateMany({
|
await ctx.db.experienceMultiplierSet.updateMany({
|
||||||
@@ -137,8 +142,10 @@ export const experienceMultiplierRouter = createTRPCRouter({
|
|||||||
delete: managerProcedure
|
delete: managerProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id } });
|
await findUniqueOrThrow(
|
||||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Experience multiplier set not found" });
|
ctx.db.experienceMultiplierSet.findUnique({ where: { id: input.id } }),
|
||||||
|
"Experience multiplier set",
|
||||||
|
);
|
||||||
await ctx.db.experienceMultiplierSet.delete({ where: { id: input.id } });
|
await ctx.db.experienceMultiplierSet.delete({ where: { id: input.id } });
|
||||||
return { id: input.id };
|
return { id: input.id };
|
||||||
}),
|
}),
|
||||||
@@ -151,6 +158,7 @@ export const experienceMultiplierRouter = createTRPCRouter({
|
|||||||
}))
|
}))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const [estimate, multiplierSet] = await Promise.all([
|
const [estimate, multiplierSet] = await Promise.all([
|
||||||
|
findUniqueOrThrow(
|
||||||
ctx.db.estimate.findUnique({
|
ctx.db.estimate.findUnique({
|
||||||
where: { id: input.estimateId },
|
where: { id: input.estimateId },
|
||||||
include: {
|
include: {
|
||||||
@@ -161,15 +169,17 @@ export const experienceMultiplierRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
"Estimate",
|
||||||
|
),
|
||||||
|
findUniqueOrThrow(
|
||||||
ctx.db.experienceMultiplierSet.findUnique({
|
ctx.db.experienceMultiplierSet.findUnique({
|
||||||
where: { id: input.multiplierSetId },
|
where: { id: input.multiplierSetId },
|
||||||
include: ruleInclude,
|
include: ruleInclude,
|
||||||
}),
|
}),
|
||||||
|
"Experience multiplier set",
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!estimate) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
|
|
||||||
if (!multiplierSet) throw new TRPCError({ code: "NOT_FOUND", message: "Experience multiplier set not found" });
|
|
||||||
|
|
||||||
const version = estimate.versions[0];
|
const version = estimate.versions[0];
|
||||||
if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" });
|
if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" });
|
||||||
|
|
||||||
@@ -227,10 +237,11 @@ export const experienceMultiplierRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
/** Apply multipliers to demand lines on the working version */
|
/** Apply multipliers to demand lines on the working version */
|
||||||
apply: managerProcedure
|
applyRules: managerProcedure
|
||||||
.input(ApplyExperienceMultipliersSchema)
|
.input(ApplyExperienceMultipliersSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const [estimate, multiplierSet] = await Promise.all([
|
const [estimate, multiplierSet] = await Promise.all([
|
||||||
|
findUniqueOrThrow(
|
||||||
ctx.db.estimate.findUnique({
|
ctx.db.estimate.findUnique({
|
||||||
where: { id: input.estimateId },
|
where: { id: input.estimateId },
|
||||||
include: {
|
include: {
|
||||||
@@ -241,15 +252,17 @@ export const experienceMultiplierRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
"Estimate",
|
||||||
|
),
|
||||||
|
findUniqueOrThrow(
|
||||||
ctx.db.experienceMultiplierSet.findUnique({
|
ctx.db.experienceMultiplierSet.findUnique({
|
||||||
where: { id: input.multiplierSetId },
|
where: { id: input.multiplierSetId },
|
||||||
include: ruleInclude,
|
include: ruleInclude,
|
||||||
}),
|
}),
|
||||||
|
"Experience multiplier set",
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!estimate) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate not found" });
|
|
||||||
if (!multiplierSet) throw new TRPCError({ code: "NOT_FOUND", message: "Experience multiplier set not found" });
|
|
||||||
|
|
||||||
const version = estimate.versions[0];
|
const version = estimate.versions[0];
|
||||||
if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" });
|
if (!version) throw new TRPCError({ code: "NOT_FOUND", message: "Estimate has no versions" });
|
||||||
if (version.status !== "WORKING") {
|
if (version.status !== "WORKING") {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
} from "@planarchy/shared";
|
} from "@planarchy/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
|
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||||
|
|
||||||
export const managementLevelRouter = createTRPCRouter({
|
export const managementLevelRouter = createTRPCRouter({
|
||||||
@@ -21,14 +22,16 @@ export const managementLevelRouter = createTRPCRouter({
|
|||||||
getGroupById: protectedProcedure
|
getGroupById: protectedProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const group = await ctx.db.managementLevelGroup.findUnique({
|
const group = await findUniqueOrThrow(
|
||||||
|
ctx.db.managementLevelGroup.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: {
|
include: {
|
||||||
levels: { orderBy: { name: "asc" } },
|
levels: { orderBy: { name: "asc" } },
|
||||||
_count: { select: { resources: true } },
|
_count: { select: { resources: true } },
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
if (!group) throw new TRPCError({ code: "NOT_FOUND", message: "Management level group not found" });
|
"Management level group",
|
||||||
|
);
|
||||||
return group;
|
return group;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -52,8 +55,10 @@ export const managementLevelRouter = createTRPCRouter({
|
|||||||
updateGroup: adminProcedure
|
updateGroup: adminProcedure
|
||||||
.input(z.object({ id: z.string(), data: UpdateManagementLevelGroupSchema }))
|
.input(z.object({ id: z.string(), data: UpdateManagementLevelGroupSchema }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.managementLevelGroup.findUnique({ where: { id: input.id } });
|
const existing = await findUniqueOrThrow(
|
||||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Group not found" });
|
ctx.db.managementLevelGroup.findUnique({ where: { id: input.id } }),
|
||||||
|
"Group",
|
||||||
|
);
|
||||||
|
|
||||||
if (input.data.name && input.data.name !== existing.name) {
|
if (input.data.name && input.data.name !== existing.name) {
|
||||||
const conflict = await ctx.db.managementLevelGroup.findUnique({ where: { name: input.data.name } });
|
const conflict = await ctx.db.managementLevelGroup.findUnique({ where: { name: input.data.name } });
|
||||||
@@ -78,8 +83,10 @@ export const managementLevelRouter = createTRPCRouter({
|
|||||||
createLevel: adminProcedure
|
createLevel: adminProcedure
|
||||||
.input(CreateManagementLevelSchema)
|
.input(CreateManagementLevelSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const group = await ctx.db.managementLevelGroup.findUnique({ where: { id: input.groupId } });
|
await findUniqueOrThrow(
|
||||||
if (!group) throw new TRPCError({ code: "NOT_FOUND", message: "Group not found" });
|
ctx.db.managementLevelGroup.findUnique({ where: { id: input.groupId } }),
|
||||||
|
"Group",
|
||||||
|
);
|
||||||
|
|
||||||
const existing = await ctx.db.managementLevel.findUnique({ where: { name: input.name } });
|
const existing = await ctx.db.managementLevel.findUnique({ where: { name: input.name } });
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -94,8 +101,10 @@ export const managementLevelRouter = createTRPCRouter({
|
|||||||
updateLevel: adminProcedure
|
updateLevel: adminProcedure
|
||||||
.input(z.object({ id: z.string(), data: UpdateManagementLevelSchema }))
|
.input(z.object({ id: z.string(), data: UpdateManagementLevelSchema }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.managementLevel.findUnique({ where: { id: input.id } });
|
const existing = await findUniqueOrThrow(
|
||||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Level not found" });
|
ctx.db.managementLevel.findUnique({ where: { id: input.id } }),
|
||||||
|
"Level",
|
||||||
|
);
|
||||||
|
|
||||||
if (input.data.name && input.data.name !== existing.name) {
|
if (input.data.name && input.data.name !== existing.name) {
|
||||||
const conflict = await ctx.db.managementLevel.findUnique({ where: { name: input.data.name } });
|
const conflict = await ctx.db.managementLevel.findUnique({ where: { name: input.data.name } });
|
||||||
@@ -116,11 +125,13 @@ export const managementLevelRouter = createTRPCRouter({
|
|||||||
deleteLevel: adminProcedure
|
deleteLevel: adminProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const level = await ctx.db.managementLevel.findUnique({
|
const level = await findUniqueOrThrow(
|
||||||
|
ctx.db.managementLevel.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: { _count: { select: { resources: true } } },
|
include: { _count: { select: { resources: true } } },
|
||||||
});
|
}),
|
||||||
if (!level) throw new TRPCError({ code: "NOT_FOUND", message: "Level not found" });
|
"Level",
|
||||||
|
);
|
||||||
if (level._count.resources > 0) {
|
if (level._count.resources > 0) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "PRECONDITION_FAILED",
|
code: "PRECONDITION_FAILED",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CreateOrgUnitSchema, UpdateOrgUnitSchema } from "@planarchy/shared";
|
import { CreateOrgUnitSchema, UpdateOrgUnitSchema } from "@planarchy/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
|
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||||
|
|
||||||
import type { OrgUnitTree } from "@planarchy/shared";
|
import type { OrgUnitTree } from "@planarchy/shared";
|
||||||
@@ -62,15 +63,17 @@ export const orgUnitRouter = createTRPCRouter({
|
|||||||
getById: protectedProcedure
|
getById: protectedProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const unit = await ctx.db.orgUnit.findUnique({
|
const unit = await findUniqueOrThrow(
|
||||||
|
ctx.db.orgUnit.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: {
|
include: {
|
||||||
parent: true,
|
parent: true,
|
||||||
children: { orderBy: { sortOrder: "asc" } },
|
children: { orderBy: { sortOrder: "asc" } },
|
||||||
_count: { select: { resources: true } },
|
_count: { select: { resources: true } },
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
if (!unit) throw new TRPCError({ code: "NOT_FOUND", message: "Org unit not found" });
|
"Org unit",
|
||||||
|
);
|
||||||
return unit;
|
return unit;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -78,8 +81,10 @@ export const orgUnitRouter = createTRPCRouter({
|
|||||||
.input(CreateOrgUnitSchema)
|
.input(CreateOrgUnitSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
if (input.parentId) {
|
if (input.parentId) {
|
||||||
const parent = await ctx.db.orgUnit.findUnique({ where: { id: input.parentId } });
|
const parent = await findUniqueOrThrow(
|
||||||
if (!parent) throw new TRPCError({ code: "NOT_FOUND", message: "Parent org unit not found" });
|
ctx.db.orgUnit.findUnique({ where: { id: input.parentId } }),
|
||||||
|
"Parent org unit",
|
||||||
|
);
|
||||||
if (parent.level >= input.level) {
|
if (parent.level >= input.level) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "BAD_REQUEST",
|
code: "BAD_REQUEST",
|
||||||
@@ -102,8 +107,10 @@ export const orgUnitRouter = createTRPCRouter({
|
|||||||
update: adminProcedure
|
update: adminProcedure
|
||||||
.input(z.object({ id: z.string(), data: UpdateOrgUnitSchema }))
|
.input(z.object({ id: z.string(), data: UpdateOrgUnitSchema }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.orgUnit.findUnique({ where: { id: input.id } });
|
await findUniqueOrThrow(
|
||||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Org unit not found" });
|
ctx.db.orgUnit.findUnique({ where: { id: input.id } }),
|
||||||
|
"Org unit",
|
||||||
|
);
|
||||||
|
|
||||||
return ctx.db.orgUnit.update({
|
return ctx.db.orgUnit.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
|
|||||||
@@ -44,6 +44,26 @@ export const PROJECT_PLANNING_ASSIGNMENT_INCLUDE = {
|
|||||||
roleEntity: PROJECT_PLANNING_ALLOCATION_INCLUDE.roleEntity,
|
roleEntity: PROJECT_PLANNING_ALLOCATION_INCLUDE.roleEntity,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lighter resource select for timeline rendering (hot path).
|
||||||
|
* Omits `availability` which is only needed for budget/cost calculations.
|
||||||
|
*/
|
||||||
|
const TIMELINE_RESOURCE_SELECT = {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayName: true,
|
||||||
|
eid: true,
|
||||||
|
chapter: true,
|
||||||
|
lcrCents: true,
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const TIMELINE_ASSIGNMENT_INCLUDE = {
|
||||||
|
resource: TIMELINE_RESOURCE_SELECT,
|
||||||
|
project: PROJECT_PLANNING_ALLOCATION_INCLUDE.project,
|
||||||
|
roleEntity: PROJECT_PLANNING_ALLOCATION_INCLUDE.roleEntity,
|
||||||
|
} as const;
|
||||||
|
|
||||||
type ProjectPlanningReadDbClient = Pick<
|
type ProjectPlanningReadDbClient = Pick<
|
||||||
PrismaClient,
|
PrismaClient,
|
||||||
"demandRequirement" | "assignment"
|
"demandRequirement" | "assignment"
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@planarchy/shared";
|
import { BlueprintTarget, CreateProjectSchema, FieldType, PermissionKey, ProjectStatus, UpdateProjectSchema } from "@planarchy/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
|
import { paginate, paginateCursor, PaginationInputSchema, CursorInputSchema } from "../db/pagination.js";
|
||||||
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
||||||
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
||||||
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
|
import { loadProjectPlanningReadModel } from "./project-planning-read-model.js";
|
||||||
@@ -13,13 +15,9 @@ import { controllerProcedure, createTRPCRouter, managerProcedure, protectedProce
|
|||||||
export const projectRouter = createTRPCRouter({
|
export const projectRouter = createTRPCRouter({
|
||||||
list: protectedProcedure
|
list: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
PaginationInputSchema.extend({
|
||||||
status: z.nativeEnum(ProjectStatus).optional(),
|
status: z.nativeEnum(ProjectStatus).optional(),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
page: z.number().int().min(1).default(1),
|
|
||||||
limit: z.number().int().min(1).max(500).default(50),
|
|
||||||
// Cursor-based pagination (additive — page/limit still supported)
|
|
||||||
cursor: z.string().optional(),
|
|
||||||
// Custom field JSONB filters
|
// Custom field JSONB filters
|
||||||
customFieldFilters: z.array(z.object({
|
customFieldFilters: z.array(z.object({
|
||||||
key: z.string(),
|
key: z.string(),
|
||||||
@@ -29,7 +27,7 @@ export const projectRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const { status, search, page, limit, cursor, customFieldFilters } = input;
|
const { status, search, cursor, customFieldFilters } = input;
|
||||||
|
|
||||||
const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields }));
|
const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields }));
|
||||||
|
|
||||||
@@ -47,36 +45,35 @@ export const projectRouter = createTRPCRouter({
|
|||||||
...(cfConditions.length > 0 ? { AND: cfConditions } : {}),
|
...(cfConditions.length > 0 ? { AND: cfConditions } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const skip = cursor ? 0 : (page - 1) * limit;
|
|
||||||
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
|
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
|
||||||
const [rawProjects, total] = await Promise.all([
|
|
||||||
|
const result = await paginate(
|
||||||
|
({ skip, take }) =>
|
||||||
ctx.db.project.findMany({
|
ctx.db.project.findMany({
|
||||||
where: whereWithCursor,
|
where: whereWithCursor,
|
||||||
skip,
|
skip,
|
||||||
take: limit + 1,
|
take,
|
||||||
orderBy: [{ startDate: "asc" }, { id: "asc" }],
|
orderBy: [{ startDate: "asc" }, { id: "asc" }],
|
||||||
}),
|
}),
|
||||||
ctx.db.project.count({ where }),
|
() => ctx.db.project.count({ where }),
|
||||||
]);
|
input,
|
||||||
|
);
|
||||||
|
|
||||||
const hasMore = rawProjects.length > limit;
|
|
||||||
const projects = hasMore ? rawProjects.slice(0, limit) : rawProjects;
|
|
||||||
const nextCursor = hasMore ? projects[projects.length - 1]!.id : null;
|
|
||||||
const { countsByProjectId } = await countPlanningEntries(ctx.db, {
|
const { countsByProjectId } = await countPlanningEntries(ctx.db, {
|
||||||
projectIds: projects.map((project) => project.id),
|
projectIds: result.items.map((project) => project.id),
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projects: projects.map((project) => ({
|
projects: result.items.map((project) => ({
|
||||||
...project,
|
...project,
|
||||||
_count: {
|
_count: {
|
||||||
allocations: countsByProjectId.get(project.id) ?? 0,
|
allocations: countsByProjectId.get(project.id) ?? 0,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
total,
|
total: result.total,
|
||||||
page,
|
page: result.page,
|
||||||
limit,
|
limit: result.limit,
|
||||||
nextCursor,
|
nextCursor: result.nextCursor,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -161,10 +158,10 @@ export const projectRouter = createTRPCRouter({
|
|||||||
.input(z.object({ id: z.string(), data: UpdateProjectSchema }))
|
.input(z.object({ id: z.string(), data: UpdateProjectSchema }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
requirePermission(ctx, PermissionKey.MANAGE_PROJECTS);
|
||||||
const existing = await ctx.db.project.findUnique({ where: { id: input.id } });
|
const existing = await findUniqueOrThrow(
|
||||||
if (!existing) {
|
ctx.db.project.findUnique({ where: { id: input.id } }),
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
"Project",
|
||||||
}
|
);
|
||||||
|
|
||||||
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
|
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
|
||||||
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>;
|
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>;
|
||||||
@@ -247,15 +244,13 @@ export const projectRouter = createTRPCRouter({
|
|||||||
|
|
||||||
listWithCosts: controllerProcedure
|
listWithCosts: controllerProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
CursorInputSchema.extend({
|
||||||
status: z.nativeEnum(ProjectStatus).optional(),
|
status: z.nativeEnum(ProjectStatus).optional(),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
limit: z.number().int().min(1).max(500).default(50),
|
|
||||||
cursor: z.string().optional(),
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const { status, search, limit, cursor } = input;
|
const { status, search, cursor } = input;
|
||||||
const where = {
|
const where = {
|
||||||
...(status ? { status } : {}),
|
...(status ? { status } : {}),
|
||||||
...(search
|
...(search
|
||||||
@@ -269,16 +264,17 @@ export const projectRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
|
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
|
||||||
|
|
||||||
const rawProjects = await ctx.db.project.findMany({
|
const result = await paginateCursor(
|
||||||
|
({ take }) =>
|
||||||
|
ctx.db.project.findMany({
|
||||||
where: whereWithCursor,
|
where: whereWithCursor,
|
||||||
take: limit + 1,
|
take,
|
||||||
orderBy: [{ startDate: "asc" }, { id: "asc" }],
|
orderBy: [{ startDate: "asc" }, { id: "asc" }],
|
||||||
});
|
}),
|
||||||
|
input,
|
||||||
|
);
|
||||||
|
|
||||||
const hasMore = rawProjects.length > limit;
|
const projectIds = result.items.map((project) => project.id);
|
||||||
const projectsRaw = hasMore ? rawProjects.slice(0, limit) : rawProjects;
|
|
||||||
const nextCursor = hasMore ? projectsRaw[projectsRaw.length - 1]!.id : null;
|
|
||||||
const projectIds = projectsRaw.map((project) => project.id);
|
|
||||||
const bookings = projectIds.length
|
const bookings = projectIds.length
|
||||||
? await listAssignmentBookings(ctx.db, {
|
? await listAssignmentBookings(ctx.db, {
|
||||||
startDate: new Date("1900-01-01T00:00:00.000Z"),
|
startDate: new Date("1900-01-01T00:00:00.000Z"),
|
||||||
@@ -288,7 +284,7 @@ export const projectRouter = createTRPCRouter({
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Compute cost + person days per project
|
// Compute cost + person days per project
|
||||||
const projects = projectsRaw.map((p) => {
|
const projects = result.items.map((p) => {
|
||||||
const projectBookings = bookings.filter((booking) => booking.projectId === p.id);
|
const projectBookings = bookings.filter((booking) => booking.projectId === p.id);
|
||||||
let totalCostCents = 0;
|
let totalCostCents = 0;
|
||||||
let totalPersonDays = 0;
|
let totalPersonDays = 0;
|
||||||
@@ -311,6 +307,6 @@ export const projectRouter = createTRPCRouter({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return { projects, nextCursor };
|
return { projects, nextCursor: result.nextCursor };
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import {
|
|||||||
} from "@planarchy/shared";
|
} from "@planarchy/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
|
import { createTRPCRouter, controllerProcedure, managerProcedure } from "../trpc.js";
|
||||||
|
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
|
||||||
|
|
||||||
const lineSelect = {
|
const lineSelect = {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -22,7 +24,7 @@ const lineSelect = {
|
|||||||
billRateCents: true,
|
billRateCents: true,
|
||||||
machineRateCents: true,
|
machineRateCents: true,
|
||||||
attributes: true,
|
attributes: true,
|
||||||
role: { select: { id: true, name: true, color: true } },
|
role: { select: ROLE_BRIEF_SELECT },
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
} as const;
|
} as const;
|
||||||
@@ -73,7 +75,8 @@ export const rateCardRouter = createTRPCRouter({
|
|||||||
getById: controllerProcedure
|
getById: controllerProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const rateCard = await ctx.db.rateCard.findUnique({
|
const rateCard = await findUniqueOrThrow(
|
||||||
|
ctx.db.rateCard.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: {
|
include: {
|
||||||
client: { select: { id: true, name: true, code: true } },
|
client: { select: { id: true, name: true, code: true } },
|
||||||
@@ -82,8 +85,9 @@ export const rateCardRouter = createTRPCRouter({
|
|||||||
orderBy: [{ chapter: "asc" }, { seniority: "asc" }, { createdAt: "asc" }],
|
orderBy: [{ chapter: "asc" }, { seniority: "asc" }, { createdAt: "asc" }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
if (!rateCard) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card not found" });
|
"Rate card",
|
||||||
|
);
|
||||||
return rateCard;
|
return rateCard;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -124,8 +128,10 @@ export const rateCardRouter = createTRPCRouter({
|
|||||||
update: managerProcedure
|
update: managerProcedure
|
||||||
.input(z.object({ id: z.string(), data: UpdateRateCardSchema }))
|
.input(z.object({ id: z.string(), data: UpdateRateCardSchema }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.rateCard.findUnique({ where: { id: input.id } });
|
await findUniqueOrThrow(
|
||||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card not found" });
|
ctx.db.rateCard.findUnique({ where: { id: input.id } }),
|
||||||
|
"Rate card",
|
||||||
|
);
|
||||||
|
|
||||||
return ctx.db.rateCard.update({
|
return ctx.db.rateCard.update({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
@@ -159,8 +165,10 @@ export const rateCardRouter = createTRPCRouter({
|
|||||||
addLine: managerProcedure
|
addLine: managerProcedure
|
||||||
.input(z.object({ rateCardId: z.string(), line: CreateRateCardLineSchema }))
|
.input(z.object({ rateCardId: z.string(), line: CreateRateCardLineSchema }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const card = await ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } });
|
await findUniqueOrThrow(
|
||||||
if (!card) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card not found" });
|
ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } }),
|
||||||
|
"Rate card",
|
||||||
|
);
|
||||||
|
|
||||||
return ctx.db.rateCardLine.create({
|
return ctx.db.rateCardLine.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -183,8 +191,10 @@ export const rateCardRouter = createTRPCRouter({
|
|||||||
updateLine: managerProcedure
|
updateLine: managerProcedure
|
||||||
.input(z.object({ lineId: z.string(), data: UpdateRateCardLineSchema }))
|
.input(z.object({ lineId: z.string(), data: UpdateRateCardLineSchema }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } });
|
await findUniqueOrThrow(
|
||||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card line not found" });
|
ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } }),
|
||||||
|
"Rate card line",
|
||||||
|
);
|
||||||
|
|
||||||
const updateData: Prisma.RateCardLineUpdateInput = {};
|
const updateData: Prisma.RateCardLineUpdateInput = {};
|
||||||
if (input.data.roleId !== undefined) updateData.role = input.data.roleId ? { connect: { id: input.data.roleId } } : { disconnect: true };
|
if (input.data.roleId !== undefined) updateData.role = input.data.roleId ? { connect: { id: input.data.roleId } } : { disconnect: true };
|
||||||
@@ -208,8 +218,10 @@ export const rateCardRouter = createTRPCRouter({
|
|||||||
deleteLine: managerProcedure
|
deleteLine: managerProcedure
|
||||||
.input(z.object({ lineId: z.string() }))
|
.input(z.object({ lineId: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } });
|
await findUniqueOrThrow(
|
||||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card line not found" });
|
ctx.db.rateCardLine.findUnique({ where: { id: input.lineId } }),
|
||||||
|
"Rate card line",
|
||||||
|
);
|
||||||
|
|
||||||
await ctx.db.rateCardLine.delete({ where: { id: input.lineId } });
|
await ctx.db.rateCardLine.delete({ where: { id: input.lineId } });
|
||||||
return { deleted: true };
|
return { deleted: true };
|
||||||
@@ -223,8 +235,10 @@ export const rateCardRouter = createTRPCRouter({
|
|||||||
lines: z.array(CreateRateCardLineSchema),
|
lines: z.array(CreateRateCardLineSchema),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const card = await ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } });
|
await findUniqueOrThrow(
|
||||||
if (!card) throw new TRPCError({ code: "NOT_FOUND", message: "Rate card not found" });
|
ctx.db.rateCard.findUnique({ where: { id: input.rateCardId } }),
|
||||||
|
"Rate card",
|
||||||
|
);
|
||||||
|
|
||||||
return ctx.db.$transaction(async (tx) => {
|
return ctx.db.$transaction(async (tx) => {
|
||||||
await tx.rateCardLine.deleteMany({ where: { rateCardId: input.rateCardId } });
|
await tx.rateCardLine.deleteMany({ where: { rateCardId: input.rateCardId } });
|
||||||
|
|||||||
+274
-129
@@ -1,11 +1,22 @@
|
|||||||
import { createAiClient, isAiConfigured } from "../ai-client.js";
|
import { createAiClient, isAiConfigured } from "../ai-client.js";
|
||||||
import { listAssignmentBookings } from "@planarchy/application";
|
import {
|
||||||
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, SkillEntrySchema, UpdateResourceSchema, VALUE_SCORE_WEIGHTS, inferStateFromPostalCode } from "@planarchy/shared";
|
isChargeabilityActualBooking,
|
||||||
|
isChargeabilityRelevantProject,
|
||||||
|
listAssignmentBookings,
|
||||||
|
recomputeResourceValueScores,
|
||||||
|
} from "@planarchy/application";
|
||||||
|
import { BlueprintTarget, CreateResourceSchema, FieldType, PermissionKey, ResourceRoleSchema, ResourceType, SkillEntrySchema, UpdateResourceSchema, inferStateFromPostalCode } from "@planarchy/shared";
|
||||||
import type { WeekdayAvailability } from "@planarchy/shared";
|
import type { WeekdayAvailability } from "@planarchy/shared";
|
||||||
import { computeValueScore } from "@planarchy/staffing";
|
|
||||||
import { computeChargeability } from "@planarchy/engine";
|
import { computeChargeability } from "@planarchy/engine";
|
||||||
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
import { assertBlueprintDynamicFields } from "./blueprint-validation.js";
|
||||||
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
import { buildDynamicFieldWhereClauses } from "./custom-field-filters.js";
|
||||||
|
import {
|
||||||
|
anonymizeResource,
|
||||||
|
anonymizeResources,
|
||||||
|
anonymizeSearchMatches,
|
||||||
|
getAnonymizationDirectory,
|
||||||
|
resolveResourceIdsByDisplayedEids,
|
||||||
|
} from "../lib/anonymization.js";
|
||||||
|
|
||||||
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
|
export const DEFAULT_SUMMARY_PROMPT = `You are writing a short professional profile for an internal resource planning tool.
|
||||||
|
|
||||||
@@ -18,16 +29,40 @@ Artist profile:
|
|||||||
Write a 2–3 sentence professional bio. Be specific, use skill names. No fluff.`;
|
Write a 2–3 sentence professional bio. Be specific, use skill names. No fluff.`;
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
import { adminProcedure, controllerProcedure, createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||||
|
import { ROLE_BRIEF_SELECT } from "../db/selects.js";
|
||||||
|
|
||||||
|
function parseResourceCursor(cursor: string | undefined): { displayName: string; id: string } | null {
|
||||||
|
if (!cursor) return null;
|
||||||
|
try {
|
||||||
|
const decoded = JSON.parse(cursor) as { displayName?: string; id?: string };
|
||||||
|
if (typeof decoded.displayName === "string" && typeof decoded.id === "string") {
|
||||||
|
return { displayName: decoded.displayName, id: decoded.id };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export const resourceRouter = createTRPCRouter({
|
export const resourceRouter = createTRPCRouter({
|
||||||
list: protectedProcedure
|
list: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
chapter: z.string().optional(),
|
chapter: z.string().optional(),
|
||||||
|
chapters: z.array(z.string()).optional(),
|
||||||
isActive: z.boolean().optional().default(true),
|
isActive: z.boolean().optional().default(true),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
eids: z.array(z.string()).optional(),
|
eids: z.array(z.string()).optional(),
|
||||||
|
countryIds: z.array(z.string()).optional(),
|
||||||
|
excludedCountryIds: z.array(z.string()).optional(),
|
||||||
|
includeWithoutCountry: z.boolean().optional().default(true),
|
||||||
|
resourceTypes: z.array(z.nativeEnum(ResourceType)).optional(),
|
||||||
|
excludedResourceTypes: z.array(z.nativeEnum(ResourceType)).optional(),
|
||||||
|
includeWithoutResourceType: z.boolean().optional().default(true),
|
||||||
|
rolledOff: z.boolean().optional(),
|
||||||
|
departed: z.boolean().optional(),
|
||||||
page: z.number().int().min(1).default(1),
|
page: z.number().int().min(1).default(1),
|
||||||
limit: z.number().int().min(1).max(500).default(50),
|
limit: z.number().int().min(1).max(500).default(50),
|
||||||
includeRoles: z.boolean().optional().default(false),
|
includeRoles: z.boolean().optional().default(false),
|
||||||
@@ -42,31 +77,192 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const { chapter, isActive, search, eids, page, limit, includeRoles, cursor, customFieldFilters } = input;
|
const {
|
||||||
|
chapter,
|
||||||
|
chapters,
|
||||||
|
isActive,
|
||||||
|
search,
|
||||||
|
eids,
|
||||||
|
countryIds,
|
||||||
|
excludedCountryIds,
|
||||||
|
includeWithoutCountry,
|
||||||
|
resourceTypes,
|
||||||
|
excludedResourceTypes,
|
||||||
|
includeWithoutResourceType,
|
||||||
|
rolledOff,
|
||||||
|
departed,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
includeRoles,
|
||||||
|
cursor,
|
||||||
|
customFieldFilters,
|
||||||
|
} = input;
|
||||||
|
const parsedCursor = parseResourceCursor(cursor);
|
||||||
|
|
||||||
const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields }));
|
const cfConditions = buildDynamicFieldWhereClauses(customFieldFilters).map((dynamicFields) => ({ dynamicFields }));
|
||||||
|
type WhereClause = Record<string, unknown>;
|
||||||
|
const andClauses: WhereClause[] = [];
|
||||||
|
const chapterFilters = Array.from(
|
||||||
|
new Set([
|
||||||
|
...(chapter ? [chapter] : []),
|
||||||
|
...(chapters ?? []),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
if (!eids) {
|
||||||
const where: any = {
|
andClauses.push({ isActive });
|
||||||
...(eids ? {} : { isActive }),
|
}
|
||||||
...(eids ? { eid: { in: eids } } : {}),
|
if (eids && !directory) {
|
||||||
...(chapter ? { chapter } : {}),
|
andClauses.push({ eid: { in: eids } });
|
||||||
...(search
|
}
|
||||||
? {
|
if (chapterFilters.length === 1) {
|
||||||
|
andClauses.push({ chapter: chapterFilters[0] });
|
||||||
|
} else if (chapterFilters.length > 1) {
|
||||||
|
andClauses.push({ chapter: { in: chapterFilters } });
|
||||||
|
}
|
||||||
|
if (search && !directory) {
|
||||||
|
andClauses.push({
|
||||||
OR: [
|
OR: [
|
||||||
{ displayName: { contains: search, mode: "insensitive" as const } },
|
{ displayName: { contains: search, mode: "insensitive" as const } },
|
||||||
{ eid: { contains: search, mode: "insensitive" as const } },
|
{ eid: { contains: search, mode: "insensitive" as const } },
|
||||||
{ email: { contains: search, mode: "insensitive" as const } },
|
{ email: { contains: search, mode: "insensitive" as const } },
|
||||||
],
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (countryIds && countryIds.length > 0) {
|
||||||
|
const countryClauses: WhereClause[] = [{ countryId: { in: countryIds } }];
|
||||||
|
if (includeWithoutCountry) {
|
||||||
|
countryClauses.push({ countryId: null });
|
||||||
|
}
|
||||||
|
andClauses.push(countryClauses.length === 1 ? countryClauses[0]! : { OR: countryClauses });
|
||||||
|
}
|
||||||
|
if (excludedCountryIds && excludedCountryIds.length > 0) {
|
||||||
|
andClauses.push({ NOT: { countryId: { in: excludedCountryIds } } });
|
||||||
|
}
|
||||||
|
if (!includeWithoutCountry) {
|
||||||
|
andClauses.push({ NOT: { countryId: null } });
|
||||||
|
}
|
||||||
|
if (resourceTypes && resourceTypes.length > 0) {
|
||||||
|
const resourceTypeClauses: WhereClause[] = [{ resourceType: { in: resourceTypes } }];
|
||||||
|
if (includeWithoutResourceType) {
|
||||||
|
resourceTypeClauses.push({ resourceType: null });
|
||||||
|
}
|
||||||
|
andClauses.push(
|
||||||
|
resourceTypeClauses.length === 1 ? resourceTypeClauses[0]! : { OR: resourceTypeClauses },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (excludedResourceTypes && excludedResourceTypes.length > 0) {
|
||||||
|
andClauses.push({ NOT: { resourceType: { in: excludedResourceTypes } } });
|
||||||
|
}
|
||||||
|
if (!includeWithoutResourceType) {
|
||||||
|
andClauses.push({ NOT: { resourceType: null } });
|
||||||
|
}
|
||||||
|
if (rolledOff !== undefined) {
|
||||||
|
andClauses.push({ rolledOff });
|
||||||
|
}
|
||||||
|
if (departed !== undefined) {
|
||||||
|
andClauses.push({ departed });
|
||||||
|
}
|
||||||
|
andClauses.push(...cfConditions);
|
||||||
|
|
||||||
|
const where = andClauses.length > 0 ? { AND: andClauses } : {};
|
||||||
|
|
||||||
|
if (directory) {
|
||||||
|
const rawResources = await (includeRoles
|
||||||
|
? ctx.db.resource.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
resourceRoles: {
|
||||||
|
include: { role: { select: ROLE_BRIEF_SELECT } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ displayName: "asc" }, { id: "asc" }],
|
||||||
|
})
|
||||||
|
: ctx.db.resource.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ displayName: "asc" }, { id: "asc" }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const directoryResources = rawResources.map((resource) => ({
|
||||||
|
id: resource.id,
|
||||||
|
eid: resource.eid,
|
||||||
|
displayName: resource.displayName,
|
||||||
|
email: resource.email,
|
||||||
|
}));
|
||||||
|
const requestedIds = eids
|
||||||
|
? resolveResourceIdsByDisplayedEids(directoryResources, directory, eids)
|
||||||
|
: [];
|
||||||
|
const requestedIdSet = requestedIds.length > 0 ? new Set(requestedIds) : null;
|
||||||
|
|
||||||
|
const filteredResources = rawResources.filter((resource) => {
|
||||||
|
const alias = directory.byResourceId.get(resource.id);
|
||||||
|
if (requestedIdSet && !requestedIdSet.has(resource.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (eids && eids.length > 0 && requestedIds.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (search && !anonymizeSearchMatches(
|
||||||
|
{
|
||||||
|
id: resource.id,
|
||||||
|
eid: resource.eid,
|
||||||
|
displayName: resource.displayName,
|
||||||
|
email: resource.email,
|
||||||
|
},
|
||||||
|
alias,
|
||||||
|
search,
|
||||||
|
)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const anonymizedResources = anonymizeResources(filteredResources, directory).sort((left, right) => {
|
||||||
|
const displayNameCompare = left.displayName.localeCompare(right.displayName);
|
||||||
|
if (displayNameCompare !== 0) {
|
||||||
|
return displayNameCompare;
|
||||||
|
}
|
||||||
|
return left.id.localeCompare(right.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = anonymizedResources.length;
|
||||||
|
const afterCursor = parsedCursor
|
||||||
|
? anonymizedResources.filter(
|
||||||
|
(resource) =>
|
||||||
|
resource.displayName > parsedCursor.displayName ||
|
||||||
|
(resource.displayName === parsedCursor.displayName && resource.id > parsedCursor.id),
|
||||||
|
)
|
||||||
|
: anonymizedResources;
|
||||||
|
const skip = cursor ? 0 : (page - 1) * limit;
|
||||||
|
const paged = afterCursor.slice(skip, skip + limit + 1);
|
||||||
|
const hasMore = paged.length > limit;
|
||||||
|
const resources = hasMore ? paged.slice(0, limit) : paged;
|
||||||
|
const nextCursor = hasMore
|
||||||
|
? JSON.stringify({
|
||||||
|
displayName: resources[resources.length - 1]!.displayName,
|
||||||
|
id: resources[resources.length - 1]!.id,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return { resources, total, page, limit, nextCursor };
|
||||||
}
|
}
|
||||||
: {}),
|
|
||||||
...(cfConditions.length > 0 ? { AND: cfConditions } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
const skip = cursor ? 0 : (page - 1) * limit;
|
const skip = cursor ? 0 : (page - 1) * limit;
|
||||||
const orderBy = [{ displayName: "asc" as const }, { id: "asc" as const }];
|
const orderBy = [{ displayName: "asc" as const }, { id: "asc" as const }];
|
||||||
// Apply cursor filter directly on where to avoid exactOptionalPropertyTypes issues
|
const whereWithCursor = parsedCursor
|
||||||
const whereWithCursor = cursor ? { ...where, id: { gt: cursor } } : where;
|
? {
|
||||||
|
AND: [
|
||||||
|
...((where as { AND?: WhereClause[] }).AND ?? []),
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ displayName: { gt: parsedCursor.displayName } },
|
||||||
|
{ displayName: parsedCursor.displayName, id: { gt: parsedCursor.id } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: where;
|
||||||
const baseQuery = { where: whereWithCursor, skip, take: limit + 1, orderBy };
|
const baseQuery = { where: whereWithCursor, skip, take: limit + 1, orderBy };
|
||||||
|
|
||||||
const [rawResources, total] = await Promise.all([
|
const [rawResources, total] = await Promise.all([
|
||||||
@@ -75,7 +271,7 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
...baseQuery,
|
...baseQuery,
|
||||||
include: {
|
include: {
|
||||||
resourceRoles: {
|
resourceRoles: {
|
||||||
include: { role: { select: { id: true, name: true, color: true } } },
|
include: { role: { select: ROLE_BRIEF_SELECT } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -85,7 +281,12 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
|
|
||||||
const hasMore = rawResources.length > limit;
|
const hasMore = rawResources.length > limit;
|
||||||
const resources = hasMore ? rawResources.slice(0, limit) : rawResources;
|
const resources = hasMore ? rawResources.slice(0, limit) : rawResources;
|
||||||
const nextCursor = hasMore ? resources[resources.length - 1]!.id : null;
|
const nextCursor = hasMore
|
||||||
|
? JSON.stringify({
|
||||||
|
displayName: resources[resources.length - 1]!.displayName,
|
||||||
|
id: resources[resources.length - 1]!.id,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
||||||
return { resources, total, page, limit, nextCursor };
|
return { resources, total, page, limit, nextCursor };
|
||||||
}),
|
}),
|
||||||
@@ -93,33 +294,42 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
getById: protectedProcedure
|
getById: protectedProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const resource = await ctx.db.resource.findUnique({
|
const resource = await findUniqueOrThrow(
|
||||||
|
ctx.db.resource.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: {
|
include: {
|
||||||
blueprint: true,
|
blueprint: true,
|
||||||
resourceRoles: {
|
resourceRoles: {
|
||||||
include: { role: { select: { id: true, name: true, color: true } } },
|
include: { role: { select: ROLE_BRIEF_SELECT } },
|
||||||
},
|
},
|
||||||
areaRole: { select: { id: true, name: true } },
|
areaRole: { select: { id: true, name: true } },
|
||||||
user: { select: { email: true } },
|
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
"Resource",
|
||||||
|
);
|
||||||
|
|
||||||
if (!resource) {
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
return {
|
||||||
}
|
...anonymizeResource(resource, directory),
|
||||||
|
isOwnedByCurrentUser: Boolean(resource.userId && ctx.dbUser?.id && resource.userId === ctx.dbUser.id),
|
||||||
return resource;
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getByEid: protectedProcedure
|
getByEid: protectedProcedure
|
||||||
.input(z.object({ eid: z.string() }))
|
.input(z.object({ eid: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const resource = await ctx.db.resource.findUnique({ where: { eid: input.eid } });
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
|
let resource = await ctx.db.resource.findUnique({ where: { eid: input.eid } });
|
||||||
|
if (!resource && directory) {
|
||||||
|
const resourceId = directory.byAliasEid.get(input.eid.trim().toLowerCase());
|
||||||
|
if (resourceId) {
|
||||||
|
resource = await ctx.db.resource.findUnique({ where: { id: resourceId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!resource) {
|
if (!resource) {
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
||||||
}
|
}
|
||||||
return resource;
|
return anonymizeResource(resource, directory);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
create: managerProcedure
|
create: managerProcedure
|
||||||
@@ -194,7 +404,7 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
: undefined,
|
: undefined,
|
||||||
} as unknown as Parameters<typeof ctx.db.resource.create>[0]["data"],
|
} as unknown as Parameters<typeof ctx.db.resource.create>[0]["data"],
|
||||||
include: {
|
include: {
|
||||||
resourceRoles: { include: { role: { select: { id: true, name: true, color: true } } } },
|
resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,10 +425,10 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
.input(z.object({ id: z.string(), data: UpdateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }) }))
|
.input(z.object({ id: z.string(), data: UpdateResourceSchema.extend({ roles: z.array(ResourceRoleSchema).optional() }) }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
|
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
|
||||||
const existing = await ctx.db.resource.findUnique({ where: { id: input.id } });
|
const existing = await findUniqueOrThrow(
|
||||||
if (!existing) {
|
ctx.db.resource.findUnique({ where: { id: input.id } }),
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
"Resource",
|
||||||
}
|
);
|
||||||
|
|
||||||
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
|
const nextBlueprintId = input.data.blueprintId ?? existing.blueprintId ?? undefined;
|
||||||
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>;
|
const nextDynamicFields = (input.data.dynamicFields ?? existing.dynamicFields ?? {}) as Record<string, unknown>;
|
||||||
@@ -275,7 +485,7 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
...(input.data.fte !== undefined ? { fte: input.data.fte } : {}),
|
...(input.data.fte !== undefined ? { fte: input.data.fte } : {}),
|
||||||
} as unknown as Parameters<typeof ctx.db.resource.update>[0]["data"],
|
} as unknown as Parameters<typeof ctx.db.resource.update>[0]["data"],
|
||||||
include: {
|
include: {
|
||||||
resourceRoles: { include: { role: { select: { id: true, name: true, color: true } } } },
|
resourceRoles: { include: { role: { select: ROLE_BRIEF_SELECT } } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -415,10 +625,10 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
|
requirePermission(ctx, PermissionKey.MANAGE_RESOURCES);
|
||||||
const existing = await ctx.db.resource.findUnique({ where: { id: input.resourceId } });
|
await findUniqueOrThrow(
|
||||||
if (!existing) {
|
ctx.db.resource.findUnique({ where: { id: input.resourceId } }),
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
"Resource",
|
||||||
}
|
);
|
||||||
|
|
||||||
await ctx.db.resource.update({
|
await ctx.db.resource.update({
|
||||||
where: { id: input.resourceId },
|
where: { id: input.resourceId },
|
||||||
@@ -693,7 +903,8 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
.filter((r): r is NonNullable<typeof r> => r !== null)
|
.filter((r): r is NonNullable<typeof r> => r !== null)
|
||||||
.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
||||||
|
|
||||||
return results;
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
|
return anonymizeResources(results, directory);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ─── Self-service ────────────────────────────────────────────────────────────
|
// ─── Self-service ────────────────────────────────────────────────────────────
|
||||||
@@ -706,7 +917,8 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
where: { email },
|
where: { email },
|
||||||
select: { resource: { select: { id: true, displayName: true, eid: true, chapter: true } } },
|
select: { resource: { select: { id: true, displayName: true, eid: true, chapter: true } } },
|
||||||
});
|
});
|
||||||
return user?.resource ?? null;
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
|
return user?.resource ? anonymizeResource(user.resource, directory) : null;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
// ─── Value Score ─────────────────────────────────────────────────────────────
|
// ─── Value Score ─────────────────────────────────────────────────────────────
|
||||||
@@ -740,83 +952,12 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
take: input.limit,
|
take: input.limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return resources;
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
|
return anonymizeResources(resources, directory);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
recomputeValueScores: adminProcedure.mutation(async ({ ctx }) => {
|
recomputeValueScores: adminProcedure.mutation(async ({ ctx }) => {
|
||||||
const [resources, settings] = await Promise.all([
|
return recomputeResourceValueScores(ctx.db);
|
||||||
ctx.db.resource.findMany({
|
|
||||||
where: { isActive: true },
|
|
||||||
select: {
|
|
||||||
id: true,
|
|
||||||
skills: true,
|
|
||||||
lcrCents: true,
|
|
||||||
chargeabilityTarget: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
ctx.db.systemSettings.findUnique({ where: { id: "singleton" } }),
|
|
||||||
]);
|
|
||||||
const bookings = await listAssignmentBookings(ctx.db, {
|
|
||||||
startDate: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
|
|
||||||
endDate: new Date(),
|
|
||||||
resourceIds: resources.map((resource) => resource.id),
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultWeights = {
|
|
||||||
skillDepth: VALUE_SCORE_WEIGHTS.SKILL_DEPTH,
|
|
||||||
skillBreadth: VALUE_SCORE_WEIGHTS.SKILL_BREADTH,
|
|
||||||
costEfficiency: VALUE_SCORE_WEIGHTS.COST_EFFICIENCY,
|
|
||||||
chargeability: VALUE_SCORE_WEIGHTS.CHARGEABILITY,
|
|
||||||
experience: VALUE_SCORE_WEIGHTS.EXPERIENCE,
|
|
||||||
};
|
|
||||||
const weights = (settings?.scoreWeights as unknown as typeof defaultWeights) ?? defaultWeights;
|
|
||||||
|
|
||||||
const maxLcrCents = resources.reduce((max, r) => Math.max(max, r.lcrCents), 0);
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
type SkillRow = { skill: string; category?: string; proficiency: number; yearsExperience?: number; isMainSkill?: boolean };
|
|
||||||
const totalWorkDays = 90 * (5 / 7); // approx working days
|
|
||||||
const availableHours = totalWorkDays * 8;
|
|
||||||
|
|
||||||
const updates = resources.map((resource) => {
|
|
||||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === resource.id);
|
|
||||||
const bookedHours = resourceBookings.reduce((sum, booking) => {
|
|
||||||
const days = Math.max(
|
|
||||||
0,
|
|
||||||
(new Date(booking.endDate).getTime() - new Date(booking.startDate).getTime()) /
|
|
||||||
(1000 * 60 * 60 * 24) +
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
return sum + booking.hoursPerDay * days;
|
|
||||||
}, 0);
|
|
||||||
const currentChargeability = availableHours > 0 ? Math.min(100, (bookedHours / availableHours) * 100) : 0;
|
|
||||||
const skills = (resource.skills as unknown as SkillRow[]) ?? [];
|
|
||||||
|
|
||||||
const breakdown = computeValueScore(
|
|
||||||
{
|
|
||||||
skills: skills as unknown as import("@planarchy/shared").SkillEntry[],
|
|
||||||
lcrCents: resource.lcrCents,
|
|
||||||
chargeabilityTarget: resource.chargeabilityTarget,
|
|
||||||
currentChargeability,
|
|
||||||
maxLcrCents,
|
|
||||||
},
|
|
||||||
weights,
|
|
||||||
);
|
|
||||||
|
|
||||||
return ctx.db.resource.update({
|
|
||||||
where: { id: resource.id },
|
|
||||||
data: {
|
|
||||||
valueScore: breakdown.total,
|
|
||||||
valueScoreBreakdown: breakdown as unknown as import("@planarchy/db").Prisma.InputJsonValue,
|
|
||||||
valueScoreUpdatedAt: now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
await ctx.db.$transaction(updates);
|
|
||||||
const updated = updates.length;
|
|
||||||
|
|
||||||
return { updated };
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
listWithUtilization: controllerProcedure
|
listWithUtilization: controllerProcedure
|
||||||
@@ -825,6 +966,7 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
startDate: z.string().datetime().optional(),
|
startDate: z.string().datetime().optional(),
|
||||||
endDate: z.string().datetime().optional(),
|
endDate: z.string().datetime().optional(),
|
||||||
chapter: z.string().optional(),
|
chapter: z.string().optional(),
|
||||||
|
includeProposed: z.boolean().default(false),
|
||||||
limit: z.number().int().min(1).max(500).default(100),
|
limit: z.number().int().min(1).max(500).default(100),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -872,6 +1014,7 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
endDate: end,
|
endDate: end,
|
||||||
resourceIds: resources.map((resource) => resource.id),
|
resourceIds: resources.map((resource) => resource.id),
|
||||||
});
|
});
|
||||||
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
|
|
||||||
return resources.map((r) => {
|
return resources.map((r) => {
|
||||||
const avail = r.availability as Record<string, number>;
|
const avail = r.availability as Record<string, number>;
|
||||||
@@ -882,7 +1025,11 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
|
|
||||||
let bookedHours = 0;
|
let bookedHours = 0;
|
||||||
let isOverbooked = false;
|
let isOverbooked = false;
|
||||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
|
const resourceBookings = bookings.filter(
|
||||||
|
(booking) =>
|
||||||
|
booking.resourceId === r.id &&
|
||||||
|
(input.includeProposed || booking.status !== "PROPOSED"),
|
||||||
|
);
|
||||||
for (const a of resourceBookings) {
|
for (const a of resourceBookings) {
|
||||||
const days =
|
const days =
|
||||||
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) /
|
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime()) /
|
||||||
@@ -895,19 +1042,19 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
const utilizationPercent =
|
const utilizationPercent =
|
||||||
availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0;
|
availableHours > 0 ? Math.round((bookedHours / availableHours) * 100) : 0;
|
||||||
|
|
||||||
return {
|
return anonymizeResource({
|
||||||
...r,
|
...r,
|
||||||
bookingCount: resourceBookings.length,
|
bookingCount: resourceBookings.length,
|
||||||
bookedHours: Math.round(bookedHours),
|
bookedHours: Math.round(bookedHours),
|
||||||
availableHours: Math.round(availableHours),
|
availableHours: Math.round(availableHours),
|
||||||
utilizationPercent,
|
utilizationPercent,
|
||||||
isOverbooked,
|
isOverbooked,
|
||||||
};
|
}, directory);
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getChargeabilityStats: controllerProcedure
|
getChargeabilityStats: controllerProcedure
|
||||||
.input(z.object({ resourceId: z.string().optional() }))
|
.input(z.object({ includeProposed: z.boolean().default(false), resourceId: z.string().optional() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
const start = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
@@ -932,26 +1079,24 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
endDate: end,
|
endDate: end,
|
||||||
resourceIds: resources.map((resource) => resource.id),
|
resourceIds: resources.map((resource) => resource.id),
|
||||||
});
|
});
|
||||||
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
|
|
||||||
return resources.map((r) => {
|
return resources.map((r) => {
|
||||||
const avail = r.availability as unknown as WeekdayAvailability;
|
const avail = r.availability as unknown as WeekdayAvailability;
|
||||||
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
|
const resourceBookings = bookings.filter((booking) => booking.resourceId === r.id);
|
||||||
|
|
||||||
// Actual: CONFIRMED or ACTIVE allocations on non-DRAFT, non-CANCELLED projects
|
const actualAllocs = resourceBookings.filter((booking) =>
|
||||||
const actualAllocs = resourceBookings.filter(
|
isChargeabilityActualBooking(booking, input.includeProposed),
|
||||||
(a) =>
|
|
||||||
(a.status === "CONFIRMED" || a.status === "ACTIVE") &&
|
|
||||||
a.project.status !== "DRAFT" &&
|
|
||||||
a.project.status !== "CANCELLED",
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Expected: all non-CANCELLED assignment-like bookings, all project statuses
|
const expectedAllocs = resourceBookings.filter((booking) =>
|
||||||
const expectedAllocs = resourceBookings;
|
isChargeabilityRelevantProject(booking.project, true),
|
||||||
|
);
|
||||||
|
|
||||||
const actual = computeChargeability(avail, actualAllocs, start, end);
|
const actual = computeChargeability(avail, actualAllocs, start, end);
|
||||||
const expected = computeChargeability(avail, expectedAllocs, start, end);
|
const expected = computeChargeability(avail, expectedAllocs, start, end);
|
||||||
|
|
||||||
return {
|
return anonymizeResource({
|
||||||
id: r.id,
|
id: r.id,
|
||||||
eid: r.eid,
|
eid: r.eid,
|
||||||
displayName: r.displayName,
|
displayName: r.displayName,
|
||||||
@@ -960,7 +1105,7 @@ export const resourceRouter = createTRPCRouter({
|
|||||||
actualChargeability: actual.chargeability,
|
actualChargeability: actual.chargeability,
|
||||||
expectedChargeability: expected.chargeability,
|
expectedChargeability: expected.chargeability,
|
||||||
availableHours: actual.availableHours,
|
availableHours: actual.availableHours,
|
||||||
};
|
}, directory);
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { countPlanningEntries } from "@planarchy/application";
|
|||||||
import { CreateRoleSchema, PermissionKey, UpdateRoleSchema } from "@planarchy/shared";
|
import { CreateRoleSchema, PermissionKey, UpdateRoleSchema } from "@planarchy/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { emitRoleCreated, emitRoleDeleted, emitRoleUpdated } from "../sse/event-bus.js";
|
import { emitRoleCreated, emitRoleDeleted, emitRoleUpdated } from "../sse/event-bus.js";
|
||||||
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||||
|
|
||||||
@@ -81,7 +82,8 @@ export const roleRouter = createTRPCRouter({
|
|||||||
getById: protectedProcedure
|
getById: protectedProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const role = await ctx.db.role.findUnique({
|
const role = await findUniqueOrThrow(
|
||||||
|
ctx.db.role.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: {
|
include: {
|
||||||
_count: { select: { resourceRoles: true } },
|
_count: { select: { resourceRoles: true } },
|
||||||
@@ -91,10 +93,9 @@ export const roleRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
if (!role) {
|
"Role",
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" });
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return attachSinglePlanningEntryCount(ctx.db, role);
|
return attachSinglePlanningEntryCount(ctx.db, role);
|
||||||
}),
|
}),
|
||||||
@@ -141,10 +142,10 @@ export const roleRouter = createTRPCRouter({
|
|||||||
.input(z.object({ id: z.string(), data: UpdateRoleSchema }))
|
.input(z.object({ id: z.string(), data: UpdateRoleSchema }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_ROLES);
|
requirePermission(ctx, PermissionKey.MANAGE_ROLES);
|
||||||
const existing = await ctx.db.role.findUnique({ where: { id: input.id } });
|
const existing = await findUniqueOrThrow(
|
||||||
if (!existing) {
|
ctx.db.role.findUnique({ where: { id: input.id } }),
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" });
|
"Role",
|
||||||
}
|
);
|
||||||
|
|
||||||
if (input.data.name && input.data.name !== existing.name) {
|
if (input.data.name && input.data.name !== existing.name) {
|
||||||
const nameConflict = await ctx.db.role.findUnique({ where: { name: input.data.name } });
|
const nameConflict = await ctx.db.role.findUnique({ where: { name: input.data.name } });
|
||||||
@@ -182,13 +183,13 @@ export const roleRouter = createTRPCRouter({
|
|||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
requirePermission(ctx, PermissionKey.MANAGE_ROLES);
|
requirePermission(ctx, PermissionKey.MANAGE_ROLES);
|
||||||
const role = await ctx.db.role.findUnique({
|
const role = await findUniqueOrThrow(
|
||||||
|
ctx.db.role.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: { _count: { select: { resourceRoles: true } } },
|
include: { _count: { select: { resourceRoles: true } } },
|
||||||
});
|
}),
|
||||||
if (!role) {
|
"Role",
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Role not found" });
|
);
|
||||||
}
|
|
||||||
|
|
||||||
const roleWithCounts = await attachSinglePlanningEntryCount(ctx.db, role);
|
const roleWithCounts = await attachSinglePlanningEntryCount(ctx.db, role);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { analyzeUtilization, findCapacityWindows, rankResources } from "@planarc
|
|||||||
import { listAssignmentBookings } from "@planarchy/application";
|
import { listAssignmentBookings } from "@planarchy/application";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
import { createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||||
|
|
||||||
export const staffingRouter = createTRPCRouter({
|
export const staffingRouter = createTRPCRouter({
|
||||||
@@ -108,7 +109,8 @@ export const staffingRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const resource = await ctx.db.resource.findUnique({
|
const resource = await findUniqueOrThrow(
|
||||||
|
ctx.db.resource.findUnique({
|
||||||
where: { id: input.resourceId },
|
where: { id: input.resourceId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -116,11 +118,9 @@ export const staffingRouter = createTRPCRouter({
|
|||||||
chargeabilityTarget: true,
|
chargeabilityTarget: true,
|
||||||
availability: true,
|
availability: true,
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
"Resource",
|
||||||
if (!resource) {
|
);
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const resourceBookings = await listAssignmentBookings(ctx.db, {
|
const resourceBookings = await listAssignmentBookings(ctx.db, {
|
||||||
startDate: input.startDate,
|
startDate: input.startDate,
|
||||||
@@ -161,18 +161,17 @@ export const staffingRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const resource = await ctx.db.resource.findUnique({
|
const resource = await findUniqueOrThrow(
|
||||||
|
ctx.db.resource.findUnique({
|
||||||
where: { id: input.resourceId },
|
where: { id: input.resourceId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
displayName: true,
|
displayName: true,
|
||||||
availability: true,
|
availability: true,
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
"Resource",
|
||||||
if (!resource) {
|
);
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Resource not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const resourceBookings = await listAssignmentBookings(ctx.db, {
|
const resourceBookings = await listAssignmentBookings(ctx.db, {
|
||||||
startDate: input.startDate,
|
startDate: input.startDate,
|
||||||
|
|||||||
@@ -13,9 +13,10 @@ import { calculateAllocation, computeBudgetStatus, validateShift } from "@planar
|
|||||||
import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@planarchy/shared";
|
import { AllocationStatus, PermissionKey, ShiftProjectSchema, UpdateAllocationHoursSchema } from "@planarchy/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import {
|
import {
|
||||||
loadProjectPlanningReadModel,
|
loadProjectPlanningReadModel,
|
||||||
PROJECT_PLANNING_ASSIGNMENT_INCLUDE,
|
TIMELINE_ASSIGNMENT_INCLUDE,
|
||||||
PROJECT_PLANNING_DEMAND_INCLUDE,
|
PROJECT_PLANNING_DEMAND_INCLUDE,
|
||||||
} from "./project-planning-read-model.js";
|
} from "./project-planning-read-model.js";
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +26,7 @@ import {
|
|||||||
} from "../sse/event-bus.js";
|
} from "../sse/event-bus.js";
|
||||||
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
|
import { buildTimelineShiftPlan } from "./timeline-shift-planning.js";
|
||||||
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
import { createTRPCRouter, managerProcedure, protectedProcedure, requirePermission } from "../trpc.js";
|
||||||
|
import { anonymizeResource, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||||
|
|
||||||
type ShiftDbClient = Pick<
|
type ShiftDbClient = Pick<
|
||||||
PrismaClient,
|
PrismaClient,
|
||||||
@@ -33,7 +35,7 @@ type ShiftDbClient = Pick<
|
|||||||
|
|
||||||
type TimelineEntriesDbClient = Pick<
|
type TimelineEntriesDbClient = Pick<
|
||||||
PrismaClient,
|
PrismaClient,
|
||||||
"demandRequirement" | "assignment"
|
"demandRequirement" | "assignment" | "resource"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type TimelineEntriesFilters = {
|
type TimelineEntriesFilters = {
|
||||||
@@ -41,6 +43,8 @@ type TimelineEntriesFilters = {
|
|||||||
endDate: Date;
|
endDate: Date;
|
||||||
resourceIds?: string[] | undefined;
|
resourceIds?: string[] | undefined;
|
||||||
projectIds?: string[] | undefined;
|
projectIds?: string[] | undefined;
|
||||||
|
chapters?: string[] | undefined;
|
||||||
|
eids?: string[] | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
function getAssignmentResourceIds(
|
function getAssignmentResourceIds(
|
||||||
@@ -59,10 +63,34 @@ async function loadTimelineEntriesReadModel(
|
|||||||
db: TimelineEntriesDbClient,
|
db: TimelineEntriesDbClient,
|
||||||
input: TimelineEntriesFilters,
|
input: TimelineEntriesFilters,
|
||||||
) {
|
) {
|
||||||
const { startDate, endDate, resourceIds, projectIds } = input;
|
const { startDate, endDate, resourceIds, projectIds, chapters, eids } = input;
|
||||||
|
|
||||||
|
// When resource-level filters are active (resourceIds, chapters, or eids),
|
||||||
|
// resolve matching resource IDs so we can push the filter to the DB query.
|
||||||
|
const effectiveResourceIds = await (async () => {
|
||||||
|
if (resourceIds && resourceIds.length > 0) return resourceIds;
|
||||||
|
const hasChapters = chapters && chapters.length > 0;
|
||||||
|
const hasEids = eids && eids.length > 0;
|
||||||
|
if (!hasChapters && !hasEids) return undefined;
|
||||||
|
const matching = await db.resource.findMany({
|
||||||
|
where: {
|
||||||
|
...(hasChapters && hasEids
|
||||||
|
? { AND: [{ chapter: { in: chapters } }, { eid: { in: eids } }] }
|
||||||
|
: hasChapters
|
||||||
|
? { chapter: { in: chapters } }
|
||||||
|
: { eid: { in: eids! } }),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
return matching.map((r) => r.id);
|
||||||
|
})();
|
||||||
|
|
||||||
|
// When filtering by resource (either explicit resourceIds or derived from chapters),
|
||||||
|
// demands without a resource are excluded.
|
||||||
|
const excludeDemands = effectiveResourceIds !== undefined;
|
||||||
|
|
||||||
const [demandRequirements, assignments] = await Promise.all([
|
const [demandRequirements, assignments] = await Promise.all([
|
||||||
resourceIds && resourceIds.length > 0
|
excludeDemands
|
||||||
? Promise.resolve([])
|
? Promise.resolve([])
|
||||||
: db.demandRequirement.findMany({
|
: db.demandRequirement.findMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -79,10 +107,10 @@ async function loadTimelineEntriesReadModel(
|
|||||||
status: { not: "CANCELLED" },
|
status: { not: "CANCELLED" },
|
||||||
startDate: { lte: endDate },
|
startDate: { lte: endDate },
|
||||||
endDate: { gte: startDate },
|
endDate: { gte: startDate },
|
||||||
...(resourceIds ? { resourceId: { in: resourceIds } } : {}),
|
...(effectiveResourceIds ? { resourceId: { in: effectiveResourceIds } } : {}),
|
||||||
...(projectIds ? { projectId: { in: projectIds } } : {}),
|
...(projectIds ? { projectId: { in: projectIds } } : {}),
|
||||||
},
|
},
|
||||||
include: PROJECT_PLANNING_ASSIGNMENT_INCLUDE,
|
include: TIMELINE_ASSIGNMENT_INCLUDE,
|
||||||
orderBy: [{ startDate: "asc" }, { resourceId: "asc" }],
|
orderBy: [{ startDate: "asc" }, { resourceId: "asc" }],
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -144,6 +172,19 @@ async function loadProjectShiftContext(db: ShiftDbClient, projectId: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function anonymizeResourceOnEntry<T extends { resource?: { id: string } | null }>(
|
||||||
|
entry: T,
|
||||||
|
directory: Awaited<ReturnType<typeof getAnonymizationDirectory>>,
|
||||||
|
): T {
|
||||||
|
if (!entry.resource) {
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...entry,
|
||||||
|
resource: anonymizeResource(entry.resource, directory),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export const timelineRouter = createTRPCRouter({
|
export const timelineRouter = createTRPCRouter({
|
||||||
/**
|
/**
|
||||||
* Get all timeline entries (projects + allocations) for a date range.
|
* Get all timeline entries (projects + allocations) for a date range.
|
||||||
@@ -156,11 +197,14 @@ export const timelineRouter = createTRPCRouter({
|
|||||||
endDate: z.coerce.date(),
|
endDate: z.coerce.date(),
|
||||||
resourceIds: z.array(z.string()).optional(),
|
resourceIds: z.array(z.string()).optional(),
|
||||||
projectIds: z.array(z.string()).optional(),
|
projectIds: z.array(z.string()).optional(),
|
||||||
|
chapters: z.array(z.string()).optional(),
|
||||||
|
eids: z.array(z.string()).optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
|
const readModel = await loadTimelineEntriesReadModel(ctx.db, input);
|
||||||
return readModel.allocations;
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
|
return readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getEntriesView: protectedProcedure
|
getEntriesView: protectedProcedure
|
||||||
@@ -170,9 +214,22 @@ export const timelineRouter = createTRPCRouter({
|
|||||||
endDate: z.coerce.date(),
|
endDate: z.coerce.date(),
|
||||||
resourceIds: z.array(z.string()).optional(),
|
resourceIds: z.array(z.string()).optional(),
|
||||||
projectIds: z.array(z.string()).optional(),
|
projectIds: z.array(z.string()).optional(),
|
||||||
|
chapters: z.array(z.string()).optional(),
|
||||||
|
eids: z.array(z.string()).optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => loadTimelineEntriesReadModel(ctx.db, input)),
|
.query(async ({ ctx, input }) => {
|
||||||
|
const [readModel, directory] = await Promise.all([
|
||||||
|
loadTimelineEntriesReadModel(ctx.db, input),
|
||||||
|
getAnonymizationDirectory(ctx.db),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...readModel,
|
||||||
|
allocations: readModel.allocations.map((allocation) => anonymizeResourceOnEntry(allocation, directory)),
|
||||||
|
assignments: readModel.assignments.map((assignment) => anonymizeResourceOnEntry(assignment, directory)),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get full project context for a project:
|
* Get full project context for a project:
|
||||||
@@ -218,12 +275,20 @@ export const timelineRouter = createTRPCRouter({
|
|||||||
resourceIds,
|
resourceIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
project,
|
project,
|
||||||
allocations: planningRead.readModel.allocations,
|
allocations: planningRead.readModel.allocations.map((allocation) =>
|
||||||
|
anonymizeResourceOnEntry(allocation, directory),
|
||||||
|
),
|
||||||
demands: planningRead.readModel.demands,
|
demands: planningRead.readModel.demands,
|
||||||
assignments: planningRead.readModel.assignments,
|
assignments: planningRead.readModel.assignments.map((assignment) =>
|
||||||
allResourceAllocations,
|
anonymizeResourceOnEntry(assignment, directory),
|
||||||
|
),
|
||||||
|
allResourceAllocations: allResourceAllocations.map((allocation) =>
|
||||||
|
anonymizeResourceOnEntry(allocation, directory),
|
||||||
|
),
|
||||||
resourceIds,
|
resourceIds,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -572,7 +637,8 @@ export const timelineRouter = createTRPCRouter({
|
|||||||
getBudgetStatus: protectedProcedure
|
getBudgetStatus: protectedProcedure
|
||||||
.input(z.object({ projectId: z.string() }))
|
.input(z.object({ projectId: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const project = await ctx.db.project.findUnique({
|
const project = await findUniqueOrThrow(
|
||||||
|
ctx.db.project.findUnique({
|
||||||
where: { id: input.projectId },
|
where: { id: input.projectId },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -581,11 +647,9 @@ export const timelineRouter = createTRPCRouter({
|
|||||||
startDate: true,
|
startDate: true,
|
||||||
endDate: true,
|
endDate: true,
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
"Project",
|
||||||
if (!project) {
|
);
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Project not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const bookings = await listAssignmentBookings(ctx.db, {
|
const bookings = await listAssignmentBookings(ctx.db, {
|
||||||
startDate: project.startDate,
|
startDate: project.startDate,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { Prisma } from "@planarchy/db";
|
import { Prisma } from "@planarchy/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
|
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||||
|
|
||||||
export const userRouter = createTRPCRouter({
|
export const userRouter = createTRPCRouter({
|
||||||
@@ -28,7 +29,8 @@ export const userRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
me: protectedProcedure.query(async ({ ctx }) => {
|
me: protectedProcedure.query(async ({ ctx }) => {
|
||||||
const user = await ctx.db.user.findUnique({
|
const user = await findUniqueOrThrow(
|
||||||
|
ctx.db.user.findUnique({
|
||||||
where: { email: ctx.session.user?.email ?? "" },
|
where: { email: ctx.session.user?.email ?? "" },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -38,11 +40,9 @@ export const userRouter = createTRPCRouter({
|
|||||||
permissionOverrides: true,
|
permissionOverrides: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
|
"User",
|
||||||
if (!user) {
|
);
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
} from "@planarchy/shared";
|
} from "@planarchy/shared";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
|
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc.js";
|
||||||
|
|
||||||
export const utilizationCategoryRouter = createTRPCRouter({
|
export const utilizationCategoryRouter = createTRPCRouter({
|
||||||
@@ -21,11 +22,13 @@ export const utilizationCategoryRouter = createTRPCRouter({
|
|||||||
getById: protectedProcedure
|
getById: protectedProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const cat = await ctx.db.utilizationCategory.findUnique({
|
const cat = await findUniqueOrThrow(
|
||||||
|
ctx.db.utilizationCategory.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: { _count: { select: { projects: true } } },
|
include: { _count: { select: { projects: true } } },
|
||||||
});
|
}),
|
||||||
if (!cat) throw new TRPCError({ code: "NOT_FOUND", message: "Utilization category not found" });
|
"Utilization category",
|
||||||
|
);
|
||||||
return cat;
|
return cat;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -59,8 +62,10 @@ export const utilizationCategoryRouter = createTRPCRouter({
|
|||||||
update: adminProcedure
|
update: adminProcedure
|
||||||
.input(z.object({ id: z.string(), data: UpdateUtilizationCategorySchema }))
|
.input(z.object({ id: z.string(), data: UpdateUtilizationCategorySchema }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.utilizationCategory.findUnique({ where: { id: input.id } });
|
const existing = await findUniqueOrThrow(
|
||||||
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: "Utilization category not found" });
|
ctx.db.utilizationCategory.findUnique({ where: { id: input.id } }),
|
||||||
|
"Utilization category",
|
||||||
|
);
|
||||||
|
|
||||||
if (input.data.code && input.data.code !== existing.code) {
|
if (input.data.code && input.data.code !== existing.code) {
|
||||||
const conflict = await ctx.db.utilizationCategory.findUnique({ where: { code: input.data.code } });
|
const conflict = await ctx.db.utilizationCategory.findUnique({ where: { code: input.data.code } });
|
||||||
|
|||||||
@@ -2,13 +2,31 @@ import { UpdateVacationStatusSchema, getPublicHolidays } from "@planarchy/shared
|
|||||||
import { VacationStatus, VacationType } from "@planarchy/db";
|
import { VacationStatus, VacationType } from "@planarchy/db";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { findUniqueOrThrow } from "../db/helpers.js";
|
||||||
import { emitVacationCreated, emitVacationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
|
import { emitVacationCreated, emitVacationUpdated, emitNotificationCreated } from "../sse/event-bus.js";
|
||||||
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
|
import { createTRPCRouter, adminProcedure, managerProcedure, protectedProcedure } from "../trpc.js";
|
||||||
import { sendEmail } from "../lib/email.js";
|
import { sendEmail } from "../lib/email.js";
|
||||||
|
import { anonymizeResource, anonymizeUser, getAnonymizationDirectory } from "../lib/anonymization.js";
|
||||||
|
|
||||||
/** Types that consume from annual leave balance */
|
/** Types that consume from annual leave balance */
|
||||||
const BALANCE_TYPES = [VacationType.ANNUAL, VacationType.OTHER];
|
const BALANCE_TYPES = [VacationType.ANNUAL, VacationType.OTHER];
|
||||||
|
|
||||||
|
function anonymizeVacationRecord<T extends {
|
||||||
|
resource?: { id: string } | null;
|
||||||
|
requestedBy?: { id?: string | null; name?: string | null; email?: string | null } | null;
|
||||||
|
approvedBy?: { id?: string | null; name?: string | null; email?: string | null } | null;
|
||||||
|
}>(
|
||||||
|
vacation: T,
|
||||||
|
directory: Awaited<ReturnType<typeof getAnonymizationDirectory>>,
|
||||||
|
): T {
|
||||||
|
return {
|
||||||
|
...vacation,
|
||||||
|
...(vacation.resource ? { resource: anonymizeResource(vacation.resource, directory) } : {}),
|
||||||
|
...(vacation.requestedBy ? { requestedBy: anonymizeUser(vacation.requestedBy, directory) } : {}),
|
||||||
|
...(vacation.approvedBy ? { approvedBy: anonymizeUser(vacation.approvedBy, directory) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Send in-app notification + optional email when vacation status changes */
|
/** Send in-app notification + optional email when vacation status changes */
|
||||||
async function notifyVacationStatus(
|
async function notifyVacationStatus(
|
||||||
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
|
db: Parameters<Parameters<typeof protectedProcedure["query"]>[0]>[0]["ctx"]["db"],
|
||||||
@@ -72,7 +90,7 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
return ctx.db.vacation.findMany({
|
const vacations = await ctx.db.vacation.findMany({
|
||||||
where: {
|
where: {
|
||||||
...(input.resourceId ? { resourceId: input.resourceId } : {}),
|
...(input.resourceId ? { resourceId: input.resourceId } : {}),
|
||||||
...(input.status ? { status: input.status } : {}),
|
...(input.status ? { status: input.status } : {}),
|
||||||
@@ -88,6 +106,8 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
orderBy: { startDate: "asc" },
|
orderBy: { startDate: "asc" },
|
||||||
take: input.limit,
|
take: input.limit,
|
||||||
});
|
});
|
||||||
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
|
return vacations.map((vacation) => anonymizeVacationRecord(vacation, directory));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -96,18 +116,19 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
getById: protectedProcedure
|
getById: protectedProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.query(async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
const vacation = await ctx.db.vacation.findUnique({
|
const vacation = await findUniqueOrThrow(
|
||||||
|
ctx.db.vacation.findUnique({
|
||||||
where: { id: input.id },
|
where: { id: input.id },
|
||||||
include: {
|
include: {
|
||||||
resource: { select: { id: true, displayName: true, eid: true } },
|
resource: { select: { id: true, displayName: true, eid: true } },
|
||||||
requestedBy: { select: { id: true, name: true, email: true } },
|
requestedBy: { select: { id: true, name: true, email: true } },
|
||||||
approvedBy: { select: { id: true, name: true, email: true } },
|
approvedBy: { select: { id: true, name: true, email: true } },
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
if (!vacation) {
|
"Vacation",
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
|
);
|
||||||
}
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
return vacation;
|
return anonymizeVacationRecord(vacation, directory);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -182,7 +203,8 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
|
|
||||||
emitVacationCreated({ id: vacation.id, resourceId: vacation.resourceId, status: vacation.status });
|
emitVacationCreated({ id: vacation.id, resourceId: vacation.resourceId, status: vacation.status });
|
||||||
|
|
||||||
return vacation;
|
const directory = await getAnonymizationDirectory(ctx.db);
|
||||||
|
return anonymizeVacationRecord(vacation, directory);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -191,10 +213,10 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
approve: managerProcedure
|
approve: managerProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.vacation.findUnique({ where: { id: input.id } });
|
const existing = await findUniqueOrThrow(
|
||||||
if (!existing) {
|
ctx.db.vacation.findUnique({ where: { id: input.id } }),
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
|
"Vacation",
|
||||||
}
|
);
|
||||||
const approvableStatuses: string[] = [VacationStatus.PENDING, VacationStatus.CANCELLED, VacationStatus.REJECTED];
|
const approvableStatuses: string[] = [VacationStatus.PENDING, VacationStatus.CANCELLED, VacationStatus.REJECTED];
|
||||||
if (!approvableStatuses.includes(existing.status)) {
|
if (!approvableStatuses.includes(existing.status)) {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING, CANCELLED, or REJECTED vacations can be approved" });
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING, CANCELLED, or REJECTED vacations can be approved" });
|
||||||
@@ -230,10 +252,10 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
reject: managerProcedure
|
reject: managerProcedure
|
||||||
.input(z.object({ id: z.string(), rejectionReason: z.string().max(500).optional() }))
|
.input(z.object({ id: z.string(), rejectionReason: z.string().max(500).optional() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.vacation.findUnique({ where: { id: input.id } });
|
const existing = await findUniqueOrThrow(
|
||||||
if (!existing) {
|
ctx.db.vacation.findUnique({ where: { id: input.id } }),
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
|
"Vacation",
|
||||||
}
|
);
|
||||||
if (existing.status !== VacationStatus.PENDING) {
|
if (existing.status !== VacationStatus.PENDING) {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING vacations can be rejected" });
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Only PENDING vacations can be rejected" });
|
||||||
}
|
}
|
||||||
@@ -324,10 +346,10 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
cancel: protectedProcedure
|
cancel: protectedProcedure
|
||||||
.input(z.object({ id: z.string() }))
|
.input(z.object({ id: z.string() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.vacation.findUnique({ where: { id: input.id } });
|
const existing = await findUniqueOrThrow(
|
||||||
if (!existing) {
|
ctx.db.vacation.findUnique({ where: { id: input.id } }),
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
|
"Vacation",
|
||||||
}
|
);
|
||||||
if (existing.status === VacationStatus.CANCELLED) {
|
if (existing.status === VacationStatus.CANCELLED) {
|
||||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Already cancelled" });
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Already cancelled" });
|
||||||
}
|
}
|
||||||
@@ -511,10 +533,10 @@ export const vacationRouter = createTRPCRouter({
|
|||||||
updateStatus: protectedProcedure
|
updateStatus: protectedProcedure
|
||||||
.input(UpdateVacationStatusSchema)
|
.input(UpdateVacationStatusSchema)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const existing = await ctx.db.vacation.findUnique({ where: { id: input.id } });
|
const existing = await findUniqueOrThrow(
|
||||||
if (!existing) {
|
ctx.db.vacation.findUnique({ where: { id: input.id } }),
|
||||||
throw new TRPCError({ code: "NOT_FOUND", message: "Vacation not found" });
|
"Vacation",
|
||||||
}
|
);
|
||||||
|
|
||||||
const userRecord = await ctx.db.user.findUnique({
|
const userRecord = await ctx.db.user.findUnique({
|
||||||
where: { email: ctx.session.user?.email ?? "" },
|
where: { email: ctx.session.user?.email ?? "" },
|
||||||
|
|||||||
@@ -12,6 +12,70 @@ type Subscriber = (event: SseEvent) => void;
|
|||||||
// Module-level subscriber registry (shared between EventBus and publishLocal)
|
// Module-level subscriber registry (shared between EventBus and publishLocal)
|
||||||
const subscribers = new Set<Subscriber>();
|
const subscribers = new Set<Subscriber>();
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Debounce buffer: aggregates rapid events of the same type within a 50ms
|
||||||
|
// window and delivers a single event per type to subscribers.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const DEBOUNCE_MS = 50;
|
||||||
|
|
||||||
|
interface BufferEntry {
|
||||||
|
payloads: Record<string, unknown>[];
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
firstTimestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const debounceBuffer = new Map<SseEventType, BufferEntry>();
|
||||||
|
|
||||||
|
/** Flush a single event type from the buffer and deliver to subscribers. */
|
||||||
|
function flushEventType(type: SseEventType): void {
|
||||||
|
const entry = debounceBuffer.get(type);
|
||||||
|
if (!entry) return;
|
||||||
|
debounceBuffer.delete(type);
|
||||||
|
|
||||||
|
const event: SseEvent =
|
||||||
|
entry.payloads.length === 1
|
||||||
|
? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp }
|
||||||
|
: {
|
||||||
|
type,
|
||||||
|
payload: { _batch: entry.payloads },
|
||||||
|
timestamp: entry.firstTimestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const fn of subscribers) {
|
||||||
|
fn(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Flush all pending debounce timers immediately (for cleanup / tests). */
|
||||||
|
export function flushPendingEvents(): void {
|
||||||
|
for (const [type, entry] of debounceBuffer) {
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
debounceBuffer.delete(type);
|
||||||
|
|
||||||
|
const event: SseEvent =
|
||||||
|
entry.payloads.length === 1
|
||||||
|
? { type, payload: entry.payloads[0]!, timestamp: entry.firstTimestamp }
|
||||||
|
: {
|
||||||
|
type,
|
||||||
|
payload: { _batch: entry.payloads },
|
||||||
|
timestamp: entry.firstTimestamp,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const fn of subscribers) {
|
||||||
|
fn(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancel all pending debounce timers without delivering (for shutdown). */
|
||||||
|
export function cancelPendingEvents(): void {
|
||||||
|
for (const [, entry] of debounceBuffer) {
|
||||||
|
clearTimeout(entry.timer);
|
||||||
|
}
|
||||||
|
debounceBuffer.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// Redis connection — use env var REDIS_URL or fallback to default dev URL
|
// Redis connection — use env var REDIS_URL or fallback to default dev URL
|
||||||
const REDIS_URL = process.env["REDIS_URL"] ?? "redis://localhost:6380";
|
const REDIS_URL = process.env["REDIS_URL"] ?? "redis://localhost:6380";
|
||||||
const CHANNEL = "planarchy:sse";
|
const CHANNEL = "planarchy:sse";
|
||||||
@@ -81,10 +145,24 @@ class EventBus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Local delivery: deliver to subscribers connected to THIS instance (called from Redis subscriber)
|
// Local delivery with debounce: buffer events of the same type within a 50ms
|
||||||
|
// window and then deliver a single (possibly aggregated) event to subscribers.
|
||||||
function publishLocal(event: SseEvent): void {
|
function publishLocal(event: SseEvent): void {
|
||||||
for (const fn of subscribers) {
|
const existing = debounceBuffer.get(event.type);
|
||||||
fn(event);
|
|
||||||
|
if (existing) {
|
||||||
|
// Another event of the same type is already buffered — append payload and
|
||||||
|
// reset the timer so the window starts fresh from the latest arrival.
|
||||||
|
existing.payloads.push(event.payload);
|
||||||
|
clearTimeout(existing.timer);
|
||||||
|
existing.timer = setTimeout(() => flushEventType(event.type), DEBOUNCE_MS);
|
||||||
|
} else {
|
||||||
|
// First event of this type — start a new debounce window.
|
||||||
|
debounceBuffer.set(event.type, {
|
||||||
|
payloads: [event.payload],
|
||||||
|
timer: setTimeout(() => flushEventType(event.type), DEBOUNCE_MS),
|
||||||
|
firstTimestamp: event.timestamp,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
import type { WeekdayAvailability } from "@planarchy/shared";
|
||||||
|
import {
|
||||||
|
createWeekdayAvailabilityFromFte,
|
||||||
|
normalizeDispoRoleToken,
|
||||||
|
} from "@planarchy/shared";
|
||||||
|
import type { TxClient, MergedStagedResource } from "./commit-dispo-batch-types.js";
|
||||||
|
import { deriveRoleTokens } from "./shared.js";
|
||||||
|
|
||||||
|
function asNullableString(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFallbackRoleName(value: string): string {
|
||||||
|
return value.replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFiniteNumber(value: unknown): value is number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asObject(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeScalar<T>(
|
||||||
|
current: T | null,
|
||||||
|
incoming: T | null | undefined,
|
||||||
|
): T | null {
|
||||||
|
return incoming ?? current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inferRoleNameFromResource(resource: MergedStagedResource): string | null {
|
||||||
|
const explicitRoleName = Array.from(resource.roleTokens)
|
||||||
|
.map((token) => normalizeDispoRoleToken(token))
|
||||||
|
.find((roleName): roleName is string => Boolean(roleName));
|
||||||
|
if (explicitRoleName) {
|
||||||
|
return explicitRoleName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const derivedRoleName = deriveRoleTokens(
|
||||||
|
resource.chapter,
|
||||||
|
asNullableString(resource.rawPayload.department),
|
||||||
|
asNullableString(resource.rawPayload.mainSkillset),
|
||||||
|
asNullableString(resource.rawPayload.sapOrgUnitLevelSix),
|
||||||
|
asNullableString(resource.rawPayload.sapOrgUnitLevelSeven),
|
||||||
|
asNullableString(resource.rawPayload.sapEmployeeName),
|
||||||
|
)
|
||||||
|
.map((token) => normalizeDispoRoleToken(token))
|
||||||
|
.find((roleName): roleName is string => Boolean(roleName));
|
||||||
|
if (derivedRoleName) {
|
||||||
|
return derivedRoleName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resource.chapter === "Art Direction") {
|
||||||
|
return "Art Director";
|
||||||
|
}
|
||||||
|
if (resource.chapter === "Project Management") {
|
||||||
|
return "Project Manager";
|
||||||
|
}
|
||||||
|
|
||||||
|
const fallbackRoleLabel =
|
||||||
|
asNullableString(resource.rawPayload.department) ??
|
||||||
|
asNullableString(resource.rawPayload.mainSkillset) ??
|
||||||
|
asNullableString(resource.chapter) ??
|
||||||
|
asNullableString(resource.rawPayload.sapOrgUnitLevelSeven) ??
|
||||||
|
asNullableString(resource.rawPayload.sapOrgUnitLevelSix);
|
||||||
|
|
||||||
|
return fallbackRoleLabel ? normalizeFallbackRoleName(fallbackRoleLabel) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeStagedResources(
|
||||||
|
rows: Awaited<ReturnType<TxClient["stagedResource"]["findMany"]>>,
|
||||||
|
): Map<string, MergedStagedResource> {
|
||||||
|
const sourcePriority = new Map([
|
||||||
|
["CHARGEABILITY", 1],
|
||||||
|
["ROSTER", 2],
|
||||||
|
]);
|
||||||
|
const ordered = [...rows].sort(
|
||||||
|
(left, right) =>
|
||||||
|
(sourcePriority.get(left.sourceKind) ?? 0) - (sourcePriority.get(right.sourceKind) ?? 0),
|
||||||
|
);
|
||||||
|
const merged = new Map<string, MergedStagedResource>();
|
||||||
|
|
||||||
|
for (const row of ordered) {
|
||||||
|
const existing = merged.get(row.canonicalExternalId);
|
||||||
|
const rawPayload = asObject(row.rawPayload);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
merged.set(row.canonicalExternalId, {
|
||||||
|
availability: row.availability ?? null,
|
||||||
|
canonicalExternalId: row.canonicalExternalId,
|
||||||
|
chapter: row.chapter ?? null,
|
||||||
|
chargeabilityTarget: row.chargeabilityTarget ?? null,
|
||||||
|
clientUnitName: row.clientUnitName ?? null,
|
||||||
|
countryCode: row.countryCode ?? null,
|
||||||
|
displayName: row.displayName ?? null,
|
||||||
|
email: row.email ?? null,
|
||||||
|
fte: row.fte ?? null,
|
||||||
|
lcrCents: row.lcrCents ?? null,
|
||||||
|
managementLevelGroupName: row.managementLevelGroupName ?? null,
|
||||||
|
managementLevelName: row.managementLevelName ?? null,
|
||||||
|
metroCityName: row.metroCityName ?? null,
|
||||||
|
rawPayload,
|
||||||
|
resourceType: row.resourceType ?? null,
|
||||||
|
roleTokens: new Set(row.roleTokens),
|
||||||
|
sourceKinds: [row.sourceKind],
|
||||||
|
ucrCents: row.ucrCents ?? null,
|
||||||
|
vacationDaysPerYear: isFiniteNumber(rawPayload.vacationDaysPerYear)
|
||||||
|
? rawPayload.vacationDaysPerYear
|
||||||
|
: null,
|
||||||
|
warnings: [...row.warnings],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.availability = mergeScalar(existing.availability, row.availability);
|
||||||
|
existing.chapter = mergeScalar(existing.chapter, row.chapter);
|
||||||
|
existing.chargeabilityTarget = mergeScalar(existing.chargeabilityTarget, row.chargeabilityTarget);
|
||||||
|
existing.clientUnitName = mergeScalar(existing.clientUnitName, row.clientUnitName);
|
||||||
|
existing.countryCode = mergeScalar(existing.countryCode, row.countryCode);
|
||||||
|
existing.displayName = mergeScalar(existing.displayName, row.displayName);
|
||||||
|
existing.email = mergeScalar(existing.email, row.email);
|
||||||
|
existing.fte = mergeScalar(existing.fte, row.fte);
|
||||||
|
existing.lcrCents = mergeScalar(existing.lcrCents, row.lcrCents);
|
||||||
|
existing.managementLevelGroupName = mergeScalar(
|
||||||
|
existing.managementLevelGroupName,
|
||||||
|
row.managementLevelGroupName,
|
||||||
|
);
|
||||||
|
existing.managementLevelName = mergeScalar(existing.managementLevelName, row.managementLevelName);
|
||||||
|
existing.metroCityName = mergeScalar(existing.metroCityName, row.metroCityName);
|
||||||
|
existing.resourceType = mergeScalar(existing.resourceType, row.resourceType);
|
||||||
|
existing.ucrCents = mergeScalar(existing.ucrCents, row.ucrCents);
|
||||||
|
if (existing.availability === null && row.availability !== null) {
|
||||||
|
existing.availability = row.availability;
|
||||||
|
}
|
||||||
|
for (const roleToken of row.roleTokens) {
|
||||||
|
existing.roleTokens.add(roleToken);
|
||||||
|
}
|
||||||
|
existing.sourceKinds.push(row.sourceKind);
|
||||||
|
existing.warnings.push(...row.warnings);
|
||||||
|
existing.rawPayload = {
|
||||||
|
...existing.rawPayload,
|
||||||
|
...rawPayload,
|
||||||
|
};
|
||||||
|
if (isFiniteNumber(rawPayload.vacationDaysPerYear)) {
|
||||||
|
existing.vacationDaysPerYear = rawPayload.vacationDaysPerYear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWeekdayAvailability(
|
||||||
|
value: unknown,
|
||||||
|
fallbackFte: number | null,
|
||||||
|
): WeekdayAvailability {
|
||||||
|
const fallback = createWeekdayAvailabilityFromFte(fallbackFte ?? 1);
|
||||||
|
const source = asObject(value);
|
||||||
|
|
||||||
|
return {
|
||||||
|
monday: isFiniteNumber(source.monday) ? source.monday : fallback.monday,
|
||||||
|
tuesday: isFiniteNumber(source.tuesday) ? source.tuesday : fallback.tuesday,
|
||||||
|
wednesday: isFiniteNumber(source.wednesday) ? source.wednesday : fallback.wednesday,
|
||||||
|
thursday: isFiniteNumber(source.thursday) ? source.thursday : fallback.thursday,
|
||||||
|
friday: isFiniteNumber(source.friday) ? source.friday : fallback.friday,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReferenceDataMaps {
|
||||||
|
clientIdByCode: Map<string, string>;
|
||||||
|
clientIdByName: Map<string, string>;
|
||||||
|
countryIdByCode: Map<string, string>;
|
||||||
|
managementLevelGroupByName: Map<string, { id: string; name: string; targetPercentage: number }>;
|
||||||
|
managementLevelIdByName: Map<string, string>;
|
||||||
|
metroCityIdByName: Map<string, string>;
|
||||||
|
orgUnitIdByLevelAndName: Map<string, string>;
|
||||||
|
roleIdByName: Map<string, string>;
|
||||||
|
utilizationCategoryIdByCode: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReferenceDataMaps(data: {
|
||||||
|
clients: { id: string; code: string | null; name: string }[];
|
||||||
|
countries: { id: string; code: string }[];
|
||||||
|
managementLevelGroups: { id: string; name: string; targetPercentage: number }[];
|
||||||
|
managementLevels: { id: string; name: string }[];
|
||||||
|
metroCities: { id: string; name: string }[];
|
||||||
|
orgUnits: { id: string; level: number; name: string }[];
|
||||||
|
roles: { id: string; name: string }[];
|
||||||
|
utilizationCategories: { id: string; code: string }[];
|
||||||
|
}): ReferenceDataMaps {
|
||||||
|
return {
|
||||||
|
clientIdByCode: new Map(
|
||||||
|
data.clients.filter((client) => client.code).map((client) => [client.code!, client.id]),
|
||||||
|
),
|
||||||
|
clientIdByName: new Map(
|
||||||
|
data.clients.map((client) => [client.name.toLowerCase(), client.id]),
|
||||||
|
),
|
||||||
|
countryIdByCode: new Map(
|
||||||
|
data.countries.map((country) => [country.code, country.id]),
|
||||||
|
),
|
||||||
|
managementLevelGroupByName: new Map(
|
||||||
|
data.managementLevelGroups.map((group) => [group.name, group]),
|
||||||
|
),
|
||||||
|
managementLevelIdByName: new Map(
|
||||||
|
data.managementLevels.map((level) => [level.name, level.id]),
|
||||||
|
),
|
||||||
|
metroCityIdByName: new Map(
|
||||||
|
data.metroCities.map((metroCity) => [metroCity.name.toLowerCase(), metroCity.id]),
|
||||||
|
),
|
||||||
|
orgUnitIdByLevelAndName: new Map(
|
||||||
|
data.orgUnits.map((orgUnit) => [`${orgUnit.level}:${orgUnit.name.toLowerCase()}`, orgUnit.id]),
|
||||||
|
),
|
||||||
|
roleIdByName: new Map(
|
||||||
|
data.roles.map((role) => [role.name, role.id]),
|
||||||
|
),
|
||||||
|
utilizationCategoryIdByCode: new Map(
|
||||||
|
data.utilizationCategories.map((category) => [category.code, category.id]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Prisma, PrismaClient } from "@planarchy/db";
|
||||||
|
|
||||||
|
export type CommitDbClient = Pick<
|
||||||
|
PrismaClient,
|
||||||
|
| "$transaction"
|
||||||
|
| "assignment"
|
||||||
|
| "client"
|
||||||
|
| "country"
|
||||||
|
| "importBatch"
|
||||||
|
| "managementLevel"
|
||||||
|
| "managementLevelGroup"
|
||||||
|
| "metroCity"
|
||||||
|
| "orgUnit"
|
||||||
|
| "project"
|
||||||
|
| "resource"
|
||||||
|
| "resourceRole"
|
||||||
|
| "role"
|
||||||
|
| "stagedAssignment"
|
||||||
|
| "stagedAvailabilityRule"
|
||||||
|
| "stagedProject"
|
||||||
|
| "stagedResource"
|
||||||
|
| "stagedUnresolvedRecord"
|
||||||
|
| "stagedVacation"
|
||||||
|
| "utilizationCategory"
|
||||||
|
| "user"
|
||||||
|
| "vacation"
|
||||||
|
| "vacationEntitlement"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type TxClient = Parameters<Parameters<CommitDbClient["$transaction"]>[0]>[0];
|
||||||
|
|
||||||
|
export interface MergedStagedResource {
|
||||||
|
availability: Prisma.InputJsonValue | null;
|
||||||
|
canonicalExternalId: string;
|
||||||
|
chapter: string | null;
|
||||||
|
chargeabilityTarget: number | null;
|
||||||
|
clientUnitName: string | null;
|
||||||
|
countryCode: string | null;
|
||||||
|
displayName: string | null;
|
||||||
|
email: string | null;
|
||||||
|
fte: number | null;
|
||||||
|
lcrCents: number | null;
|
||||||
|
managementLevelGroupName: string | null;
|
||||||
|
managementLevelName: string | null;
|
||||||
|
metroCityName: string | null;
|
||||||
|
rawPayload: Record<string, unknown>;
|
||||||
|
resourceType: NonNullable<Awaited<ReturnType<TxClient["stagedResource"]["findMany"]>>[number]["resourceType"]> | null;
|
||||||
|
roleTokens: Set<string>;
|
||||||
|
sourceKinds: string[];
|
||||||
|
ucrCents: number | null;
|
||||||
|
vacationDaysPerYear: number | null;
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AggregatedAssignment {
|
||||||
|
endDate: Date;
|
||||||
|
hoursPerDay: number;
|
||||||
|
percentage: number;
|
||||||
|
projectId: string;
|
||||||
|
projectShortCode: string;
|
||||||
|
resourceId: string;
|
||||||
|
resourceKey: string;
|
||||||
|
roleId: string;
|
||||||
|
roleName: string;
|
||||||
|
sourceDates: string[];
|
||||||
|
startDate: Date;
|
||||||
|
utilizationCategoryCode: string | null;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
|||||||
|
import type { WeekdayAvailability } from "@planarchy/shared";
|
||||||
|
import {
|
||||||
|
DISPO_INTERNAL_PROJECT_BUCKETS,
|
||||||
|
normalizeDispoRoleToken,
|
||||||
|
} from "@planarchy/shared";
|
||||||
|
import type { TxClient, AggregatedAssignment } from "./commit-dispo-batch-types.js";
|
||||||
|
import { deriveTbdDispoProjectIdentity } from "./tbd-projects.js";
|
||||||
|
|
||||||
|
function asObject(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDate(date: Date): Date {
|
||||||
|
return new Date(`${date.toISOString().slice(0, 10)}T00:00:00.000Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDateKey(date: Date): string {
|
||||||
|
return normalizeDate(date).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(date: Date, days: number): Date {
|
||||||
|
const next = normalizeDate(date);
|
||||||
|
next.setUTCDate(next.getUTCDate() + days);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundToOneDecimal(value: number): number {
|
||||||
|
return Math.round(value * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAY_KEYS = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"] as const;
|
||||||
|
const WORKDAY_KEYS = ["monday", "tuesday", "wednesday", "thursday", "friday"] as const;
|
||||||
|
|
||||||
|
function resolveInternalProjectShortCode(utilizationCategoryCode: string | null): string | null {
|
||||||
|
return (
|
||||||
|
DISPO_INTERNAL_PROJECT_BUCKETS.find(
|
||||||
|
(bucket) => bucket.utilizationCategoryCode === utilizationCategoryCode,
|
||||||
|
)?.shortCode ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateAssignments(
|
||||||
|
rows: Awaited<ReturnType<TxClient["stagedAssignment"]["findMany"]>>,
|
||||||
|
resourceIdByKey: ReadonlyMap<string, string>,
|
||||||
|
projectIdByShortCode: ReadonlyMap<string, string>,
|
||||||
|
roleIdByName: ReadonlyMap<string, string>,
|
||||||
|
resourceRoleNameByKey: ReadonlyMap<string, string>,
|
||||||
|
importTbdProjects: boolean,
|
||||||
|
): AggregatedAssignment[] {
|
||||||
|
const resolvedRows = rows
|
||||||
|
.filter((row) => !row.isUnassigned && (!row.isTbd || importTbdProjects))
|
||||||
|
.map((row) => {
|
||||||
|
const projectShortCode = row.isInternal
|
||||||
|
? resolveInternalProjectShortCode(row.utilizationCategoryCode)
|
||||||
|
: row.isTbd
|
||||||
|
? deriveTbdDispoProjectIdentity(
|
||||||
|
String(asObject(row.rawPayload).rawToken ?? ""),
|
||||||
|
row.utilizationCategoryCode ?? null,
|
||||||
|
).shortCode
|
||||||
|
: (row.projectKey ?? null);
|
||||||
|
const roleName =
|
||||||
|
row.roleName ??
|
||||||
|
normalizeDispoRoleToken(row.roleToken) ??
|
||||||
|
resourceRoleNameByKey.get(row.resourceExternalId) ??
|
||||||
|
null;
|
||||||
|
const resourceId = resourceIdByKey.get(row.resourceExternalId);
|
||||||
|
const projectId = projectShortCode ? projectIdByShortCode.get(projectShortCode) : null;
|
||||||
|
const roleId = roleName ? roleIdByName.get(roleName) : null;
|
||||||
|
|
||||||
|
if (!resourceId) {
|
||||||
|
throw new Error(`Unable to resolve resource "${row.resourceExternalId}" during assignment commit`);
|
||||||
|
}
|
||||||
|
if (!projectShortCode || !projectId) {
|
||||||
|
throw new Error(
|
||||||
|
`Unable to resolve project for assignment resource "${row.resourceExternalId}" on ${getDateKey(row.assignmentDate ?? row.startDate ?? new Date())}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!roleName || !roleId) {
|
||||||
|
throw new Error(
|
||||||
|
`Unable to resolve role for assignment resource "${row.resourceExternalId}" on ${getDateKey(row.assignmentDate ?? row.startDate ?? new Date())}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (row.assignmentDate === null || row.hoursPerDay === null || row.percentage === null) {
|
||||||
|
throw new Error(`Assignment row "${row.id}" is missing normalized date or load information`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
assignmentDate: normalizeDate(row.assignmentDate),
|
||||||
|
hoursPerDay: row.hoursPerDay,
|
||||||
|
percentage: row.percentage,
|
||||||
|
projectId,
|
||||||
|
projectShortCode,
|
||||||
|
resourceId,
|
||||||
|
resourceKey: row.resourceExternalId,
|
||||||
|
roleId,
|
||||||
|
roleName,
|
||||||
|
utilizationCategoryCode: row.utilizationCategoryCode ?? null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((left, right) =>
|
||||||
|
left.resourceKey.localeCompare(right.resourceKey) ||
|
||||||
|
left.projectShortCode.localeCompare(right.projectShortCode) ||
|
||||||
|
left.roleName.localeCompare(right.roleName) ||
|
||||||
|
left.assignmentDate.getTime() - right.assignmentDate.getTime(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const aggregated: AggregatedAssignment[] = [];
|
||||||
|
|
||||||
|
for (const row of resolvedRows) {
|
||||||
|
const previous = aggregated.at(-1);
|
||||||
|
const canMerge = previous &&
|
||||||
|
previous.resourceId === row.resourceId &&
|
||||||
|
previous.projectId === row.projectId &&
|
||||||
|
previous.roleId === row.roleId &&
|
||||||
|
previous.hoursPerDay === row.hoursPerDay &&
|
||||||
|
previous.percentage === row.percentage &&
|
||||||
|
previous.endDate.getTime() === addDays(row.assignmentDate, -1).getTime();
|
||||||
|
|
||||||
|
if (canMerge) {
|
||||||
|
previous.endDate = row.assignmentDate;
|
||||||
|
previous.sourceDates.push(getDateKey(row.assignmentDate));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
aggregated.push({
|
||||||
|
endDate: row.assignmentDate,
|
||||||
|
hoursPerDay: row.hoursPerDay,
|
||||||
|
percentage: row.percentage,
|
||||||
|
projectId: row.projectId,
|
||||||
|
projectShortCode: row.projectShortCode,
|
||||||
|
resourceId: row.resourceId,
|
||||||
|
resourceKey: row.resourceKey,
|
||||||
|
roleId: row.roleId,
|
||||||
|
roleName: row.roleName,
|
||||||
|
sourceDates: [getDateKey(row.assignmentDate)],
|
||||||
|
startDate: row.assignmentDate,
|
||||||
|
utilizationCategoryCode: row.utilizationCategoryCode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return aggregated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveOverlayAvailability(
|
||||||
|
baseAvailability: WeekdayAvailability,
|
||||||
|
rules: Awaited<ReturnType<TxClient["stagedAvailabilityRule"]["findMany"]>>,
|
||||||
|
): WeekdayAvailability {
|
||||||
|
const next = { ...baseAvailability };
|
||||||
|
const weekdayVotes = new Map<string, Map<number, number>>();
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
const date = rule.effectiveStartDate ?? rule.effectiveEndDate;
|
||||||
|
if (!date) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const weekdayIndex = normalizeDate(date).getUTCDay();
|
||||||
|
if (weekdayIndex === 0 || weekdayIndex === 6) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const weekdayKey = WEEKDAY_KEYS[weekdayIndex] as (typeof WORKDAY_KEYS)[number] | undefined;
|
||||||
|
if (!weekdayKey || !WORKDAY_KEYS.includes(weekdayKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const availableHours = rule.availableHours ?? (
|
||||||
|
rule.percentage !== null && rule.percentage !== undefined
|
||||||
|
? roundToOneDecimal((rule.percentage / 100) * 8)
|
||||||
|
: null
|
||||||
|
);
|
||||||
|
if (availableHours === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoursMap = weekdayVotes.get(weekdayKey) ?? new Map<number, number>();
|
||||||
|
hoursMap.set(availableHours, (hoursMap.get(availableHours) ?? 0) + 1);
|
||||||
|
weekdayVotes.set(weekdayKey, hoursMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const weekdayKey of WORKDAY_KEYS) {
|
||||||
|
const hoursMap = weekdayVotes.get(weekdayKey);
|
||||||
|
if (!hoursMap || hoursMap.size === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstEntry = [...hoursMap.entries()].sort(
|
||||||
|
(left, right) => right[1] - left[1] || left[0] - right[0],
|
||||||
|
)[0];
|
||||||
|
if (!firstEntry) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [resolvedHours] = firstEntry;
|
||||||
|
next[weekdayKey] = Math.min(next[weekdayKey], resolvedHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { CommitDbClient } from "./commit-dispo-batch-types.js";
|
||||||
|
import { StagedRecordStatus } from "@planarchy/db";
|
||||||
|
|
||||||
|
function asObject(value: unknown): Record<string, unknown> {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedUnresolvedRecord(
|
||||||
|
record: Awaited<ReturnType<CommitDbClient["stagedUnresolvedRecord"]["findMany"]>>[number],
|
||||||
|
): boolean {
|
||||||
|
const message = record.message.toLowerCase();
|
||||||
|
const hint = (record.resolutionHint ?? "").toLowerCase();
|
||||||
|
const rawToken = String(asObject(record.normalizedData).rawToken ?? "").toLowerCase();
|
||||||
|
|
||||||
|
return record.recordType === "PROJECT" && (
|
||||||
|
message.includes("[tbd]") ||
|
||||||
|
hint.includes("[tbd]") ||
|
||||||
|
rawToken.includes("[tbd]")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchValidationResult {
|
||||||
|
batchId: string;
|
||||||
|
batchSummary: unknown;
|
||||||
|
blockingUnresolved: number;
|
||||||
|
skippedTbdUnresolved: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateDispoBatch(
|
||||||
|
db: CommitDbClient,
|
||||||
|
input: {
|
||||||
|
allowTbdUnresolved?: boolean;
|
||||||
|
importBatchId: string;
|
||||||
|
importTbdProjects?: boolean;
|
||||||
|
},
|
||||||
|
): Promise<BatchValidationResult> {
|
||||||
|
const batch = await db.importBatch.findUnique({
|
||||||
|
where: { id: input.importBatchId },
|
||||||
|
select: { id: true, status: true, summary: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!batch) {
|
||||||
|
throw new Error(`Import batch "${input.importBatchId}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!["STAGED", "REVIEW_READY", "APPROVED"].includes(batch.status)) {
|
||||||
|
throw new Error(`Import batch "${batch.id}" is not ready to commit from status "${batch.status}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unresolved = await db.stagedUnresolvedRecord.findMany({
|
||||||
|
where: {
|
||||||
|
importBatchId: batch.id,
|
||||||
|
status: StagedRecordStatus.UNRESOLVED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const blockingUnresolved = unresolved.filter(
|
||||||
|
(record) =>
|
||||||
|
!(
|
||||||
|
(input.allowTbdUnresolved ?? true) ||
|
||||||
|
(input.importTbdProjects && isAllowedUnresolvedRecord(record))
|
||||||
|
) || !isAllowedUnresolvedRecord(record),
|
||||||
|
);
|
||||||
|
const skippedTbdUnresolved = unresolved.length - blockingUnresolved.length;
|
||||||
|
|
||||||
|
if (blockingUnresolved.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Import batch "${batch.id}" still has ${blockingUnresolved.length} blocking unresolved staged record(s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
batchId: batch.id,
|
||||||
|
batchSummary: batch.summary,
|
||||||
|
blockingUnresolved: blockingUnresolved.length,
|
||||||
|
skippedTbdUnresolved,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -848,6 +848,7 @@ model EstimateVersion {
|
|||||||
notes String?
|
notes String?
|
||||||
lockedAt DateTime?
|
lockedAt DateTime?
|
||||||
projectSnapshot Json @db.JsonB @default("{}")
|
projectSnapshot Json @db.JsonB @default("{}")
|
||||||
|
commercialTerms Json? @db.JsonB
|
||||||
|
|
||||||
estimate Estimate @relation(fields: [estimateId], references: [id], onDelete: Cascade)
|
estimate Estimate @relation(fields: [estimateId], references: [id], onDelete: Cascade)
|
||||||
assumptions EstimateAssumption[]
|
assumptions EstimateAssumption[]
|
||||||
@@ -1170,6 +1171,7 @@ model DemandRequirement {
|
|||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
@@index([startDate, endDate])
|
@@index([startDate, endDate])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([projectId, status, startDate])
|
||||||
@@map("demand_requirements")
|
@@map("demand_requirements")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1202,6 +1204,8 @@ model Assignment {
|
|||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
@@index([startDate, endDate])
|
@@index([startDate, endDate])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([resourceId, status, startDate])
|
||||||
|
@@index([projectId, startDate, endDate])
|
||||||
@@map("assignments")
|
@@map("assignments")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1233,6 +1237,7 @@ model Vacation {
|
|||||||
@@index([resourceId])
|
@@index([resourceId])
|
||||||
@@index([startDate, endDate])
|
@@index([startDate, endDate])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
|
@@index([resourceId, status, startDate, endDate])
|
||||||
@@map("vacations")
|
@@map("vacations")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1297,6 +1302,12 @@ model SystemSettings {
|
|||||||
smtpPassword String?
|
smtpPassword String?
|
||||||
smtpFrom String?
|
smtpFrom String?
|
||||||
smtpTls Boolean? @default(true)
|
smtpTls Boolean? @default(true)
|
||||||
|
// Global viewer-side anonymization
|
||||||
|
anonymizationEnabled Boolean? @default(false)
|
||||||
|
anonymizationDomain String? @default("superhartmut.de")
|
||||||
|
anonymizationSeed String?
|
||||||
|
anonymizationMode String? @default("global")
|
||||||
|
anonymizationAliases Json? @db.JsonB
|
||||||
// Vacation defaults
|
// Vacation defaults
|
||||||
vacationDefaultDays Int? @default(28) // default annual entitlement
|
vacationDefaultDays Int? @default(28) // default annual entitlement
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|||||||
Reference in New Issue
Block a user