Files
CapaKraken/apps/web/src/components/estimates/CommercialTermsEditor.tsx
T
Hartmut cd78f72f33 chore: full technical rename planarchy → capakraken
Complete rename of all technical identifiers across the codebase:

Package names (11 packages):
- @planarchy/* → @capakraken/* in all package.json, tsconfig, imports

Import statements: 277 files, 548 occurrences replaced

Database & Docker:
- PostgreSQL user/db: planarchy → capakraken
- Docker volumes: planarchy_pgdata → capakraken_pgdata
- Connection strings updated in docker-compose, .env, CI

CI/CD:
- GitHub Actions workflow: all filter commands updated
- Test database credentials updated

Infrastructure:
- Redis channel: planarchy:sse → capakraken:sse
- Logger service name: planarchy-api → capakraken-api
- Anonymization seed updated
- Start/stop/restart scripts updated

Test data:
- Seed emails: @planarchy.dev → @capakraken.dev
- E2E test credentials: all 11 spec files updated
- Email defaults: @planarchy.app → @capakraken.app
- localStorage keys: planarchy_* → capakraken_*

Documentation: 30+ .md files updated

Verification:
- pnpm install: workspace resolution works
- TypeScript: only pre-existing TS2589 (no new errors)
- Engine: 310/310 tests pass
- Staffing: 37/37 tests pass

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-27 13:18:09 +01:00

466 lines
18 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import type { CommercialTerms, PaymentMilestone, PricingModel } from "@capakraken/shared";
import {
computeCommercialTermsSummary,
computeMilestoneAmounts,
validatePaymentMilestones,
} from "@capakraken/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";
interface Props {
estimateId: string;
baseCostCents: number;
basePriceCents: number;
baseCurrency: string;
canEdit: boolean;
}
const PRICING_MODELS: Array<{ value: PricingModel; label: string }> = [
{ value: "fixed_price", label: "Fixed Price" },
{ value: "time_and_materials", label: "Time & Materials" },
{ value: "hybrid", label: "Hybrid" },
];
export function CommercialTermsEditor({
estimateId,
baseCostCents,
basePriceCents,
baseCurrency,
canEdit,
}: Props) {
const utils = trpc.useUtils();
const { data, isLoading } = trpc.estimate.getCommercialTerms.useQuery({
estimateId,
});
const updateMutation = trpc.estimate.updateCommercialTerms.useMutation({
onSuccess: async () => {
await utils.estimate.getCommercialTerms.invalidate({ estimateId });
},
});
const [terms, setTerms] = useState<CommercialTerms>({
pricingModel: "fixed_price",
contingencyPercent: 0,
discountPercent: 0,
paymentTermDays: 30,
paymentMilestones: [],
warrantyMonths: 0,
});
const [dirty, setDirty] = useState(false);
useEffect(() => {
if (data?.terms) {
setTerms(data.terms);
setDirty(false);
}
}, [data]);
function update(patch: Partial<CommercialTerms>) {
setTerms((prev) => ({ ...prev, ...patch }));
setDirty(true);
}
function save() {
updateMutation.mutate({
estimateId,
terms,
});
setDirty(false);
}
const summary = computeCommercialTermsSummary({
baseCostCents,
basePriceCents,
terms,
});
const milestoneWarnings = validatePaymentMilestones(terms.paymentMilestones);
const milestoneAmounts = computeMilestoneAmounts(
summary.adjustedPriceCents,
terms.paymentMilestones,
);
if (isLoading) {
return (
<div className="rounded-3xl border border-gray-200 bg-white p-5 shadow-sm">
<div className="h-6 w-48 shimmer-skeleton rounded" />
</div>
);
}
return (
<div className="space-y-6">
{/* Adjusted financials summary */}
<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 <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)}
</p>
{summary.contingencyCents > 0 && (
<p className="mt-1 text-xs text-amber-600">
+{formatMoney(summary.contingencyCents, baseCurrency)} contingency
</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">
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)}
</p>
{summary.discountCents > 0 && (
<p className="mt-1 text-xs text-red-600">
-{formatMoney(summary.discountCents, baseCurrency)} discount
</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">
Adjusted Margin <InfoTooltip content="Adjusted margin = adjusted price - adjusted cost. Margin % = margin / adjusted price x 100." />
</p>
<p
className={clsx(
"mt-2 text-2xl font-semibold",
summary.adjustedMarginCents >= 0
? "text-emerald-700"
: "text-red-700",
)}
>
{formatMoney(summary.adjustedMarginCents, baseCurrency)}
</p>
<p className="mt-1 text-xs text-gray-500">
{summary.adjustedMarginPercent.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">
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 ??
terms.pricingModel}
</p>
{terms.warrantyMonths > 0 && (
<p className="mt-1 text-xs text-gray-500">
{terms.warrantyMonths} mo warranty
</p>
)}
</div>
</div>
{/* Terms editor */}
<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">
Commercial Terms
</h3>
{canEdit && dirty && (
<button
type="button"
onClick={save}
disabled={updateMutation.isPending}
className="rounded-lg bg-brand-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
>
{updateMutation.isPending ? "Saving..." : "Save"}
</button>
)}
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{/* Pricing Model */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Pricing Model <InfoTooltip content="How the project will be billed to the client." />
</label>
<select
value={terms.pricingModel}
onChange={(e) =>
update({ pricingModel: e.target.value as PricingModel })
}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm disabled:bg-gray-50"
>
{PRICING_MODELS.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</div>
{/* Contingency % */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Contingency % <InfoTooltip content="Risk buffer added to the base cost. Adjusted cost = base cost x (1 + contingency %)." />
</label>
<input
type="number"
min={0}
max={100}
step={0.5}
value={terms.contingencyPercent}
onChange={(e) =>
update({ contingencyPercent: parseFloat(e.target.value) || 0 })
}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
</div>
{/* Discount % */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Discount % <InfoTooltip content="Client discount applied to the base price. Adjusted price = base price x (1 - discount %)." />
</label>
<input
type="number"
min={0}
max={100}
step={0.5}
value={terms.discountPercent}
onChange={(e) =>
update({ discountPercent: parseFloat(e.target.value) || 0 })
}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
</div>
{/* Payment Terms */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Payment Terms (days) <InfoTooltip content="Number of days after invoice date within which payment is due." />
</label>
<input
type="number"
min={0}
max={365}
value={terms.paymentTermDays}
onChange={(e) =>
update({ paymentTermDays: parseInt(e.target.value) || 0 })
}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
</div>
{/* Warranty */}
<div>
<label className="block text-xs font-medium text-gray-500 mb-1">
Warranty (months) <InfoTooltip content="Post-delivery warranty period during which defects are covered at no extra cost." />
</label>
<input
type="number"
min={0}
max={60}
value={terms.warrantyMonths}
onChange={(e) =>
update({ warrantyMonths: parseInt(e.target.value) || 0 })
}
disabled={!canEdit}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm tabular-nums disabled:bg-gray-50"
/>
</div>
</div>
{/* Notes */}
<div className="mt-4">
<label className="block text-xs font-medium text-gray-500 mb-1">
Notes <InfoTooltip content="Free-text notes about the commercial terms, e.g. special conditions or negotiation context." />
</label>
<textarea
value={terms.notes ?? ""}
onChange={(e) =>
update({ notes: e.target.value || null })
}
disabled={!canEdit}
rows={2}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm disabled:bg-gray-50"
placeholder="Additional commercial notes..."
/>
</div>
</div>
{/* Payment Milestones */}
<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 <InfoTooltip content="Define when payments are due as a percentage of the adjusted price. Milestones should sum to 100%." />
</h3>
{canEdit && (
<button
type="button"
onClick={() =>
update({
paymentMilestones: [
...terms.paymentMilestones,
{ label: "", percent: 0 },
],
})
}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm font-medium text-gray-700 hover:bg-gray-50"
>
+ Add milestone
</button>
)}
</div>
{milestoneWarnings.length > 0 && (
<div className="mb-3 rounded-lg border border-amber-200 bg-amber-50 p-3">
<ul className="space-y-1">
{milestoneWarnings.map((w, i) => (
<li key={i} className="text-xs text-amber-700">
{w}
</li>
))}
</ul>
</div>
)}
{terms.paymentMilestones.length === 0 ? (
<p className="text-sm text-gray-400">
No payment milestones defined.
</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 text-xs uppercase tracking-wider text-gray-500">
<th className="py-2 pr-3 font-medium">Milestone</th>
<th className="px-3 py-2 text-right font-medium w-24">%</th>
<th className="px-3 py-2 text-right font-medium">Amount</th>
<th className="px-3 py-2 font-medium w-36">Due Date</th>
{canEdit && (
<th className="pl-3 py-2 font-medium w-12" />
)}
</tr>
</thead>
<tbody>
{terms.paymentMilestones.map((ms, idx) => {
const amount = milestoneAmounts[idx];
return (
<tr key={idx} className="border-b border-gray-100">
<td className="py-2 pr-3">
{canEdit ? (
<input
type="text"
value={ms.label}
onChange={(e) => {
const updated = [...terms.paymentMilestones];
updated[idx] = { ...ms, label: e.target.value };
update({ paymentMilestones: updated });
}}
className="w-full rounded border border-gray-200 px-2 py-1 text-sm"
placeholder="e.g. Kickoff"
/>
) : (
<span className="text-gray-900">{ms.label}</span>
)}
</td>
<td className="px-3 py-2 text-right">
{canEdit ? (
<input
type="number"
min={0}
max={100}
step={1}
value={ms.percent}
onChange={(e) => {
const updated = [...terms.paymentMilestones];
updated[idx] = {
...ms,
percent: parseFloat(e.target.value) || 0,
};
update({ paymentMilestones: updated });
}}
className="w-20 rounded border border-gray-200 px-2 py-1 text-right text-sm tabular-nums"
/>
) : (
<span className="tabular-nums text-gray-700">
{ms.percent}%
</span>
)}
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-700">
{amount
? formatMoney(amount.amountCents, baseCurrency)
: "—"}
</td>
<td className="px-3 py-2">
{canEdit ? (
<input
type="date"
value={ms.dueDate ?? ""}
onChange={(e) => {
const updated = [...terms.paymentMilestones];
updated[idx] = {
...ms,
dueDate: e.target.value || null,
};
update({ paymentMilestones: updated });
}}
className="rounded border border-gray-200 px-2 py-1 text-sm"
/>
) : (
<span className="text-gray-700">
{ms.dueDate ?? "—"}
</span>
)}
</td>
{canEdit && (
<td className="pl-3 py-2 text-center">
<button
type="button"
onClick={() => {
const updated = terms.paymentMilestones.filter(
(_, i) => i !== idx,
);
update({ paymentMilestones: updated });
}}
className="text-red-400 hover:text-red-600 text-xs"
title="Remove"
>
X
</button>
</td>
)}
</tr>
);
})}
{/* Total row */}
<tr className="border-t-2 border-gray-300 font-semibold">
<td className="py-2 pr-3 text-gray-900">Total</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">
{terms.paymentMilestones
.reduce((sum, m) => sum + m.percent, 0)
.toFixed(1)}
%
</td>
<td className="px-3 py-2 text-right tabular-nums text-gray-900">
{formatMoney(
milestoneAmounts.reduce((s, a) => s + a.amountCents, 0),
baseCurrency,
)}
</td>
<td />
{canEdit && <td />}
</tr>
</tbody>
</table>
</div>
)}
</div>
</div>
);
}