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

@@ -161,6 +161,11 @@ export async function evaluateRule(
statusLastChangedBy: meta.userId,
statusLastChangedReason: `Auto-applied by rule: ${trigger}`,
statusLastModified: new Date(),
// #67 Phase 1: stamp the source so the reconciliation queue
// can filter "Manual only" — rules-engine writes are never
// candidates for catch-up because they already have a backing
// interest driving them.
statusOverrideMode: 'automated',
updatedAt: new Date(),
})
.where(and(eq(berths.id, targetBerthId), eq(berths.portId, portId)));

View File

@@ -1,4 +1,4 @@
import { and, eq, gte, lte, inArray, isNull, sql } from 'drizzle-orm';
import { and, desc, eq, gte, lte, inArray, isNull, notInArray, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { berths, berthTags, berthWaitingList, berthMaintenanceLog } from '@/lib/db/schema/berths';
@@ -314,6 +314,11 @@ export async function updateBerthStatus(
});
if (!existing) throw new NotFoundError('Berth');
// #67 Phase 1: stamp the source of this write so the reconciliation
// queue (and the "Manual" chip on the row) can later distinguish a
// human-set status from a rules-engine auto-set status. The rules
// engine sets this to 'automated' on its own write path; user-facing
// API hits always end up here.
const [updated] = await db
.update(berths)
.set({
@@ -321,6 +326,7 @@ export async function updateBerthStatus(
statusLastChangedBy: meta.userId,
statusLastChangedReason: data.reason,
statusLastModified: new Date(),
statusOverrideMode: 'manual',
updatedAt: new Date(),
})
.where(and(eq(berths.id, id), eq(berths.portId, portId)))
@@ -365,6 +371,206 @@ export async function updateBerthStatus(
return updated!;
}
// ─── Reconciliation Queue ─────────────────────────────────────────────────────
//
// #67 Phase 3: surfaces every berth whose status was set manually (i.e.
// statusOverrideMode === 'manual') AND that has no active linked interest
// backing the status change. These are the rows the catch-up wizard
// targets — a rep flipped them to under_offer / sold without ever
// creating the matching deal. Sorted by status_last_modified DESC so the
// freshest manual flips show up first.
interface ReconcileRow {
id: string;
mooringNumber: string;
area: string | null;
status: string;
statusLastChangedBy: string | null;
statusLastChangedReason: string | null;
statusLastModified: Date | null;
}
export async function listManualReconcileBerths(portId: string): Promise<{
data: ReconcileRow[];
total: number;
}> {
// Use a NOT EXISTS subquery against interest_berths joined with the active
// interests predicate so a berth currently linked to any open deal drops
// out of the queue — even if the rep set the status manually first and
// only later created the interest, that follow-up is the catch-up.
const activeBerthIds = db
.select({ berthId: interestBerths.berthId })
.from(interestBerths)
.innerJoin(interests, eq(interestBerths.interestId, interests.id))
.where(activeInterestsWhere(portId));
const rows = await db
.select({
id: berths.id,
mooringNumber: berths.mooringNumber,
area: berths.area,
status: berths.status,
statusLastChangedBy: berths.statusLastChangedBy,
statusLastChangedReason: berths.statusLastChangedReason,
statusLastModified: berths.statusLastModified,
})
.from(berths)
.where(
and(
eq(berths.portId, portId),
eq(berths.statusOverrideMode, 'manual'),
isNull(berths.archivedAt),
notInArray(berths.id, activeBerthIds),
),
)
.orderBy(desc(berths.statusLastModified));
return { data: rows, total: rows.length };
}
// ─── Reconcile Manual Override ────────────────────────────────────────────────
//
// #67 Phase 1: called by the catch-up wizard once a backing interest is in
// place. Clears `statusOverrideMode` so the berth drops out of the
// reconciliation queue, and stamps the reason with the interest id so the
// audit trail records the reconciliation event explicitly.
//
// Intentionally NOT called from setPrimaryBerth/upsertInterestBerth — those
// run on every berth-link write (including drag-drop reorders that have
// nothing to do with a manual override) and would silently clear the flag
// behind the rep's back. Only the wizard owns the clear semantics.
export async function clearBerthOverride(
berthId: string,
portId: string,
reconciledInterestId: string,
meta: AuditMeta,
): Promise<void> {
const existing = await db.query.berths.findFirst({
where: and(eq(berths.id, berthId), eq(berths.portId, portId)),
});
if (!existing) throw new NotFoundError('Berth');
await db
.update(berths)
.set({
statusOverrideMode: null,
statusLastChangedReason: `Reconciled via interest ${reconciledInterestId}`,
statusLastChangedBy: meta.userId,
statusLastModified: new Date(),
updatedAt: new Date(),
})
.where(and(eq(berths.id, berthId), eq(berths.portId, portId)));
void createAuditLog({
userId: meta.userId,
portId,
action: 'update',
entityType: 'berth',
entityId: berthId,
oldValue: { statusOverrideMode: existing.statusOverrideMode ?? null },
newValue: { statusOverrideMode: null, reconciledInterestId },
metadata: { type: 'reconcile_manual', reconciledInterestId },
ipAddress: meta.ipAddress,
userAgent: meta.userAgent,
});
}
// ─── Catch-up Reconcile ───────────────────────────────────────────────────────
//
// #67 Phase 4: orchestrates "rep set the berth manually, now create the
// backing interest so the row drops out of the reconciliation queue".
//
// Intentionally a thin orchestrator over the existing client / interest
// service helpers (each of which already runs in its own transaction
// with its own audit-log emit). We pull them together here so the API
// layer has a single call to make, but the actual work stays inside the
// already-tested helpers — wrapping ALL of this in one transaction would
// require restructuring the audit-log emits to be queued + flushed at
// commit, which is out of scope for this feature.
interface ReconcileBerthInput {
clientId?: string;
newClient?: { fullName: string; email?: string; phone?: string };
yachtId?: string;
pipelineStage: string;
outcome?: 'won' | null;
outcomeReason?: string;
}
export async function reconcileBerthWithNewInterest(
berthId: string,
portId: string,
input: ReconcileBerthInput,
meta: AuditMeta,
): Promise<{ interestId: string; clientId: string }> {
const berth = await db.query.berths.findFirst({
where: and(eq(berths.id, berthId), eq(berths.portId, portId)),
});
if (!berth) throw new NotFoundError('Berth');
if (berth.statusOverrideMode !== 'manual') {
throw new ValidationError('Berth is not in a manual-override state');
}
// Lazy imports so this module doesn't pull in the entire interest/client
// service surface (and create circular import chains).
const [{ createClient }, { createInterest }] = await Promise.all([
import('@/lib/services/clients.service'),
import('@/lib/services/interests.service'),
]);
let clientId = input.clientId;
if (!clientId) {
if (!input.newClient?.fullName) {
throw new ValidationError('Either clientId or newClient.fullName is required');
}
const contacts: Array<{
channel: 'email' | 'phone' | 'whatsapp' | 'other';
value: string;
isPrimary: boolean;
}> = [];
if (input.newClient.email) {
contacts.push({ channel: 'email', value: input.newClient.email.trim(), isPrimary: true });
}
if (input.newClient.phone) {
contacts.push({
channel: 'phone',
value: input.newClient.phone.trim(),
isPrimary: contacts.length === 0,
});
}
const created = await createClient(
portId,
{
fullName: input.newClient.fullName.trim(),
contacts,
tagIds: [],
} as unknown as Parameters<typeof createClient>[1],
meta,
);
clientId = created.id;
}
const interest = await createInterest(
portId,
{
clientId,
yachtId: input.yachtId ?? null,
berthId,
pipelineStage: input.pipelineStage,
outcome: input.outcome ?? null,
outcomeReason: input.outcomeReason ?? null,
assignedTo: meta.userId,
tagIds: [],
} as unknown as Parameters<typeof createInterest>[1],
meta,
);
await clearBerthOverride(berthId, portId, interest.id, meta);
return { interestId: interest.id, clientId: clientId! };
}
// ─── Set Tags ─────────────────────────────────────────────────────────────────
export async function setBerthTags(id: string, portId: string, tagIds: string[], meta: AuditMeta) {