cd78f72f33
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>
466 lines
18 KiB
TypeScript
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>
|
|
);
|
|
}
|