feat: project cover art with AI generation, branding rename, RBAC fix, computation graph

- 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>
This commit is contained in:
2026-03-18 11:31:56 +01:00
parent 21af720f90
commit 093e13b88f
86 changed files with 5623 additions and 744 deletions
@@ -2,6 +2,7 @@
import { useState } from "react";
import { clsx } from "clsx";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatCents } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -63,11 +64,11 @@ export function ApplyExperienceMultipliers({ estimateId, canEdit, onApplied }: A
return (
<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">Apply experience multipliers</h3>
<h3 className="mb-3 text-base font-semibold text-gray-900">Apply experience multipliers <InfoTooltip content="Experience multipliers adjust hours, cost rates, and bill rates based on rules like seniority level or chapter. A multiplier >1 increases effort, <1 decreases it." /></h3>
<div className="flex flex-wrap items-end gap-4">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Multiplier set</span>
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Multiplier set <InfoTooltip content="Select which set of rules to apply. Each set contains rules that match by chapter, role, or level." /></span>
<select
value={selectedSetId}
onChange={(e) => {
@@ -8,6 +8,7 @@ import {
validatePaymentMilestones,
} from "@planarchy/engine";
import { clsx } from "clsx";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatMoney } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -99,7 +100,7 @@ export function CommercialTermsEditor({
<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">
Adjusted Cost
Adjusted Cost <InfoTooltip content="Base cost + contingency. Adjusted cost = base cost x (1 + contingency %)." />
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(summary.adjustedCostCents, baseCurrency)}
@@ -112,7 +113,7 @@ export function CommercialTermsEditor({
</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">
Adjusted Price
Adjusted Price <InfoTooltip content="Base price minus discount. Adjusted price = base price x (1 - discount %)." />
</p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(summary.adjustedPriceCents, baseCurrency)}
@@ -125,7 +126,7 @@ export function CommercialTermsEditor({
</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">
Adjusted Margin
Adjusted Margin <InfoTooltip content="Adjusted margin = adjusted price - adjusted cost. Margin % = margin / adjusted price x 100." />
</p>
<p
className={clsx(
@@ -143,7 +144,7 @@ export function CommercialTermsEditor({
</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">
Pricing Model
Pricing Model <InfoTooltip content="Fixed Price: agreed total. Time & Materials: billed per actual hour. Hybrid: mix of both." />
</p>
<p className="mt-2 text-lg font-semibold text-gray-900">
{PRICING_MODELS.find((m) => m.value === terms.pricingModel)?.label ??
@@ -179,7 +180,7 @@ export function CommercialTermsEditor({
{/* Pricing Model */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Pricing Model
Pricing Model <InfoTooltip content="How the project will be billed to the client." />
</label>
<select
value={terms.pricingModel}
@@ -200,7 +201,7 @@ export function CommercialTermsEditor({
{/* Contingency % */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Contingency %
Contingency % <InfoTooltip content="Risk buffer added to the base cost. Adjusted cost = base cost x (1 + contingency %)." />
</label>
<input
type="number"
@@ -219,7 +220,7 @@ export function CommercialTermsEditor({
{/* Discount % */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Discount %
Discount % <InfoTooltip content="Client discount applied to the base price. Adjusted price = base price x (1 - discount %)." />
</label>
<input
type="number"
@@ -238,7 +239,7 @@ export function CommercialTermsEditor({
{/* Payment Terms */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Payment Terms (days)
Payment Terms (days) <InfoTooltip content="Number of days after invoice date within which payment is due." />
</label>
<input
type="number"
@@ -256,7 +257,7 @@ export function CommercialTermsEditor({
{/* Warranty */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Warranty (months)
Warranty (months) <InfoTooltip content="Post-delivery warranty period during which defects are covered at no extra cost." />
</label>
<input
type="number"
@@ -275,7 +276,7 @@ export function CommercialTermsEditor({
{/* Notes */}
<div className="mt-4">
<label className="block text-xs font-medium text-gray-500 mb-1">
Notes
Notes <InfoTooltip content="Free-text notes about the commercial terms, e.g. special conditions or negotiation context." />
</label>
<textarea
value={terms.notes ?? ""}
@@ -294,7 +295,7 @@ export function CommercialTermsEditor({
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-base font-semibold text-gray-900">
Payment Milestones
Payment Milestones <InfoTooltip content="Define when payments are due as a percentage of the adjusted price. Milestones should sum to 100%." />
</h3>
{canEdit && (
<button
@@ -8,6 +8,7 @@ import { parseScopeImport } from "~/lib/scopeImportParser.js";
import { clsx } from "clsx";
import { formatMoney } from "~/lib/format.js";
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { ProjectCombobox } from "~/components/ui/ProjectCombobox.js";
import { ResourceCombobox } from "~/components/ui/ResourceCombobox.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -440,7 +441,7 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimate Wizard</p>
<h2 className="mt-2 text-2xl font-semibold text-gray-900">Create a connected estimate</h2>
<p className="mt-1 text-sm text-gray-500">
Rates, resource snapshots, and project linkage are pulled from existing Planarchy data.
Rates, resource snapshots, and project linkage are pulled from existing plANARCHY data.
</p>
</div>
<button
@@ -485,19 +486,19 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-5">
<div className="grid gap-5 md:grid-cols-2">
<div>
<label className={LABEL_CLS}>Estimate Name</label>
<label className={LABEL_CLS}>Estimate Name <InfoTooltip content="A descriptive name for this estimate, e.g. project name + scope qualifier." /></label>
<input value={name} onChange={(event) => setName(event.target.value)} className={INPUT_CLS} placeholder="CGI Breakdown Q2 2026" />
</div>
<div>
<label className={LABEL_CLS}>Linked Project</label>
<label className={LABEL_CLS}>Linked Project <InfoTooltip content="Link to an existing plANARCHY project. This enables automatic date-based phasing and planning handoff." /></label>
<ProjectCombobox value={projectId} onChange={setProjectId} placeholder="Link to project" />
</div>
<div>
<label className={LABEL_CLS}>Opportunity ID</label>
<label className={LABEL_CLS}>Opportunity ID <InfoTooltip content="Optional external reference from your CRM or sales system to track this opportunity." /></label>
<input value={opportunityId} onChange={(event) => setOpportunityId(event.target.value)} className={INPUT_CLS} placeholder="Optional CRM or sales reference" />
</div>
<div>
<label className={LABEL_CLS}>Estimate Status</label>
<label className={LABEL_CLS}>Estimate Status <InfoTooltip content="DRAFT: work in progress. IN_REVIEW: submitted for approval. APPROVED: locked and ready for handoff. ARCHIVED: no longer active." /></label>
<select value={status} onChange={(event) => setStatus(event.target.value as EstimateStatus)} className={SELECT_CLS}>
{Object.values(EstimateStatus).map((value) => (
<option key={value} value={value}>
@@ -507,17 +508,17 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
</select>
</div>
<div>
<label className={LABEL_CLS}>Base Currency</label>
<label className={LABEL_CLS}>Base Currency <InfoTooltip content="ISO 4217 currency code (e.g. EUR, USD) used for all monetary values in this estimate." /></label>
<input value={baseCurrency} onChange={(event) => setBaseCurrency(event.target.value.toUpperCase())} className={INPUT_CLS} maxLength={3} />
</div>
<div>
<label className={LABEL_CLS}>Version Label</label>
<label className={LABEL_CLS}>Version Label <InfoTooltip content="A label for the initial version snapshot. Use labels like 'Initial', 'Client revision 2', etc." /></label>
<input value={versionLabel} onChange={(event) => setVersionLabel(event.target.value)} className={INPUT_CLS} placeholder="Initial" />
</div>
</div>
<div>
<label className={LABEL_CLS}>Version Notes</label>
<label className={LABEL_CLS}>Version Notes <InfoTooltip content="Free-text notes for this version. Document assumptions, exclusions, or client comments." /></label>
<textarea
value={versionNotes}
onChange={(event) => setVersionNotes(event.target.value)}
@@ -551,7 +552,7 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Commercial and delivery assumptions</h3>
<h3 className="text-lg font-semibold text-gray-900">Commercial and delivery assumptions <InfoTooltip content="Preconditions that affect the estimate validity. If an assumption changes, the estimate may need revision." /></h3>
<p className="text-sm text-gray-500">These rows replace free-form spreadsheet notes with structured data.</p>
</div>
<button type="button" onClick={() => setAssumptions((current) => [...current, makeAssumption()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
@@ -579,7 +580,7 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Scope breakdown</h3>
<h3 className="text-lg font-semibold text-gray-900">Scope breakdown <InfoTooltip content="Deliverables and work packages that define what is included in this estimate." /></h3>
<p className="text-sm text-gray-500">Create structured work packages that can later evolve into versioned estimate scope.</p>
</div>
<div className="flex gap-2">
@@ -621,7 +622,7 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-gray-900">Staffing and rate lines</h3>
<h3 className="text-lg font-semibold text-gray-900">Staffing and rate lines <InfoTooltip content="Each line represents a staffing need. Line cost = hours x cost rate. Line price = hours x sell rate." /></h3>
<p className="text-sm text-gray-500">Selecting a resource pre-fills cost rate, sell rate, chapter, and role from live data.</p>
</div>
<button type="button" onClick={() => setDemandLines((current) => [...current, makeDemand()])} className="rounded-xl border border-gray-200 px-3 py-2 text-sm text-gray-600 transition hover:border-gray-300 hover:text-gray-900">
@@ -639,11 +640,11 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div key={line.id} className="rounded-3xl border border-gray-100 p-4">
<div className="grid gap-4 lg:grid-cols-2">
<div>
<label className={LABEL_CLS}>Resource</label>
<label className={LABEL_CLS}>Resource <InfoTooltip content="Link to a live plANARCHY resource. Auto-fills rates, chapter, and role." /></label>
<ResourceCombobox value={line.resourceId} onChange={(resourceId) => applyResource(resourceId, line.id)} placeholder="Search resource" />
</div>
<div>
<label className={LABEL_CLS}>Role</label>
<label className={LABEL_CLS}>Role <InfoTooltip content="The production role for this demand line (e.g. Compositor, Animator)." /></label>
<select value={line.roleId ?? ""} onChange={(event) => updateDemandLine(line.id, { roleId: event.target.value || null })} className={SELECT_CLS}>
<option value="">Unassigned</option>
{roles.map((role) => (
@@ -654,27 +655,27 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
</select>
</div>
<div>
<label className={LABEL_CLS}>Line Name</label>
<label className={LABEL_CLS}>Line Name <InfoTooltip content="Descriptive name for this staffing line, e.g. 'Compositing Lead' or 'PM overhead'." /></label>
<input value={line.name} onChange={(event) => updateDemandLine(line.id, { name: event.target.value })} className={INPUT_CLS} placeholder="Compositing, lighting, PM, ..." />
</div>
<div>
<label className={LABEL_CLS}>Chapter</label>
<label className={LABEL_CLS}>Chapter <InfoTooltip content="Department or cost center. Auto-filled when a resource is linked." /></label>
<input value={line.chapter} onChange={(event) => updateDemandLine(line.id, { chapter: event.target.value })} className={INPUT_CLS} placeholder="Auto-filled from resource when linked" />
</div>
<div>
<label className={LABEL_CLS}>Hours</label>
<label className={LABEL_CLS}>Hours <InfoTooltip content="Total estimated effort in hours. Used to calculate line cost and price." /></label>
<input value={line.hours} onChange={(event) => updateDemandLine(line.id, { hours: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
</div>
<div>
<label className={LABEL_CLS}>Currency</label>
<label className={LABEL_CLS}>Currency <InfoTooltip content="ISO 4217 currency code for this line's rates." /></label>
<input value={line.currency} onChange={(event) => updateDemandLine(line.id, { currency: event.target.value.toUpperCase() })} className={INPUT_CLS} maxLength={3} />
</div>
<div>
<label className={LABEL_CLS}>Cost Rate / h</label>
<label className={LABEL_CLS}>Cost Rate / h <InfoTooltip content="Internal hourly cost rate in EUR. Line cost = hours x cost rate." /></label>
<input value={line.costRate} onChange={(event) => updateDemandLine(line.id, { costRate: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
</div>
<div>
<label className={LABEL_CLS}>Sell Rate / h</label>
<label className={LABEL_CLS}>Sell Rate / h <InfoTooltip content="Client-facing hourly rate. Line price = hours x sell rate." /></label>
<input value={line.billRate} onChange={(event) => updateDemandLine(line.id, { billRate: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
</div>
</div>
@@ -711,19 +712,19 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Total Hours <InfoTooltip content="Sum of all demand line hours across the estimate." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{summary.totalHours.toFixed(1)}</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Total Cost <InfoTooltip content="Sum of (hours x cost rate) for each demand line. Stored in cents, displayed in EUR." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(summary.totalCostCents, baseCurrency)}</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Total Price <InfoTooltip content="Sum of (hours x sell rate) for each demand line. This is the client-facing revenue." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">{formatMoney(summary.totalPriceCents, baseCurrency)}</p>
</div>
<div className="rounded-3xl border border-gray-100 bg-gray-50 px-5 py-4">
<p className="text-xs uppercase tracking-wide text-gray-400">Margin</p>
<p className="text-xs uppercase tracking-wide text-gray-400">Margin <InfoTooltip content="Margin = Total Price - Total Cost. Margin % = Margin / Total Price x 100." /></p>
<p className="mt-2 text-2xl font-semibold text-gray-900">
{formatMoney(marginCents, baseCurrency)} ({marginPercent}%)
</p>
@@ -17,6 +17,7 @@ import { StaffingTab } from "~/components/estimates/tabs/StaffingTab.js";
import { FinancialsTab } from "~/components/estimates/tabs/FinancialsTab.js";
import { VersionsTab } from "~/components/estimates/tabs/VersionsTab.js";
import { ExportsTab } from "~/components/estimates/tabs/ExportsTab.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { usePermissions } from "~/hooks/usePermissions.js";
import { formatDateLong } from "~/lib/format.js";
import { trpc } from "~/lib/trpc/client.js";
@@ -192,7 +193,7 @@ export function EstimateWorkspaceClient({ estimateId }: { estimateId: string })
<div className="rounded-[28px] border border-gray-200 bg-gradient-to-br from-white via-white to-brand-50 p-6 shadow-sm">
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimate Workspace</p>
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-600">Estimate Workspace <InfoTooltip content="Central workspace for inspecting and editing an estimate's scope, staffing, financials, and version history." /></p>
<h1 className="mt-2 text-3xl font-semibold text-gray-900">
{estimate?.name ?? "Loading estimate"}
</h1>
@@ -10,6 +10,7 @@ import {
type ScopeItemDiff,
} from "@planarchy/engine";
import type { EstimateVersionView } from "~/components/estimates/EstimateWorkspace.types.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatMoney } from "~/lib/format.js";
function formatDelta(value: number, formatter: (v: number) => string) {
@@ -134,10 +135,10 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
<div className="space-y-6">
{/* Version selectors */}
<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">Compare versions</h3>
<h3 className="mb-4 text-base font-semibold text-gray-900">Compare versions <InfoTooltip content="Select two version snapshots to see what changed in demand lines, scope, assumptions, and resource rates between them." /></h3>
<div className="flex flex-wrap items-end gap-4">
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Base (A)</span>
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Base (A) <InfoTooltip content="The older or reference version to compare from." /></span>
<select
value={aId}
onChange={(e) => setAId(e.target.value)}
@@ -154,7 +155,7 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
<span className="pb-2 text-sm text-gray-400">vs</span>
<label className="block">
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Compare (B)</span>
<span className="mb-1 block text-xs font-medium text-gray-500 uppercase tracking-wider">Compare (B) <InfoTooltip content="The newer or target version to compare against." /></span>
<select
value={bId}
onChange={(e) => setBId(e.target.value)}
@@ -1,5 +1,7 @@
"use client";
import { InfoTooltip } from "~/components/ui/InfoTooltip.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";
@@ -37,29 +39,29 @@ export function AssumptionEditor({ assumptions, onChange }: AssumptionEditorProp
<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>
<span className={LABEL_CLS}>Category <InfoTooltip content="Groups assumptions by topic, e.g. 'commercial', 'delivery', 'technical'." /></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>
<span className={LABEL_CLS}>Key <InfoTooltip content="Machine-readable identifier, auto-generated from label if left empty." /></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>
<span className={LABEL_CLS}>Label <InfoTooltip content="Human-readable name for this assumption." /></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>
<span className={LABEL_CLS}>Type <InfoTooltip content="Data type of the value, e.g. 'string', 'number', 'boolean'." /></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>
<span className={LABEL_CLS}>Value <InfoTooltip content="The concrete value or condition for this assumption." /></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>
<span className={LABEL_CLS}>Notes <InfoTooltip content="Additional context or rationale for this assumption." /></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>
@@ -10,6 +10,7 @@ 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 =
@@ -335,7 +336,7 @@ export function DemandLineEditor({
<div className="mb-4 grid gap-4 md:grid-cols-2">
<label>
<span className={LABEL_CLS}>Linked resource</span>
<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 ?? ""}
@@ -352,34 +353,34 @@ export function DemandLineEditor({
<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.
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>
<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</span>
<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</span>
<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</span>
<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</span>
<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</span>
<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}
@@ -413,7 +414,7 @@ export function DemandLineEditor({
</div>
</label>
<label>
<span className={LABEL_CLS}>Bill rate</span>
<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}
@@ -447,11 +448,11 @@ export function DemandLineEditor({
</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="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</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>
@@ -1,5 +1,6 @@
"use client";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { isSpreadsheetFile } from "~/lib/excel.js";
import { parseScopeImport } from "~/lib/scopeImportParser.js";
@@ -81,25 +82,25 @@ export function ScopeItemEditor({
<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>
<span className={LABEL_CLS}>Sequence <InfoTooltip content="Ordering number for this scope item within the estimate." /></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>
<span className={LABEL_CLS}>Scope type <InfoTooltip content="Category of deliverable, e.g. SHOT, SEQUENCE, ASSET, or a custom 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>
<span className={LABEL_CLS}>Package code <InfoTooltip content="Optional reference code for tracking this scope item in external systems." /></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>
<span className={LABEL_CLS}>Name <InfoTooltip content="Short descriptive name for this deliverable or work package." /></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>
<span className={LABEL_CLS}>Description <InfoTooltip content="Detailed description of what this scope item includes." /></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>
@@ -1,5 +1,6 @@
"use client";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import type {
EstimateVersionView,
EstimateWorkspaceView,
@@ -25,22 +26,22 @@ export function AssumptionsTab({ estimate }: { estimate: EstimateWorkspaceView }
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>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Commercial and delivery assumptions <InfoTooltip content="Preconditions that affect the estimate validity. If any assumption changes, the estimate may need revision." /></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="text-xs uppercase tracking-wide text-gray-400">Category <InfoTooltip content="Groups assumptions by topic, e.g. 'commercial', 'delivery', 'technical'." /></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="text-xs uppercase tracking-wide text-gray-400">Label <InfoTooltip content="Human-readable description of the assumption. The key below is the machine-readable identifier." /></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="text-xs uppercase tracking-wide text-gray-400">Value <InfoTooltip content="The concrete value or condition for this assumption." /></p>
<p className="mt-1 text-sm text-gray-800">{String(assumption.value)}</p>
</div>
</div>
@@ -9,6 +9,7 @@ import type {
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
const EXPORT_FORMATS: EstimateExportFormat[] = [
@@ -100,7 +101,7 @@ export function ExportsTab({
<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>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Export delivery <InfoTooltip content="Generate downloadable files from the current estimate version. Each format includes demand lines, scope, and financial summaries." /></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>
@@ -125,7 +126,7 @@ export function ExportsTab({
<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>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-600">Generated exports <InfoTooltip content="Previously generated export artifacts. XLSX/CSV contain tabular data; JSON is machine-readable; SAP/MMP are ERP integration formats." /></h2>
</div>
{exports.length === 0 ? (
<div className="px-6 py-8">
@@ -6,6 +6,7 @@ import type {
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatMoney } from "~/lib/format.js";
function EmptyState({ children }: { children: React.ReactNode }) {
@@ -77,24 +78,24 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
{/* 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="text-xs uppercase tracking-wide text-gray-400">Total Cost <InfoTooltip content="Sum of (hours x cost rate) for all demand lines. Avg shows weighted average cost per hour." /></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="text-xs uppercase tracking-wide text-gray-400">Total Price <InfoTooltip content="Sum of (hours x sell rate) for all demand lines. This is the total client-facing revenue." /></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="text-xs uppercase tracking-wide text-gray-400">Margin <InfoTooltip content="Margin = Total Price - Total Cost. Margin % = Margin / Total Price x 100. Green = positive, red = negative." /></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="text-xs uppercase tracking-wide text-gray-400">Total Hours <InfoTooltip content="Sum of all demand line hours. Each demand line contributes its hours to this total." /></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>
@@ -102,7 +103,7 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
{/* 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>
<h3 className="mb-4 text-base font-semibold text-gray-900">Cost to price bridge <InfoTooltip content="Visual waterfall: internal cost + margin = client price. Bar heights are proportional." /></h3>
<div className="flex items-end gap-1 h-32">
{(() => {
const maxVal = Math.max(totals.costCents, totals.priceCents, 1);
@@ -137,7 +138,7 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
{/* 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>
<h3 className="mb-3 text-base font-semibold text-gray-900">Breakdown by chapter <InfoTooltip content="Financial aggregation by department/chapter. Chapter margin % = (price - cost) / price x 100." /></h3>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
@@ -192,7 +193,7 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
{/* 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>
<h3 className="mb-3 text-base font-semibold text-gray-900">Monthly financial phasing <InfoTooltip content="Monthly cost and price derived from each line's hourly spread. Cost = monthly hours x line cost rate. Price = monthly hours x line sell rate." /></h3>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
@@ -7,6 +7,7 @@ import type {
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
const STATUS_STYLES: Record<EstimateStatus, string> = {
@@ -56,15 +57,15 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
<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="text-xs uppercase tracking-wide text-gray-400">Opportunity <InfoTooltip content="External CRM or sales reference linking this estimate to a sales 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="text-xs uppercase tracking-wide text-gray-400">Base currency <InfoTooltip content="The primary currency for all monetary calculations in this estimate." /></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="text-xs uppercase tracking-wide text-gray-400">Latest version <InfoTooltip content="The most recent version snapshot. Each version captures a full copy of scope, demand, and financials." /></p>
<p className="mt-1 text-sm text-gray-800">
{latestVersion ? `v${latestVersion.versionNumber}${latestVersion.label ? ` - ${latestVersion.label}` : ""}` : "No version"}
</p>
@@ -86,7 +87,7 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
<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>
<p className="text-sm font-semibold text-gray-900">Scope items <InfoTooltip content="Deliverables or work packages included in this estimate version." /></p>
<span className="text-xs text-gray-400">{latestVersion?.scopeItems.length ?? 0}</span>
</div>
<div className="mt-4 space-y-2">
@@ -104,7 +105,7 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
<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>
<p className="text-sm font-semibold text-gray-900">Demand lines <InfoTooltip content="Staffing demand rows with hours, cost rate, and sell rate per role or resource." /></p>
<span className="text-xs text-gray-400">{latestVersion?.demandLines.length ?? 0}</span>
</div>
<div className="mt-4 space-y-2">
@@ -124,7 +125,7 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
<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>
<p className="text-sm font-semibold text-gray-900">Summary metrics <InfoTooltip content="Key financial indicators derived from the latest version's demand lines." /></p>
<div className="mt-4 space-y-3">
{latestMetrics.length === 0 ? (
<p className="text-sm text-gray-400">No derived metrics available yet.</p>
@@ -140,7 +141,7 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
</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>
<p className="text-sm font-semibold text-gray-900">Version context <InfoTooltip content="Metadata about the latest version, including its workflow status and linked records." /></p>
<div className="mt-4 space-y-3">
{latestVersion ? (
<>
@@ -1,5 +1,6 @@
"use client";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import type {
EstimateVersionView,
EstimateWorkspaceView,
@@ -24,6 +25,10 @@ export function ScopeTab({ estimate }: { estimate: EstimateWorkspaceView }) {
return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>Scope items define the deliverables and work packages in this estimate.</span>
<InfoTooltip content="Each scope item represents a distinct deliverable (e.g. a shot, sequence, or asset). Scope items organize the estimate but do not directly affect cost calculations." />
</div>
{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">
@@ -13,6 +13,7 @@ import type {
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatMoney } from "~/lib/format.js";
function EmptyState({ children }: { children: React.ReactNode }) {
@@ -109,7 +110,7 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
<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="text-xs uppercase tracking-wide text-gray-400">Cost rate <InfoTooltip content="Internal hourly cost rate. Can be synced from the live resource or manually overridden." /></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">
@@ -118,7 +119,7 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
)}
</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="text-xs uppercase tracking-wide text-gray-400">Sell rate <InfoTooltip content="Client-facing hourly rate. Can be synced from the live resource or manually overridden." /></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">
@@ -127,11 +128,11 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
)}
</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="text-xs uppercase tracking-wide text-gray-400">Cost total <InfoTooltip content="Line cost total = hours x cost rate. Stored in cents." /></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="text-xs uppercase tracking-wide text-gray-400">Price total <InfoTooltip content="Line price total = hours x sell rate. This is the client-facing revenue for this line." /></p>
<p className="mt-1 text-sm font-medium text-gray-900">{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}</p>
</div>
</div>
@@ -165,7 +166,7 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
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>
<p className="mb-3 text-sm font-semibold text-gray-900">Aggregated monthly phasing <InfoTooltip content="Sum of hours across all demand lines per month, based on the project date range." /></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">
@@ -8,6 +8,7 @@ import type {
EstimateVersionView,
EstimateWorkspaceView,
} from "~/components/estimates/EstimateWorkspace.types.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { formatDateLong, formatMoney } from "~/lib/format.js";
const VERSION_STYLES: Record<EstimateVersionStatus, string> = {
@@ -74,6 +75,10 @@ export function VersionsTab({
return (
<div className="space-y-3">
<div className="flex items-center gap-2 text-sm text-gray-500">
<span>Versions are immutable snapshots of the estimate for comparison and audit.</span>
<InfoTooltip content="Each version captures a full copy of scope, assumptions, demand lines, and metrics. WORKING versions can be edited; SUBMITTED and APPROVED versions are locked." />
</div>
{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">