Files
pn-new-crm/src/app/api/v1/berths/[id]/reconcile/route.ts

45 lines
1.5 KiB
TypeScript
Raw Normal View History

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>
2026-05-14 23:55:22 +02:00
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);
}
}),
);