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:
2026-05-21 17:47:38 +02:00
parent b9d388a362
commit 51ca875665
2 changed files with 147 additions and 90 deletions

View File

@@ -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 '';
}