feat(uat-batch-8): qualification rework — intent auto-confirm + derived-only + collapse-when-done
Three coordinated changes to the per-interest qualification checklist that collectively trim it from a noisy gate into an out-of-the-way audit log once the deal moves forward. - Auto-confirm `intent_confirmed` once `pipelineStage > qualified`. Signing an EOI (or later) is the strongest signal of intent; the checklist no longer requires a redundant explicit tick. Evidence string reads "Stage advanced past Qualified". - `dimensions` becomes derived-only — explicit ticks no longer override removed evidence. When the rep deletes a yacht link or clears desired dims, the row un-ticks immediately. Judgement-based criteria keep the OR semantic so a manual confirmation survives an evidence change. - Checklist auto-collapses when fully confirmed: header shows ✓ All confirmed (label · label) with a chevron; rep clicks to expand and inspect or untick. Forced-expanded whenever an item is still outstanding. ARIA-controlled. - `qualification.service` gains a `pipelineStage` column-select and threads it through `AutoCtx`; `DERIVED_ONLY_KEYS` Set sentinel drives the new merge semantic. tsc clean. 1419/1419 vitest pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ import { and, asc, eq } from 'drizzle-orm';
|
||||
import { db } from '@/lib/db';
|
||||
import { interestQualifications, interests, qualificationCriteria } from '@/lib/db/schema';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
import { PIPELINE_STAGES, type PipelineStage } from '@/lib/constants';
|
||||
import { ConflictError, NotFoundError, ValidationError } from '@/lib/errors';
|
||||
import { emitToRoom } from '@/lib/socket/server';
|
||||
import type {
|
||||
@@ -261,6 +262,7 @@ export async function listInterestQualifications(
|
||||
desiredLengthFt: true,
|
||||
desiredWidthFt: true,
|
||||
desiredDraftFt: true,
|
||||
pipelineStage: true,
|
||||
},
|
||||
});
|
||||
if (!interest) throw new NotFoundError('Interest');
|
||||
@@ -295,36 +297,34 @@ export async function listInterestQualifications(
|
||||
|
||||
return criteria.map((c) => {
|
||||
const s = stateByKey.get(c.key);
|
||||
const autoSatisfied = computeAutoSatisfied(c.key, {
|
||||
const ctx = {
|
||||
yachtDims,
|
||||
desiredDims: {
|
||||
lengthFt: interest.desiredLengthFt ?? null,
|
||||
widthFt: interest.desiredWidthFt ?? null,
|
||||
draftFt: interest.desiredDraftFt ?? null,
|
||||
},
|
||||
});
|
||||
pipelineStage: (interest.pipelineStage ?? 'enquiry') as PipelineStage,
|
||||
};
|
||||
const autoSatisfied = computeAutoSatisfied(c.key, ctx);
|
||||
const explicit = s?.confirmed ?? false;
|
||||
const evidence = autoSatisfied
|
||||
? computeEvidence(c.key, {
|
||||
yachtDims,
|
||||
desiredDims: {
|
||||
lengthFt: interest.desiredLengthFt ?? null,
|
||||
widthFt: interest.desiredWidthFt ?? null,
|
||||
draftFt: interest.desiredDraftFt ?? null,
|
||||
},
|
||||
})
|
||||
: '';
|
||||
const evidence = autoSatisfied ? computeEvidence(c.key, ctx) : '';
|
||||
// Derived-only criteria (e.g. `dimensions`) ignore the explicit tick
|
||||
// entirely — if the underlying evidence disappears, the row un-ticks.
|
||||
// Judgement-based criteria keep the OR semantic so a rep's explicit
|
||||
// confirmation survives an evidence change.
|
||||
const confirmed = isDerivedOnly(c.key) ? autoSatisfied : explicit || autoSatisfied;
|
||||
return {
|
||||
key: c.key,
|
||||
label: c.label,
|
||||
description: c.description,
|
||||
enabled: c.enabled,
|
||||
displayOrder: c.displayOrder,
|
||||
// Surface ticked state when either signal is true. Explicit confirmation
|
||||
// Surface ticked state per `confirmed` above. Explicit confirmation
|
||||
// still gets its confirmedAt/By stamps; auto-satisfied state leaves
|
||||
// those null so the rep can see "this was system-derived, not an
|
||||
// explicit sign-off".
|
||||
confirmed: explicit || autoSatisfied,
|
||||
confirmed,
|
||||
confirmedAt: s?.confirmedAt ?? null,
|
||||
confirmedBy: s?.confirmedBy ?? null,
|
||||
notes: s?.notes ?? null,
|
||||
@@ -339,13 +339,26 @@ export async function listInterestQualifications(
|
||||
* (and any linked records) and decide whether the criterion is satisfied
|
||||
* without an explicit rep tick. Add new rules by branching on `key`.
|
||||
*/
|
||||
function computeAutoSatisfied(
|
||||
key: string,
|
||||
ctx: {
|
||||
yachtDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null } | null;
|
||||
desiredDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null };
|
||||
},
|
||||
): boolean {
|
||||
interface AutoCtx {
|
||||
yachtDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null } | null;
|
||||
desiredDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null };
|
||||
pipelineStage: PipelineStage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys whose `confirmed` state should be purely derived (no explicit
|
||||
* tick respected). Removing the underlying evidence un-ticks the row.
|
||||
* Compare with keys carrying real rep judgement (e.g. intent_confirmed
|
||||
* before auto-derivation kicks in), which retain explicit-vs-auto OR
|
||||
* semantics.
|
||||
*/
|
||||
const DERIVED_ONLY_KEYS: ReadonlySet<string> = new Set(['dimensions']);
|
||||
|
||||
function isDerivedOnly(key: string): boolean {
|
||||
return DERIVED_ONLY_KEYS.has(key);
|
||||
}
|
||||
|
||||
function computeAutoSatisfied(key: string, ctx: AutoCtx): boolean {
|
||||
if (key === 'dimensions') {
|
||||
const hasYachtDims =
|
||||
!!ctx.yachtDims &&
|
||||
@@ -356,6 +369,14 @@ function computeAutoSatisfied(
|
||||
!!ctx.desiredDims.lengthFt && !!ctx.desiredDims.widthFt && !!ctx.desiredDims.draftFt;
|
||||
return hasYachtDims || hasDesiredDims;
|
||||
}
|
||||
if (key === 'intent_confirmed') {
|
||||
// Signing an EOI (or later) is the strongest signal of intent —
|
||||
// auto-tick once the rep has moved past Qualified. The criterion
|
||||
// can still be ticked manually before then.
|
||||
const stageIdx = PIPELINE_STAGES.indexOf(ctx.pipelineStage);
|
||||
const qualifiedIdx = PIPELINE_STAGES.indexOf('qualified');
|
||||
return stageIdx > qualifiedIdx;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -364,13 +385,7 @@ function computeAutoSatisfied(
|
||||
* auto-satisfaction. Mirrors `computeAutoSatisfied`'s branching so the UI
|
||||
* can render "Auto · <evidence>" — closes the "why is this ticked?" gap.
|
||||
*/
|
||||
function computeEvidence(
|
||||
key: string,
|
||||
ctx: {
|
||||
yachtDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null } | null;
|
||||
desiredDims: { lengthFt: string | null; widthFt: string | null; draftFt: string | null };
|
||||
},
|
||||
): string {
|
||||
function computeEvidence(key: string, ctx: AutoCtx): string {
|
||||
if (key === 'dimensions') {
|
||||
const hasYacht =
|
||||
!!ctx.yachtDims &&
|
||||
@@ -387,6 +402,9 @@ function computeEvidence(
|
||||
}
|
||||
return '';
|
||||
}
|
||||
if (key === 'intent_confirmed') {
|
||||
return 'Stage advanced past Qualified';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user