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:
@@ -0,0 +1,14 @@
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { ReconcileQueue } from '@/components/admin/reconcile-queue';
|
||||
|
||||
export default function ReconcileBerthsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Berth reconciliation queue"
|
||||
description="Berths flipped manually to Under Offer or Sold without a backing interest. Run the catch-up wizard on each row to create the deal, attach docs, and clear the manual flag."
|
||||
/>
|
||||
<ReconcileQueue />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
src/app/api/v1/berths/[id]/reconcile/route.ts
Normal file
44
src/app/api/v1/berths/[id]/reconcile/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
16
src/app/api/v1/berths/reconcile-queue/route.ts
Normal file
16
src/app/api/v1/berths/reconcile-queue/route.ts
Normal 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);
|
||||
}
|
||||
}),
|
||||
);
|
||||
Reference in New Issue
Block a user