From 6bc81270b923b12ae94cde2589fabb226e10b34a Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 19 Jun 2026 10:48:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(interests):=20CM-2=20Part=20B=20=E2=80=94?= =?UTF-8?q?=20deal-price=20override=20route=20+=20UI=20on=20linked=20berth?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../[id]/berths/[berthId]/price/handlers.ts | 39 ++++++++++++ .../[id]/berths/[berthId]/price/route.ts | 5 ++ .../interests/linked-berths-list.tsx | 59 +++++++++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 src/app/api/v1/interests/[id]/berths/[berthId]/price/handlers.ts create mode 100644 src/app/api/v1/interests/[id]/berths/[berthId]/price/route.ts diff --git a/src/app/api/v1/interests/[id]/berths/[berthId]/price/handlers.ts b/src/app/api/v1/interests/[id]/berths/[berthId]/price/handlers.ts new file mode 100644 index 00000000..467f90b5 --- /dev/null +++ b/src/app/api/v1/interests/[id]/berths/[berthId]/price/handlers.ts @@ -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); + } +}; diff --git a/src/app/api/v1/interests/[id]/berths/[berthId]/price/route.ts b/src/app/api/v1/interests/[id]/berths/[berthId]/price/route.ts new file mode 100644 index 00000000..0cca892b --- /dev/null +++ b/src/app/api/v1/interests/[id]/berths/[berthId]/price/route.ts @@ -0,0 +1,5 @@ +import { withAuth, withPermission } from '@/lib/api/helpers'; + +import { putHandler } from './handlers'; + +export const PUT = withAuth(withPermission('interests', 'edit', putHandler)); diff --git a/src/components/interests/linked-berths-list.tsx b/src/components/interests/linked-berths-list.tsx index 5a3a5e34..b2471ed6 100644 --- a/src/components/interests/linked-berths-list.tsx +++ b/src/components/interests/linked-berths-list.tsx @@ -67,6 +67,8 @@ export interface LinkedBerthRow { addedBy: string | null; addedAt: string; notes: string | null; + priceOverride: string | null; + priceOverrideCurrency: string | null; mooringNumber: string | null; area: string | null; 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 ────────────────────────────────────────────────────────── interface BypassDialogProps { @@ -289,9 +309,20 @@ function LinkedBerthRowItem({ }: RowProps) { const [bypassOpen, setBypassOpen] = 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 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 (
+ {/* 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. */} +
+
+

Deal price

+

+ Overrides the berth's list price for this deal only. Leave blank to use the list + price. +

+
+
+ setPriceDraft(e.target.value)} + onBlur={commitPrice} + aria-label={`Deal price for ${row.mooringNumber ?? row.berthId}`} + /> + {row.priceOverrideCurrency ? ( + {row.priceOverrideCurrency} + ) : null} +
+
+ {showBypassControl ? ( // Bypass section reads as a third toggle-style row: label + description // on the left, action button inline with the description so it doesn't