feat(interests): CM-2 Part B — deal-price override route + UI on linked berths

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-19 10:48:38 +02:00
parent 38e392e38b
commit 6bc81270b9
3 changed files with 103 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
/**
* Route handler for `/api/v1/interests/[id]/berths/[berthId]/price` (CM-2 Part B).
*
* Sets or clears the deal-specific price override for one (interest, berth).
* In handlers.ts so integration tests can call it directly.
*/
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { type RouteHandler } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { errorResponse } from '@/lib/errors';
import { setBerthPriceOverride } from '@/lib/services/interest-berths.service';
const bodySchema = z.object({
price: z.number().nonnegative().nullable(),
currency: z.string().min(1).max(8).optional(),
});
export const putHandler: RouteHandler<{ id: string; berthId: string }> = async (
req,
ctx,
params,
) => {
try {
const body = await parseBody(req, bodySchema);
await setBerthPriceOverride(
params.id!,
params.berthId!,
body.price,
body.currency ?? null,
ctx.portId,
);
return new NextResponse(null, { status: 204 });
} catch (error) {
return errorResponse(error);
}
};

View File

@@ -0,0 +1,5 @@
import { withAuth, withPermission } from '@/lib/api/helpers';
import { putHandler } from './handlers';
export const PUT = withAuth(withPermission('interests', 'edit', putHandler));

View File

@@ -67,6 +67,8 @@ export interface LinkedBerthRow {
addedBy: string | null; addedBy: string | null;
addedAt: string; addedAt: string;
notes: string | null; notes: string | null;
priceOverride: string | null;
priceOverrideCurrency: string | null;
mooringNumber: string | null; mooringNumber: string | null;
area: string | null; area: string | null;
status: string; status: string;
@@ -193,6 +195,24 @@ function useRemoveLink(interestId: string) {
}); });
} }
// CM-2 Part B: set/clear the deal-specific price override for one berth.
function useSetBerthPrice(interestId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { berthId: string; price: number | null }) =>
apiFetch(`/api/v1/interests/${interestId}/berths/${args.berthId}/price`, {
method: 'PUT',
body: { price: args.price },
}),
onSuccess: (_data, args) => {
toast.success(args.price == null ? 'Reverted to list price.' : 'Deal price saved.');
qc.invalidateQueries({ queryKey: ['interest-berths', interestId] });
qc.invalidateQueries({ queryKey: ['interests', interestId] });
},
onError: (e: Error) => toastError(e),
});
}
// ─── Bypass dialog ────────────────────────────────────────────────────────── // ─── Bypass dialog ──────────────────────────────────────────────────────────
interface BypassDialogProps { interface BypassDialogProps {
@@ -289,9 +309,20 @@ function LinkedBerthRowItem({
}: RowProps) { }: RowProps) {
const [bypassOpen, setBypassOpen] = useState(false); const [bypassOpen, setBypassOpen] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false); const [confirmRemove, setConfirmRemove] = useState(false);
const [priceDraft, setPriceDraft] = useState(row.priceOverride ?? '');
const setBerthPrice = useSetBerthPrice(interestId);
const dims = formatDimensions(row.lengthFt, row.widthFt, row.draftFt); const dims = formatDimensions(row.lengthFt, row.widthFt, row.draftFt);
const showBypassControl = eoiStatus === 'signed'; const showBypassControl = eoiStatus === 'signed';
const commitPrice = () => {
const raw = priceDraft.replace(/[,\s]/g, '');
const next = raw === '' ? null : Number(raw);
if (next !== null && (!Number.isFinite(next) || next < 0)) return; // ignore garbage
const prev = row.priceOverride == null ? null : Number(row.priceOverride);
if (next === prev) return;
setBerthPrice.mutate({ berthId: row.berthId, price: next });
};
return ( return (
<div <div
className={cn( className={cn(
@@ -458,6 +489,34 @@ function LinkedBerthRowItem({
</div> </div>
</TooltipProvider> </TooltipProvider>
{/* CM-2 Part B: deal-specific price. Overrides the berth's list price for
this interest only; flows into the EOI/document {{berth.price}} token. */}
<div className="mt-3 flex flex-wrap items-center gap-3 border-t pt-3">
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-sm font-medium">Deal price</p>
<p className="text-xs text-muted-foreground">
Overrides the berth&apos;s list price for this deal only. Leave blank to use the list
price.
</p>
</div>
<div className="flex items-center gap-2">
<input
type="text"
inputMode="numeric"
className="w-36 rounded-md border px-2 py-1 text-sm tabular-nums"
placeholder="List price"
value={priceDraft}
disabled={isPending || setBerthPrice.isPending}
onChange={(e) => setPriceDraft(e.target.value)}
onBlur={commitPrice}
aria-label={`Deal price for ${row.mooringNumber ?? row.berthId}`}
/>
{row.priceOverrideCurrency ? (
<span className="text-xs text-muted-foreground">{row.priceOverrideCurrency}</span>
) : null}
</div>
</div>
{showBypassControl ? ( {showBypassControl ? (
// Bypass section reads as a third toggle-style row: label + description // Bypass section reads as a third toggle-style row: label + description
// on the left, action button inline with the description so it doesn't // on the left, action button inline with the description so it doesn't