feat(berths): manual status catch-up wizard + reconciliation queue (#67)

Wires the long-dormant berths.status_override_mode column into a closed
loop so reps can reconcile berths flipped to under_offer/sold without a
backing interest.

Phase 1 — Status source tracking:
  - updateBerthStatus() stamps 'manual' on every user-facing write
  - berth-rules-engine.ts stamps 'automated' on auto-rule writes
  - new clearBerthOverride() helper nulls the field and stamps the
    reason "Reconciled via interest <id>" — only the wizard calls it

Phase 2 — Visual indicator:
  - Amber "Manual" chip on berth-list rows where statusOverrideMode='manual'
    AND no active linked interest (the candidates for catch-up)

Phase 3 — Reconciliation queue:
  - new service listManualReconcileBerths() with cross-port-safe
    NOT-EXISTS against activeInterestsWhere
  - GET /api/v1/berths/reconcile-queue
  - new page /[portSlug]/admin/berths/reconcile listing the queue,
    each row linking to the catch-up wizard

Phase 4 — Catch-up wizard:
  - POST /api/v1/berths/[id]/reconcile orchestrates create-client
    (optional quick-create), create-interest with primary berth link,
    and clearBerthOverride — composed via existing service helpers
  - <CatchUpWizard> dialog: existing-client or quick-create, optional
    yacht link, stage picker scoped to the current berth status, with
    contract auto-setting outcome=won

Phase 5 — Entry points:
  - sidebar Admin > "Reconcile berths" link
  - berth-list row action menu shows "Catch up…" on flagged rows

Doc upload + payment recording (spec phases 4.4 / 4.5) are deferred —
once the interest exists, the rep uses the standard interest detail
page surfaces for those follow-ups. The wizard's MVP responsibility is
to take a manual berth to "interest exists, override cleared" in one
round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 23:55:22 +02:00
parent d2804de0d1
commit 7d33e73eef
9 changed files with 777 additions and 36 deletions

View File

@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { z } from 'zod';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { parseBody } from '@/lib/api/route-helpers';
import { reconcileBerthWithNewInterest } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
import { PIPELINE_STAGES } from '@/lib/constants';
const reconcileSchema = z
.object({
clientId: z.string().uuid().optional(),
newClient: z
.object({
fullName: z.string().trim().min(1).max(200),
email: z.string().email().optional(),
phone: z.string().trim().max(50).optional(),
})
.optional(),
yachtId: z.string().uuid().optional(),
pipelineStage: z.enum(PIPELINE_STAGES as unknown as [string, ...string[]]),
outcome: z.enum(['won']).optional(),
outcomeReason: z.string().trim().max(500).optional(),
})
.refine((v) => !!v.clientId || !!v.newClient?.fullName, {
message: 'Either clientId or newClient.fullName must be provided',
});
export const POST = withAuth(
withPermission('berths', 'edit', async (req, ctx, params) => {
try {
const body = await parseBody(req, reconcileSchema);
const result = await reconcileBerthWithNewInterest(params.id!, ctx.portId, body, {
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
});
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);

View File

@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { listManualReconcileBerths } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
export const GET = withAuth(
withPermission('berths', 'edit', async (_req, ctx) => {
try {
const result = await listManualReconcileBerths(ctx.portId);
return NextResponse.json(result);
} catch (error) {
return errorResponse(error);
}
}),
);