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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user