093e13b88f
- Add DALL-E cover art generation for projects (Azure OpenAI + standard OpenAI)
- CoverArtSection component with generate/upload/remove/focus-point controls
- Client-side image compression (10MB input → WebP/JPEG, max 1920px)
- DALL-E settings in admin panel (deployment, endpoint, API key)
- MCP assistant tools for cover art (generate_project_cover, remove_project_cover)
- Rename "Planarchy" → "plANARCHY" across all UI-facing text (13 files)
- Fix hardcoded canEdit={true} on project detail page — now checks user role
- Computation graph visualization (2D/3D) for calculation rules
- OG image and OpenGraph metadata
Co-Authored-By: claude-flow <ruv@ruv.net>
576 lines
24 KiB
TypeScript
576 lines
24 KiB
TypeScript
"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 { InfoTooltip } from "~/components/ui/InfoTooltip.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 <InfoTooltip content="Link to a plANARCHY resource. Live-linked rates refresh automatically; manual overrides are persisted." /></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 <InfoTooltip content="Descriptive label for this demand line, e.g. role name or resource 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 <InfoTooltip content="Classification of the demand, typically LABOR. Can also be EXPENSE or SUBCONTRACTOR." /></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 <InfoTooltip content="Department or cost center. Auto-filled when a resource is linked." /></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 <InfoTooltip content="Estimated effort in hours. Cost total = hours x cost rate. Price total = hours x sell rate." /></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 <InfoTooltip content="ISO 4217 currency code for this line's rates (e.g. EUR, USD)." /></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 <InfoTooltip content="Internal hourly cost rate. 'Live' syncs from the resource; 'Manual' allows a custom override. Line cost = hours x 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 <InfoTooltip content="Client-facing hourly sell rate. 'Live' syncs from the resource; 'Manual' allows a custom override. Line price = hours x 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 <InfoTooltip content="hours x cost rate, stored in cents." /></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 <InfoTooltip content="hours x sell rate, stored in cents." /></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>
|
|
);
|
|
}
|