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:
@@ -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)));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user