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:
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
|
||||
import { putHandler } from './handlers';
|
||||
|
||||
export const PUT = withAuth(withPermission('interests', 'edit', putHandler));
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -458,6 +489,34 @@ function LinkedBerthRowItem({
|
||||
</div>
|
||||
</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'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 ? (
|
||||
// Bypass section reads as a third toggle-style row: label + description
|
||||
// on the left, action button inline with the description so it doesn't
|
||||
|
||||
Reference in New Issue
Block a user