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:
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { formatCents } from "~/lib/format.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
// ─── Local types ────────────────────────────────────────────────────────────
|
||||
@@ -86,11 +87,6 @@ const emptyLine: EditingLine = {
|
||||
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 {
|
||||
if (!d) return "-";
|
||||
return new Date(d).toLocaleDateString("de-DE");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||
import { useInvalidatePlanningViews } from "~/hooks/useInvalidatePlanningViews.js";
|
||||
import { AllocationStatus } from "@planarchy/shared";
|
||||
import type { AllocationWithDetails, RecurrencePattern } from "@planarchy/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
@@ -64,17 +65,7 @@ export function AllocationModal({ allocation, onClose, onSuccess }: AllocationMo
|
||||
{ staleTime: 60_000 },
|
||||
);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
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();
|
||||
};
|
||||
const invalidatePlanningViews = useInvalidatePlanningViews();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-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 { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||
import { getPlanningEntryMutationId } from "~/lib/planningEntryIds.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",
|
||||
};
|
||||
import { ALLOCATION_STATUS_BADGE as STATUS_BADGE } from "~/lib/status-styles.js";
|
||||
|
||||
const ALL_ALLOC_STATUSES = [
|
||||
{ value: "PROPOSED", label: "Proposed" },
|
||||
@@ -221,12 +214,11 @@ export function AllocationsClient() {
|
||||
const singleDeletePending = deleteDemandMutation.isPending || deleteAssignmentMutation.isPending;
|
||||
|
||||
return (
|
||||
<div className="p-6 pb-24">
|
||||
{/* Page header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="app-page space-y-5 pb-24">
|
||||
<div className="app-page-header gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Allocations</h1>
|
||||
<p className="text-gray-500 dark:text-gray-400 text-sm mt-1">
|
||||
<h1 className="app-page-title">Allocations</h1>
|
||||
<p className="app-page-subtitle mt-1">
|
||||
{isLoading
|
||||
? "Loading…"
|
||||
: `${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"
|
||||
target="_blank"
|
||||
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
|
||||
</a>
|
||||
<a
|
||||
href="/api/reports/allocations?format=xlsx"
|
||||
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
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<FilterBar>
|
||||
<ProjectCombobox
|
||||
value={filterProjectId || null}
|
||||
@@ -277,7 +268,7 @@ export function AllocationsClient() {
|
||||
<select
|
||||
value={filterStatus}
|
||||
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>
|
||||
{ALL_ALLOC_STATUSES.map((s) => (
|
||||
@@ -285,7 +276,7 @@ export function AllocationsClient() {
|
||||
))}
|
||||
</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
|
||||
type="checkbox"
|
||||
checked={hidePastProjects}
|
||||
@@ -295,7 +286,7 @@ export function AllocationsClient() {
|
||||
Hide past
|
||||
</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
|
||||
type="checkbox"
|
||||
checked={hideCompletedProjects}
|
||||
@@ -305,7 +296,7 @@ export function AllocationsClient() {
|
||||
Hide completed
|
||||
</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
|
||||
type="checkbox"
|
||||
checked={hideDraftProjects}
|
||||
@@ -329,10 +320,9 @@ export function AllocationsClient() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<div className="app-data-table">
|
||||
<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>
|
||||
<th className="px-4 py-3 w-10">
|
||||
<input
|
||||
@@ -370,16 +360,16 @@ export function AllocationsClient() {
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{isLoading && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!isLoading && sorted.length === 0 && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
@@ -387,7 +377,7 @@ export function AllocationsClient() {
|
||||
sorted.map((alloc) => {
|
||||
const isSelected = selection.selectedIds.has(alloc.id);
|
||||
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">
|
||||
<input
|
||||
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>;
|
||||
case "project":
|
||||
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 ? (
|
||||
<><span className="font-mono text-xs">{alloc.project.shortCode}</span> {alloc.project.name}</>
|
||||
) : "—"}
|
||||
</td>
|
||||
);
|
||||
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":
|
||||
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":
|
||||
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":
|
||||
@@ -425,17 +415,17 @@ export function AllocationsClient() {
|
||||
</td>
|
||||
);
|
||||
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">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<button type="button" onClick={() => openEdit(alloc)} className="text-xs text-blue-600 hover:text-blue-800 font-medium hover:underline">Edit</button>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete({ single: alloc })}
|
||||
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
|
||||
</button>
|
||||
@@ -449,8 +439,8 @@ export function AllocationsClient() {
|
||||
</div>
|
||||
|
||||
{!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="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="app-surface mt-6 overflow-hidden border-amber-200 dark:border-amber-900/70">
|
||||
<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>
|
||||
<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">
|
||||
@@ -465,7 +455,7 @@ export function AllocationsClient() {
|
||||
{filteredDemands.map((demand) => (
|
||||
<div
|
||||
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="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}</>
|
||||
) : "Unknown project"}
|
||||
</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
|
||||
</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-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">
|
||||
@@ -488,7 +478,7 @@ export function AllocationsClient() {
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
@@ -496,7 +486,7 @@ export function AllocationsClient() {
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete({ single: demand as AllocationWithDetails })}
|
||||
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
|
||||
</button>
|
||||
@@ -511,8 +501,8 @@ export function AllocationsClient() {
|
||||
{/* Batch Status Picker */}
|
||||
{batchStatusPicker && (
|
||||
<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()}>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">Set status for {selection.count} allocations</h3>
|
||||
<div className="min-w-[220px] rounded-2xl bg-white p-5 shadow-2xl dark:bg-gray-900" onClick={(e) => e.stopPropagation()}>
|
||||
<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">
|
||||
{ALL_ALLOC_STATUSES.map((s) => (
|
||||
<button
|
||||
@@ -522,7 +512,7 @@ export function AllocationsClient() {
|
||||
setConfirmBatchStatus({ ids: selectedMutationIds, status: s.value });
|
||||
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]}`}>
|
||||
{s.label}
|
||||
|
||||
@@ -5,14 +5,7 @@ import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { ProjectStatus } from "@planarchy/shared/types";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.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",
|
||||
};
|
||||
import { PROJECT_STATUS_BADGE as STATUS_COLORS } from "~/lib/status-styles.js";
|
||||
|
||||
export function ProjectTableWidget({ config, onConfigChange }: WidgetProps) {
|
||||
const status = (config.status as ProjectStatus) || undefined;
|
||||
|
||||
@@ -2,12 +2,9 @@
|
||||
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import type { WidgetProps } from "~/components/dashboard/widget-registry.js";
|
||||
import { formatMoney } from "~/lib/format.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 }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { clsx } from "clsx";
|
||||
import { formatCents } from "~/lib/format.js";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
interface ApplyExperienceMultipliersProps {
|
||||
@@ -10,13 +11,6 @@ interface ApplyExperienceMultipliersProps {
|
||||
onApplied?: () => void;
|
||||
}
|
||||
|
||||
function formatCents(cents: number): string {
|
||||
return (cents / 100).toLocaleString("de-DE", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: ApplyExperienceMultipliersProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const { data: sets, isLoading } = trpc.experienceMultiplier.list.useQuery();
|
||||
@@ -29,7 +23,7 @@ export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: A
|
||||
{ enabled: showPreview && Boolean(selectedSetId) },
|
||||
);
|
||||
|
||||
const applyMutation = trpc.experienceMultiplier.apply.useMutation({
|
||||
const applyMutation = trpc.experienceMultiplier.applyRules.useMutation({
|
||||
onSuccess: (result) => {
|
||||
utils.estimate.getById.invalidate();
|
||||
setShowPreview(false);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { computeEvenSpread } from "@planarchy/engine";
|
||||
import { isSpreadsheetFile } from "~/lib/excel.js";
|
||||
import { parseScopeImport } from "~/lib/scopeImportParser.js";
|
||||
import { clsx } from "clsx";
|
||||
import { formatMoney } from "~/lib/format.js";
|
||||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||||
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
|
||||
import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js";
|
||||
@@ -120,14 +121,6 @@ function toHours(value: string) {
|
||||
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) {
|
||||
return value
|
||||
.trim()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
CommercialTerms,
|
||||
EstimateDemandLineMetadata,
|
||||
EstimateExportArtifactPayload,
|
||||
EstimateExportFormat,
|
||||
@@ -98,6 +99,7 @@ export interface EstimateVersionView {
|
||||
resourceSnapshots: EstimateResourceSnapshotView[];
|
||||
metrics: EstimateMetricView[];
|
||||
exports: EstimateExportView[];
|
||||
commercialTerms?: CommercialTerms | null;
|
||||
}
|
||||
|
||||
export interface EstimateWorkspaceView {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,62 +19,22 @@ import type {
|
||||
EstimateWorkspaceView,
|
||||
WorkspaceTab,
|
||||
} from "~/components/estimates/EstimateWorkspace.types.js";
|
||||
import { isSpreadsheetFile } from "~/lib/excel.js";
|
||||
import { parseScopeImport } from "~/lib/scopeImportParser.js";
|
||||
import {
|
||||
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";
|
||||
|
||||
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 {
|
||||
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";
|
||||
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) {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
@@ -130,44 +82,6 @@ function stringifyAssumptionValue(value: unknown) {
|
||||
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() {
|
||||
return (
|
||||
<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();
|
||||
}
|
||||
|
||||
const editableVersion = workingVersion;
|
||||
|
||||
function getLineResourceSnapshot(line: EditableDemandLine) {
|
||||
if (!line.resourceId) {
|
||||
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
|
||||
? new Date(estimate.project.startDate)
|
||||
: null;
|
||||
@@ -520,41 +321,14 @@ export function EstimateWorkspaceDraftEditor({
|
||||
}).spread;
|
||||
}
|
||||
|
||||
const spreadMonths = useMemo(
|
||||
() =>
|
||||
hasProjectDates
|
||||
? getEstimateMonthRange(projectStartDate, projectEndDate)
|
||||
: [],
|
||||
[hasProjectDates, projectStartDate, projectEndDate],
|
||||
);
|
||||
const spreadMonths =
|
||||
hasProjectDates
|
||||
? getEstimateMonthRange(projectStartDate, projectEndDate)
|
||||
: [];
|
||||
|
||||
const aggregatedSpread = useMemo(() => {
|
||||
if (!hasProjectDates) return {};
|
||||
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 },
|
||||
}));
|
||||
}
|
||||
const aggregatedSpread = hasProjectDates
|
||||
? summarizeMonthlySpread(demandLines.map(computeLineSpread))
|
||||
: {};
|
||||
|
||||
async function handleSave() {
|
||||
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 (
|
||||
<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">
|
||||
@@ -1196,9 +547,34 @@ export function EstimateWorkspaceDraftEditor({
|
||||
)}
|
||||
|
||||
{tab === "overview" && renderOverviewEditor()}
|
||||
{tab === "assumptions" && renderAssumptionsEditor()}
|
||||
{tab === "scope" && renderScopeEditor()}
|
||||
{tab === "staffing" && renderStaffingEditor()}
|
||||
{tab === "assumptions" && (
|
||||
<AssumptionEditor
|
||||
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()}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,14 +10,7 @@ import {
|
||||
type ScopeItemDiff,
|
||||
} from "@planarchy/engine";
|
||||
import type { EstimateVersionView } from "~/components/estimates/EstimateWorkspace.types.js";
|
||||
|
||||
function formatMoney(cents: number, currency = "EUR") {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(cents / 100);
|
||||
}
|
||||
import { formatMoney } from "~/lib/format.js";
|
||||
|
||||
function formatDelta(value: number, formatter: (v: number) => string) {
|
||||
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 { BalanceCard } from "./BalanceCard.js";
|
||||
import { VacationCalendar } from "./VacationCalendar.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",
|
||||
};
|
||||
import { VACATION_STATUS_BADGE as STATUS_BADGE, VACATION_TYPE_LABELS as TYPE_LABELS } from "~/lib/status-styles.js";
|
||||
|
||||
export function MyVacationsClient() {
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
@@ -10,20 +10,7 @@ import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
|
||||
import { useTableSort } from "~/hooks/useTableSort.js";
|
||||
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.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",
|
||||
};
|
||||
import { VACATION_STATUS_BADGE as STATUS_BADGE, VACATION_TYPE_LABELS as TYPE_LABELS } from "~/lib/status-styles.js";
|
||||
|
||||
type VacationStatusFilter = VacationStatus | "ALL";
|
||||
type VacationTypeFilter = VacationType | "ALL";
|
||||
|
||||
Reference in New Issue
Block a user