feat: dashboard overhaul, chargeability reports, dispo import enhancements, UI polish
Dashboard: expanded chargeability widget, resource/project table widgets with sorting and filters, stat cards with formatMoney integration. Chargeability: new report client with filtering, chargeability-bookings use case, updated dashboard overview logic. Dispo import: TBD project handling, parse-dispo-matrix improvements, stage-dispo-projects resource value scores, new tests. Estimates: CommercialTermsEditor component, commercial-terms engine module, expanded estimate schemas and types. UI: AppShell navigation updates, timeline filter/toolbar enhancements, role management improvements, signin page redesign, Tailwind/globals polish, SystemSettings SMTP section, anonymization support. Tests: new router tests (anonymization, chargeability, effort-rule, entitlement, estimate, experience-multiplier, notification, resource, staffing, vacation). Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -0,0 +1,464 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { CommercialTerms, PaymentMilestone, PricingModel } from "@planarchy/shared";
|
||||
import {
|
||||
computeCommercialTermsSummary,
|
||||
computeMilestoneAmounts,
|
||||
validatePaymentMilestones,
|
||||
} from "@planarchy/engine";
|
||||
import { clsx } from "clsx";
|
||||
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 animate-pulse rounded bg-gray-200" />
|
||||
</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
|
||||
</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
|
||||
</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
|
||||
</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
|
||||
</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
|
||||
</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 %
|
||||
</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 %
|
||||
</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)
|
||||
</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)
|
||||
</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
|
||||
</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
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user