diff --git a/src/app/api/public/website-inquiries/route.ts b/src/app/api/public/website-inquiries/route.ts index d94fe0a4..30430a9b 100644 --- a/src/app/api/public/website-inquiries/route.ts +++ b/src/app/api/public/website-inquiries/route.ts @@ -21,6 +21,10 @@ import { notifyWebsiteSubmissionInApp, sendWebsiteSubmissionEmails, } from '@/lib/services/website-intake-email.service'; +import { + autoPromoteWebsiteBerthInquiry, + isWebsiteBerthAutopromoteEnabled, +} from '@/lib/services/website-intake-promote.service'; /** * POST /api/public/website-inquiries @@ -190,6 +194,39 @@ export async function POST(req: NextRequest) { ), ); + // Flag-gated berth auto-promote (Option 2). On a fresh capture, a + // registration for a specific currently-available berth becomes a prospect + // immediately and flips the public map to "Under Offer". Default OFF, so + // by default captures wait in triage for a rep (Option 1). Fire-and-forget + // after the insert: a promote failure must not 500 the capture POST. + if (await isWebsiteBerthAutopromoteEnabled(port.id)) { + void autoPromoteWebsiteBerthInquiry({ + portId: port.id, + submissionId: parsed.submission_id, + kind: parsed.kind, + payload: parsed.payload, + }) + .then((r) => { + if (r.promoted) { + logger.info( + { submissionId: parsed.submission_id, interestId: r.interestId, berthId: r.berthId }, + 'website inquiry auto-promoted to interest (berth marked under offer)', + ); + } else { + logger.info( + { submissionId: parsed.submission_id, reason: r.reason }, + 'website inquiry auto-promote skipped', + ); + } + }) + .catch((err) => + logger.error( + { err, submissionId: parsed.submission_id }, + 'Failed to auto-promote website inquiry', + ), + ); + } + // Flag-gated CRM-owned emails (registrant confirmation + staff alert). // Fire only on this fresh-insert branch so a redelivery never re-sends. // Inline fire-and-forget: a send failure must not 500 the capture POST. diff --git a/src/app/api/v1/berths/[id]/status/reset/route.ts b/src/app/api/v1/berths/[id]/status/reset/route.ts new file mode 100644 index 00000000..78680563 --- /dev/null +++ b/src/app/api/v1/berths/[id]/status/reset/route.ts @@ -0,0 +1,26 @@ +import { NextResponse } from 'next/server'; + +import { withAuth, withPermission } from '@/lib/api/helpers'; +import { parseBody } from '@/lib/api/route-helpers'; +import { resetBerthOverrideSchema } from '@/lib/validators/berths'; +import { resetBerthStatusOverride } from '@/lib/services/berths.service'; +import { errorResponse } from '@/lib/errors'; + +// POST /api/v1/berths/[id]/status/reset +// Clears a manual status pin so the berth resumes derived/automatic status. +export const POST = withAuth( + withPermission('berths', 'edit', async (req, ctx, params) => { + try { + const { reason } = await parseBody(req, resetBerthOverrideSchema); + const updated = await resetBerthStatusOverride(params.id!, ctx.portId, reason, { + userId: ctx.userId, + portId: ctx.portId, + ipAddress: ctx.ipAddress, + userAgent: ctx.userAgent, + }); + return NextResponse.json({ data: updated }); + } catch (error) { + return errorResponse(error); + } + }), +); diff --git a/src/components/berths/berth-columns.tsx b/src/components/berths/berth-columns.tsx index 9d767cd4..3826ce82 100644 --- a/src/components/berths/berth-columns.tsx +++ b/src/components/berths/berth-columns.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { type ColumnDef } from '@tanstack/react-table'; -import { MoreHorizontal, Pencil, Activity, RefreshCw } from 'lucide-react'; +import { MoreHorizontal, Pencil, Activity, RefreshCw, Lock } from 'lucide-react'; import { useRouter, useParams } from 'next/navigation'; import { useMutation, useQueryClient } from '@tanstack/react-query'; @@ -150,17 +150,29 @@ function StatusBadge({ status }: { status: string }) { } /** - * #67 Phase 2: small amber chip beside the status pill flagging rows - * whose status was set manually and has no backing interest. These are - * the candidates for the catch-up wizard - the rep flipped a berth to - * "Under Offer" or "Sold" without ever creating the matching deal. + * Chip beside the status pill flagging a manually-pinned status (wins over + * automatic derivation). Two variants: + * - 'catchup' (amber): manual AND no backing interest - a candidate for the + * catch-up wizard (rep flipped to Under Offer/Sold without a matching deal). + * - 'pinned' (slate + lock): manual WITH a backing deal - a deliberate pin. */ -function ManualBadge() { +function ManualBadge({ variant }: { variant: 'catchup' | 'pinned' }) { + if (variant === 'catchup') { + return ( + + Manual + + ); + } return ( + Manual ); @@ -326,11 +338,12 @@ export const berthColumns: ColumnDef[] = [ header: 'Status', cell: ({ row }) => { const r = row.original; - const isManualUnreconciled = r.statusOverrideMode === 'manual' && !r.latestInterestStage; + const isManual = r.statusOverrideMode === 'manual'; + const isManualUnreconciled = isManual && !r.latestInterestStage; return (
- {isManualUnreconciled ? : null} + {isManual ? : null}
); }, diff --git a/src/components/berths/berth-detail-header.tsx b/src/components/berths/berth-detail-header.tsx index b044cdac..b8ea2672 100644 --- a/src/components/berths/berth-detail-header.tsx +++ b/src/components/berths/berth-detail-header.tsx @@ -1,8 +1,8 @@ 'use client'; import { useState } from 'react'; -import { Check, ChevronsUpDown, Pencil, RefreshCw } from 'lucide-react'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { Check, ChevronsUpDown, Lock, Pencil, RefreshCw, RotateCcw } from 'lucide-react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -84,6 +84,12 @@ export type BerthDetailData = { berthApproved: boolean | null; statusLastChangedReason: string | null; statusLastModified: string | null; + /** 'manual' = a human pinned this status (wins over derived signals); + * 'automated' = set by the rules engine; null = pure derived. */ + statusOverrideMode: string | null; + /** Pipeline stage of the most recent active interest on this berth, if any. + * Used to flag a manual pin that diverges from an active deal. */ + latestInterestStage: string | null; tags: Array<{ id: string; name: string; color: string }>; }; @@ -258,9 +264,89 @@ function StatusChangeDialog({ ); } +/** + * Confirm dialog for "Reset to automatic" - drops the manual pin so the berth + * resumes derived status. A reason is required (audit trail). An optional + * `defaultReason` pre-fills the box for the one-click banner path. + */ +function ResetOverrideDialog({ + berthId, + open, + onOpenChange, + defaultReason = '', +}: { + berthId: string; + open: boolean; + onOpenChange: (open: boolean) => void; + defaultReason?: string; +}) { + const queryClient = useQueryClient(); + const [reason, setReason] = useState(defaultReason); + + const resetMutation = useMutation({ + mutationFn: () => + apiFetch(`/api/v1/berths/${berthId}/status/reset`, { + method: 'POST', + body: { reason: reason.trim() || 'Resumed automatic status' }, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['berths'] }); + queryClient.invalidateQueries({ queryKey: ['berth', berthId] }); + toast.success('Berth reset to automatic status'); + onOpenChange(false); + }, + onError: (err) => toastError(err), + }); + + return ( + + + + Reset to automatic status + +
+

+ This removes the manual pin so the berth's public status is governed by its + interests and tenancies again. The displayed status may change immediately. +

+
+ +