- Resource
+ Resource
applyResource(resourceId, line.id)} placeholder="Search resource" />
- Role
+ Role
updateDemandLine(line.id, { roleId: event.target.value || null })} className={SELECT_CLS}>
Unassigned
{roles.map((role) => (
@@ -654,27 +655,27 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
- Line Name
+ Line Name
updateDemandLine(line.id, { name: event.target.value })} className={INPUT_CLS} placeholder="Compositing, lighting, PM, ..." />
- Chapter
+ Chapter
updateDemandLine(line.id, { chapter: event.target.value })} className={INPUT_CLS} placeholder="Auto-filled from resource when linked" />
- Hours
+ Hours
updateDemandLine(line.id, { hours: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
- Currency
+ Currency
updateDemandLine(line.id, { currency: event.target.value.toUpperCase() })} className={INPUT_CLS} maxLength={3} />
- Cost Rate / h
+ Cost Rate / h
updateDemandLine(line.id, { costRate: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
- Sell Rate / h
+ Sell Rate / h
updateDemandLine(line.id, { billRate: event.target.value })} className={INPUT_CLS} inputMode="decimal" />
@@ -711,19 +712,19 @@ export function EstimateWizard({ onClose }: { onClose: () => void }) {
-
Total Hours
+
Total Hours
{summary.totalHours.toFixed(1)}
-
Total Cost
+
Total Cost
{formatMoney(summary.totalCostCents, baseCurrency)}
-
Total Price
+
Total Price
{formatMoney(summary.totalPriceCents, baseCurrency)}
-
Margin
+
Margin
{formatMoney(marginCents, baseCurrency)} ({marginPercent}%)
diff --git a/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx b/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx
index e85fac2..adbebe8 100644
--- a/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx
+++ b/apps/web/src/components/estimates/EstimateWorkspaceClient.tsx
@@ -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 })
-
Estimate Workspace
+
Estimate Workspace
{estimate?.name ?? "Loading estimate"}
diff --git a/apps/web/src/components/estimates/VersionCompare.tsx b/apps/web/src/components/estimates/VersionCompare.tsx
index 4f5e749..30ddbd6 100644
--- a/apps/web/src/components/estimates/VersionCompare.tsx
+++ b/apps/web/src/components/estimates/VersionCompare.tsx
@@ -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[] }
{/* Version selectors */}
-
Compare versions
+
Compare versions
- Base (A)
+ Base (A)
setAId(e.target.value)}
@@ -154,7 +155,7 @@ export function VersionCompare({ versions }: { versions: EstimateVersionView[] }
vs
- Compare (B)
+ Compare (B)
setBId(e.target.value)}
diff --git a/apps/web/src/components/estimates/editors/AssumptionEditor.tsx b/apps/web/src/components/estimates/editors/AssumptionEditor.tsx
index ec6f7ee..e992c83 100644
--- a/apps/web/src/components/estimates/editors/AssumptionEditor.tsx
+++ b/apps/web/src/components/estimates/editors/AssumptionEditor.tsx
@@ -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
- Category
+ Category
onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, category: event.target.value } : item))} />
- Key
+ Key
onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, key: event.target.value } : item))} />
- Label
+ Label
onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, label: event.target.value } : item))} />
- Type
+ Type
onChange((current) => current.map((item, itemIndex) => itemIndex === index ? { ...item, valueType: event.target.value } : item))} />
- Value
+ Value
- Notes
+ Notes
diff --git a/apps/web/src/components/estimates/editors/DemandLineEditor.tsx b/apps/web/src/components/estimates/editors/DemandLineEditor.tsx
index 3bb3350..eae26ef 100644
--- a/apps/web/src/components/estimates/editors/DemandLineEditor.tsx
+++ b/apps/web/src/components/estimates/editors/DemandLineEditor.tsx
@@ -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({
- Linked resource
+ Linked resource
Snapshot behavior
- 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.
- Name
+ Name
updateDemandLine(index, (entry) => ({ ...entry, name: event.target.value }))} />
- Line type
+ Line type
updateDemandLine(index, (entry) => ({ ...entry, lineType: event.target.value }))} />
- Chapter
+ Chapter
updateDemandLine(index, (entry) => ({ ...entry, chapter: event.target.value }))} />
- Hours
+ Hours
updateDemandLine(index, (entry) => ({ ...entry, hours: event.target.value }))} />
- Currency
+ Currency
updateDemandLine(index, (entry) => ({ ...entry, currency: event.target.value.toUpperCase() }))} />
- Cost rate
+ Cost rate
- Bill rate
+ Bill rate
-
Cost total
+
Cost total
{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}
-
Price total
+
Price total
{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}
diff --git a/apps/web/src/components/estimates/editors/ScopeItemEditor.tsx b/apps/web/src/components/estimates/editors/ScopeItemEditor.tsx
index 1f4cb65..2c260b4 100644
--- a/apps/web/src/components/estimates/editors/ScopeItemEditor.tsx
+++ b/apps/web/src/components/estimates/editors/ScopeItemEditor.tsx
@@ -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({
- Sequence
+ Sequence
onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, sequenceNo: event.target.value } : entry))} />
- Scope type
+ Scope type
onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, scopeType: event.target.value } : entry))} />
- Package code
+ Package code
onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, packageCode: event.target.value } : entry))} />
- Name
+ Name
onChange((current) => current.map((entry, entryIndex) => entryIndex === index ? { ...entry, name: event.target.value } : entry))} />
- Description
+ Description
diff --git a/apps/web/src/components/estimates/tabs/AssumptionsTab.tsx b/apps/web/src/components/estimates/tabs/AssumptionsTab.tsx
index 4d92d0b..7046f05 100644
--- a/apps/web/src/components/estimates/tabs/AssumptionsTab.tsx
+++ b/apps/web/src/components/estimates/tabs/AssumptionsTab.tsx
@@ -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 (
-
Commercial and delivery assumptions
+ Commercial and delivery assumptions
{assumptions.map((assumption) => (
-
Category
+
Category
{assumption.category}
-
Label
+
Label
{assumption.label}
{assumption.key}
-
Value
+
Value
{String(assumption.value)}
diff --git a/apps/web/src/components/estimates/tabs/ExportsTab.tsx b/apps/web/src/components/estimates/tabs/ExportsTab.tsx
index 8211e16..75e24a9 100644
--- a/apps/web/src/components/estimates/tabs/ExportsTab.tsx
+++ b/apps/web/src/components/estimates/tabs/ExportsTab.tsx
@@ -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({
-
Export delivery
+
Export delivery
Generate format-specific artifacts from the current version and download them directly from the stored serializer payload.
@@ -125,7 +126,7 @@ export function ExportsTab({
-
Generated exports
+ Generated exports
{exports.length === 0 ? (
diff --git a/apps/web/src/components/estimates/tabs/FinancialsTab.tsx b/apps/web/src/components/estimates/tabs/FinancialsTab.tsx
index 074362e..40bf582 100644
--- a/apps/web/src/components/estimates/tabs/FinancialsTab.tsx
+++ b/apps/web/src/components/estimates/tabs/FinancialsTab.tsx
@@ -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 */}
-
Total Cost
+
Total Cost
{formatMoney(totals.costCents, estimate.baseCurrency)}
Avg {formatMoney(Math.round(avgCostRate), estimate.baseCurrency)}/h
-
Total Price
+
Total Price
{formatMoney(totals.priceCents, estimate.baseCurrency)}
Avg {formatMoney(Math.round(avgBillRate), estimate.baseCurrency)}/h
-
Margin
+
Margin
= 0 ? "text-emerald-700" : "text-red-700")}>
{formatMoney(marginCents, estimate.baseCurrency)}
{marginPercent.toFixed(1)}% of price
-
Total Hours
+
Total Hours
{totals.hours.toFixed(1)} h
{demandLines.length} demand lines
@@ -102,7 +103,7 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
{/* Margin waterfall: Cost -> Margin -> Price */}
-
Cost to price bridge
+
Cost to price bridge
{(() => {
const maxVal = Math.max(totals.costCents, totals.priceCents, 1);
@@ -137,7 +138,7 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
{/* Chapter breakdown */}
-
Breakdown by chapter
+
Breakdown by chapter
@@ -192,7 +193,7 @@ export function FinancialsTab({ estimate, canEdit }: { estimate: EstimateWorkspa
{/* Monthly cost/price phasing */}
{sortedMonths.length > 0 && (
-
Monthly financial phasing
+
Monthly financial phasing
diff --git a/apps/web/src/components/estimates/tabs/OverviewTab.tsx b/apps/web/src/components/estimates/tabs/OverviewTab.tsx
index 729c3f5..a0314e7 100644
--- a/apps/web/src/components/estimates/tabs/OverviewTab.tsx
+++ b/apps/web/src/components/estimates/tabs/OverviewTab.tsx
@@ -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 = {
@@ -56,15 +57,15 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
-
Opportunity
+
Opportunity
{estimate.opportunityId ?? "Not set"}
-
Base currency
+
Base currency
{estimate.baseCurrency}
-
Latest version
+
Latest version
{latestVersion ? `v${latestVersion.versionNumber}${latestVersion.label ? ` - ${latestVersion.label}` : ""}` : "No version"}
@@ -86,7 +87,7 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
-
Scope items
+
Scope items
{latestVersion?.scopeItems.length ?? 0}
@@ -104,7 +105,7 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
-
Demand lines
+
Demand lines
{latestVersion?.demandLines.length ?? 0}
@@ -124,7 +125,7 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
-
Summary metrics
+
Summary metrics
{latestMetrics.length === 0 ? (
No derived metrics available yet.
@@ -140,7 +141,7 @@ export function OverviewTab({ estimate }: { estimate: EstimateWorkspaceView }) {
-
Version context
+
Version context
{latestVersion ? (
<>
diff --git a/apps/web/src/components/estimates/tabs/ScopeTab.tsx b/apps/web/src/components/estimates/tabs/ScopeTab.tsx
index 0f82696..ea41b9b 100644
--- a/apps/web/src/components/estimates/tabs/ScopeTab.tsx
+++ b/apps/web/src/components/estimates/tabs/ScopeTab.tsx
@@ -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 (
+
+ Scope items define the deliverables and work packages in this estimate.
+
+
{scopeItems.map((item) => (
diff --git a/apps/web/src/components/estimates/tabs/StaffingTab.tsx b/apps/web/src/components/estimates/tabs/StaffingTab.tsx
index 54be8f5..8d7943a 100644
--- a/apps/web/src/components/estimates/tabs/StaffingTab.tsx
+++ b/apps/web/src/components/estimates/tabs/StaffingTab.tsx
@@ -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
-
Cost rate
+
Cost rate
{formatMoney(line.costRateCents, line.currency)}
{linkedSnapshot && calculation.costRateMode === "manual" && (
@@ -118,7 +119,7 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
)}
-
Sell rate
+
Sell rate
{formatMoney(line.billRateCents, line.currency)}
{linkedSnapshot && calculation.billRateMode === "manual" && (
@@ -127,11 +128,11 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
)}
-
Cost total
+
Cost total
{formatMoney(effectiveValues.costTotalCents, effectiveValues.currency)}
-
Price total
+
Price total
{formatMoney(effectiveValues.priceTotalCents, effectiveValues.currency)}
@@ -165,7 +166,7 @@ export function StaffingTab({ estimate, canEdit }: { estimate: EstimateWorkspace
if (months.length === 0) return null;
return (
-
Aggregated monthly phasing
+
Aggregated monthly phasing
{months.map((month) => (
diff --git a/apps/web/src/components/estimates/tabs/VersionsTab.tsx b/apps/web/src/components/estimates/tabs/VersionsTab.tsx
index 46d6f92..81456d6 100644
--- a/apps/web/src/components/estimates/tabs/VersionsTab.tsx
+++ b/apps/web/src/components/estimates/tabs/VersionsTab.tsx
@@ -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
= {
@@ -74,6 +75,10 @@ export function VersionsTab({
return (
+
+ Versions are immutable snapshots of the estimate for comparison and audit.
+
+
{versions.map((version) => (
diff --git a/apps/web/src/components/projects/BudgetStatusBar.tsx b/apps/web/src/components/projects/BudgetStatusBar.tsx
index 35b1c7c..b614eef 100644
--- a/apps/web/src/components/projects/BudgetStatusBar.tsx
+++ b/apps/web/src/components/projects/BudgetStatusBar.tsx
@@ -2,6 +2,7 @@
import { clsx } from "clsx";
import { formatMoney } from "~/lib/format.js";
+import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
interface Warning {
level: string;
@@ -66,6 +67,10 @@ export function BudgetStatusBar({
return (
{/* Progress bar with stacked segments */}
+
+ Budget utilization
+
+
{/* Confirmed segment */}
-
Total Budget
+
Total Budget
{formatEur(budgetCents)}
-
Confirmed
+
Confirmed
{formatEur(confirmedCents)}
-
Proposed
+
Proposed
{formatEur(proposedCents)}
-
Remaining
+
Remaining
{formatEur(remainingCents)}
@@ -125,7 +126,7 @@ export function BudgetStatusCard({ projectId }: BudgetStatusCardProps) {
{/* Win-probability weighted amount */}
- Win-probability weighted cost:
+ Win-probability weighted cost:
{formatEur(winProbabilityWeightedCents)}
diff --git a/apps/web/src/components/projects/CoverArtSection.tsx b/apps/web/src/components/projects/CoverArtSection.tsx
new file mode 100644
index 0000000..9d01c77
--- /dev/null
+++ b/apps/web/src/components/projects/CoverArtSection.tsx
@@ -0,0 +1,384 @@
+"use client";
+
+import { useState, useRef } from "react";
+import { trpc } from "~/lib/trpc/client.js";
+
+interface CoverArtSectionProps {
+ projectId: string;
+ coverImageUrl?: string | null;
+ coverFocusY?: number;
+ projectColor?: string | null;
+ projectName: string;
+ canEdit: boolean;
+}
+
+export function CoverArtSection({ projectId, coverImageUrl, coverFocusY = 50, projectColor, projectName, canEdit }: CoverArtSectionProps) {
+ const [imageUrl, setImageUrl] = useState(coverImageUrl ?? null);
+ const [focusY, setFocusY] = useState(coverFocusY);
+ const [generating, setGenerating] = useState(false);
+ const [uploading, setUploading] = useState(false);
+ const [error, setError] = useState
(null);
+ const [customPrompt, setCustomPrompt] = useState("");
+ const [showPromptInput, setShowPromptInput] = useState(false);
+ const [showFocusSlider, setShowFocusSlider] = useState(false);
+ const fileInputRef = useRef(null);
+ const utils = trpc.useUtils();
+
+ const { data: dalleStatus } = trpc.project.isDalleConfigured.useQuery();
+ const generateMutation = trpc.project.generateCover.useMutation();
+ const uploadMutation = trpc.project.uploadCover.useMutation();
+ const removeMutation = trpc.project.removeCover.useMutation();
+ const focusMutation = trpc.project.updateCoverFocus.useMutation();
+
+ const handleGenerate = async () => {
+ setError(null);
+ setGenerating(true);
+ try {
+ const result = await generateMutation.mutateAsync({
+ projectId,
+ ...(customPrompt.trim() ? { prompt: customPrompt.trim() } : {}),
+ });
+ setImageUrl(result.coverImageUrl);
+ setShowPromptInput(false);
+ setCustomPrompt("");
+ void utils.project.getById.invalidate({ id: projectId });
+ void utils.project.listWithCosts.invalidate();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to generate cover");
+ } finally {
+ setGenerating(false);
+ }
+ };
+
+ /** Compress an image file to WebP/JPEG via canvas, targeting max 1920px and ~200-400KB output */
+ const compressImage = (file: File, maxDim = 1920, quality = 0.82): Promise =>
+ new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => {
+ let { width, height } = img;
+ if (width > maxDim || height > maxDim) {
+ const scale = maxDim / Math.max(width, height);
+ width = Math.round(width * scale);
+ height = Math.round(height * scale);
+ }
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const ctx2d = canvas.getContext("2d");
+ if (!ctx2d) { reject(new Error("Canvas not supported")); return; }
+ ctx2d.drawImage(img, 0, 0, width, height);
+ // Prefer WebP, fall back to JPEG
+ let dataUrl = canvas.toDataURL("image/webp", quality);
+ if (!dataUrl.startsWith("data:image/webp")) {
+ dataUrl = canvas.toDataURL("image/jpeg", quality);
+ }
+ resolve(dataUrl);
+ };
+ img.onerror = () => reject(new Error("Failed to load image"));
+ img.src = URL.createObjectURL(file);
+ });
+
+ const handleFileSelect = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ if (!file.type.startsWith("image/")) {
+ setError("Please select an image file (PNG, JPG, WebP, etc.)");
+ return;
+ }
+
+ if (file.size > 10 * 1024 * 1024) {
+ setError("Image too large. Maximum upload size is 10 MB.");
+ return;
+ }
+
+ setError(null);
+ setUploading(true);
+
+ try {
+ const dataUrl = await compressImage(file);
+
+ const result = await uploadMutation.mutateAsync({
+ projectId,
+ imageDataUrl: dataUrl,
+ });
+ setImageUrl(result.coverImageUrl);
+ void utils.project.getById.invalidate({ id: projectId });
+ void utils.project.listWithCosts.invalidate();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to upload image");
+ } finally {
+ setUploading(false);
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ }
+ };
+
+ const handleRemove = async () => {
+ setError(null);
+ try {
+ await removeMutation.mutateAsync({ projectId });
+ setImageUrl(null);
+ setShowFocusSlider(false);
+ void utils.project.getById.invalidate({ id: projectId });
+ void utils.project.listWithCosts.invalidate();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to remove cover");
+ }
+ };
+
+ const handleFocusSave = async () => {
+ try {
+ await focusMutation.mutateAsync({ projectId, coverFocusY: focusY });
+ setShowFocusSlider(false);
+ void utils.project.getById.invalidate({ id: projectId });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to save focus point");
+ }
+ };
+
+ const initials = projectName
+ .split(/\s+/)
+ .map((w) => w[0])
+ .filter(Boolean)
+ .slice(0, 2)
+ .join("")
+ .toUpperCase();
+
+ return (
+
+ {/* Cover image or placeholder */}
+ {imageUrl ? (
+
+
+ {/* Gradient overlay at bottom for readability */}
+
+
+ ) : (
+
+
+ {initials}
+
+
+ 1024 × 1024 px
+
+
+ )}
+
+ {/* Controls overlay */}
+ {canEdit && (
+
+ {/* Focus point adjuster — only when image exists */}
+ {imageUrl && (
+
setShowFocusSlider((v) => !v)}
+ className="inline-flex items-center gap-1.5 rounded-lg bg-white/90 px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm backdrop-blur transition hover:bg-white dark:bg-gray-800/90 dark:text-gray-200 dark:hover:bg-gray-800"
+ title="Adjust vertical focus point"
+ >
+
+
+
+ Focus
+
+ )}
+
+ {/* Generate with AI */}
+ {dalleStatus?.configured && (
+
{
+ if (showPromptInput) {
+ handleGenerate();
+ } else {
+ setShowPromptInput(true);
+ }
+ }}
+ disabled={generating}
+ className="inline-flex items-center gap-1.5 rounded-lg bg-white/90 px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm backdrop-blur transition hover:bg-white disabled:opacity-50 dark:bg-gray-800/90 dark:text-gray-200 dark:hover:bg-gray-800"
+ title="Generate cover art with AI"
+ >
+ {generating ? (
+ <>
+
+
+
+
+ Generating...
+ >
+ ) : (
+ <>
+
+
+
+ {showPromptInput ? "Generate" : "AI Cover"}
+ >
+ )}
+
+ )}
+
+ {/* Upload */}
+
fileInputRef.current?.click()}
+ disabled={uploading}
+ className="inline-flex items-center gap-1.5 rounded-lg bg-white/90 px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm backdrop-blur transition hover:bg-white disabled:opacity-50 dark:bg-gray-800/90 dark:text-gray-200 dark:hover:bg-gray-800"
+ >
+ {uploading ? (
+ <>
+
+
+
+
+ Uploading...
+ >
+ ) : (
+ <>
+
+
+
+ Upload
+ >
+ )}
+
+
+
+
+ {/* Remove */}
+ {imageUrl && (
+
+
+
+
+
+ )}
+
+ )}
+
+ {/* Focus point slider */}
+ {showFocusSlider && imageUrl && canEdit && (
+
+
+
+ Focus point
+
+ Top
+ setFocusY(Number(e.target.value))}
+ className="flex-1 accent-brand-600"
+ />
+ Bottom
+ {focusY}%
+
+ {focusMutation.isPending ? "..." : "Save"}
+
+ {
+ setFocusY(coverFocusY);
+ setShowFocusSlider(false);
+ }}
+ className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
+ >
+ Cancel
+
+
+
+ )}
+
+ {/* Custom prompt input (shown when AI Cover is clicked) */}
+ {showPromptInput && !showFocusSlider && canEdit && (
+
+
+ setCustomPrompt(e.target.value)}
+ placeholder="Optional: describe the style you want..."
+ className="flex-1 rounded-lg border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200"
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleGenerate();
+ if (e.key === "Escape") {
+ setShowPromptInput(false);
+ setCustomPrompt("");
+ }
+ }}
+ autoFocus
+ />
+
+ {generating ? "..." : "Generate"}
+
+ {
+ setShowPromptInput(false);
+ setCustomPrompt("");
+ }}
+ className="rounded-lg px-2 py-1.5 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400"
+ >
+ Cancel
+
+
+
+ )}
+
+ {/* Error message */}
+ {error && (
+
+ {error}
+ setError(null)}
+ className="ml-2 font-medium underline"
+ >
+ Dismiss
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/projects/ProjectAssignmentsTable.tsx b/apps/web/src/components/projects/ProjectAssignmentsTable.tsx
index 0a75934..e8540ba 100644
--- a/apps/web/src/components/projects/ProjectAssignmentsTable.tsx
+++ b/apps/web/src/components/projects/ProjectAssignmentsTable.tsx
@@ -73,7 +73,9 @@ export function ProjectAssignmentsTable({ assignments }: ProjectAssignmentsTable
- Resource
+
+ Resource
+
Role
diff --git a/apps/web/src/components/projects/ProjectDemandsTable.tsx b/apps/web/src/components/projects/ProjectDemandsTable.tsx
index c13e732..ef97448 100644
--- a/apps/web/src/components/projects/ProjectDemandsTable.tsx
+++ b/apps/web/src/components/projects/ProjectDemandsTable.tsx
@@ -69,10 +69,10 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
- Role
+ Role
- Period
+ Period
@@ -80,7 +80,9 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
- Hours/Day
+
+ Hours/Day
+
@@ -88,7 +90,7 @@ export function ProjectDemandsTable({ demands, project }: ProjectDemandsTablePro
- Status
+ Status
{canEdit && (
diff --git a/apps/web/src/components/projects/ProjectModal.tsx b/apps/web/src/components/projects/ProjectModal.tsx
index 4c84adc..90d63fc 100644
--- a/apps/web/src/components/projects/ProjectModal.tsx
+++ b/apps/web/src/components/projects/ProjectModal.tsx
@@ -6,6 +6,7 @@ import { OrderType, AllocationType, ProjectStatus } from "@planarchy/shared";
import type { Project } from "@planarchy/shared";
import { trpc } from "~/lib/trpc/client.js";
import { DateInput } from "~/components/ui/DateInput.js";
+import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
const ORDER_TYPE_OPTIONS = [
{ value: "BD", label: "BD" },
@@ -283,6 +284,7 @@ export function ProjectModal({ project, onClose }: ProjectModalProps) {
Chargecode *
+
Name *
+
Order Type
+
Allocation
+
Win Probability %
+
Utilization Category
+
Client
+
Start Date
+
End Date
+
Budget (€)
+
Status
+
Responsible Person
+
Timeline Color
+
{/* Blueprint picker */}
-
Project Blueprint (optional)
+
Project Blueprint (optional)
{/* Short code */}
-
Short Code *
+
Short Code *
-
Project Name *
+
Project Name *
-
Order Type *
+
Order Type *
onChange({ orderType: e.target.value })}
@@ -279,7 +280,7 @@ function Step1({ state, onChange }: Step1Props) {
{/* Allocation type */}
-
Allocation Type *
+
Allocation Type *
onChange({ allocationType: e.target.value })}
@@ -392,14 +393,14 @@ function Step2({ state, onChange }: Step2Props) {
- Start Date *
+ Start Date *
onChange({ startDate: v })}
/>
-
End Date *
+
End Date *
onChange({ endDate: v })}
@@ -409,7 +410,7 @@ function Step2({ state, onChange }: Step2Props) {
- Budget (EUR) *
+ Budget (EUR) *
-
Responsible Person
+
Responsible Person
onChange({ responsiblePerson: v })}
@@ -432,6 +433,7 @@ function Step2({ state, onChange }: Step2Props) {
Win Probability: {state.winProbability}%
+
-
Role *
+
Role *
{roles.length > 0 ? (
- h/day
+ h/day
- Count
+ Count
-
Budget (EUR)
+
Budget (EUR)
- Required skills
+ Required skills
updateReq(idx, { requiredSkills: skills })}
@@ -614,7 +616,7 @@ function Step3({ state, onChange }: Step3Props) {
/>
- Preferred skills (optional)
+ Preferred skills (optional)
updateReq(idx, { preferredSkills: skills })}
@@ -622,7 +624,7 @@ function Step3({ state, onChange }: Step3Props) {
/>
-
Chapter filter (optional)
+
Chapter filter (optional)
+
+ AI-powered resource suggestions based on skills, availability, and utilization.
+
+
{state.staffingReqs.map((req) => (
@@ -876,6 +882,10 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError }: Step5Pr
return (
{/* Project summary */}
+
@@ -941,8 +951,9 @@ function Step5({ state, onChange, onSubmit, isSubmitting, submitError }: Step5Pr
{/* Draft toggle */}
-
+
Save as
+
diff --git a/apps/web/src/components/reports/AllocationReport.tsx b/apps/web/src/components/reports/AllocationReport.tsx
index 3d24222..de74394 100644
--- a/apps/web/src/components/reports/AllocationReport.tsx
+++ b/apps/web/src/components/reports/AllocationReport.tsx
@@ -63,7 +63,7 @@ export function AllocationReport({ title, generatedAt, rows }: AllocationReportP
))}
- Planarchy · Confidential · {rows.length} allocations
+ plANARCHY · Confidential · {rows.length} allocations
);
diff --git a/apps/web/src/components/reports/ChargeabilityReportClient.tsx b/apps/web/src/components/reports/ChargeabilityReportClient.tsx
index ba9a5ac..663a4e6 100644
--- a/apps/web/src/components/reports/ChargeabilityReportClient.tsx
+++ b/apps/web/src/components/reports/ChargeabilityReportClient.tsx
@@ -2,6 +2,7 @@
import React, { useState, useMemo, useCallback } from "react";
import { trpc } from "~/lib/trpc/client.js";
+import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
// ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -333,13 +334,13 @@ export function ChargeabilityReportClient() {
Mgmt: {r.mgmtGroup ?? "—"} / {r.mgmtLevel ?? "—"}
- Chg
- BD
- MD&I
- M&O
- PD&R
- Abs
- Free
+ Chg
+ BD
+ MD&I
+ M&O
+ PD&R
+ Abs
+ Free
@@ -438,22 +439,22 @@ export function ChargeabilityReportClient() {
{data ? (
-
Resources
+
Resources
{filteredResources.length}
People in the current filter scope
-
Average Chargeability
+
Average Chargeability
{pct(averageChargeability)}
Weighted across visible resources
-
Average Target
+
Average Target
{pct(averageTarget)}
Planning target for the same population
-
Average Gap
+
Average Gap
= 0 ? "text-green-700 dark:text-green-400" : "text-red-700 dark:text-red-400"}`}>
{averageGap > 0 ? "+" : ""}{pct(averageGap)}
@@ -585,8 +586,8 @@ export function ChargeabilityReportClient() {
Resource
-
FTE
-
Target
+
FTE
+
Target
{data.monthKeys.map((key) => (
{formatMonth(key)}
diff --git a/apps/web/src/components/resources/ResourceDetail.tsx b/apps/web/src/components/resources/ResourceDetail.tsx
index 17a52dd..2a8ca48 100644
--- a/apps/web/src/components/resources/ResourceDetail.tsx
+++ b/apps/web/src/components/resources/ResourceDetail.tsx
@@ -10,6 +10,7 @@ import { SkillRadarChart } from "./SkillRadarChart.js";
import { AiSummaryCard } from "./AiSummaryCard.js";
import { SkillMatrixUpload } from "./SkillMatrixUpload.js";
import { usePermissions } from "~/hooks/usePermissions.js";
+import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
interface ResourceDetailProps {
resourceId: string;
@@ -46,10 +47,10 @@ const allocationStatusColor: Record = {
CANCELLED: "bg-red-100 text-red-500",
};
-function StatCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
+function StatCard({ label, value, sub, tooltip }: { label: string; value: string | number; sub?: string; tooltip?: string }) {
return (
-
{label}
+
{label}{tooltip && }
{value}
{sub &&
{sub}
}
@@ -276,22 +277,26 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
)}
{canViewCosts && (
)}
{canViewCosts && (
)}
{canViewScores && resourceWithMeta.valueScore != null && (
@@ -316,6 +323,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{resourceWithMeta.valueScoreBreakdown && (
@@ -391,7 +399,7 @@ export function ResourceDetail({ resourceId }: ResourceDetailProps) {
{/* Main Skills Badges */}
{mainSkills.length > 0 && (
-
Main Skills
+
Main Skills
{mainSkills.map((s) => (
0 && (
-
Roles
+
Roles
{resourceRoles.map((rr) => (
0 && (