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;
|
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'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
|
||||||
|
|||||||
Reference in New Issue
Block a user