diff --git a/src/components/interests/qualification-checklist.tsx b/src/components/interests/qualification-checklist.tsx index ebaeba77..77c87cb3 100644 --- a/src/components/interests/qualification-checklist.tsx +++ b/src/components/interests/qualification-checklist.tsx @@ -1,8 +1,9 @@ 'use client'; +import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useParams } from 'next/navigation'; -import { CheckCircle2, ChevronRight } from 'lucide-react'; +import { CheckCircle2, ChevronDown, ChevronRight } from 'lucide-react'; import { Checkbox } from '@/components/ui/checkbox'; import { Button } from '@/components/ui/button'; @@ -49,6 +50,10 @@ export function QualificationChecklist({ }) { const params = useParams<{ portSlug: string }>(); const queryClient = useQueryClient(); + // When the checklist is fully confirmed, default to collapsed and let + // the rep expand on demand. `null` means "follow the auto-default"; + // an explicit boolean reflects a rep click. + const [manuallyExpanded, setManuallyExpanded] = useState(false); const { data, isLoading } = useQuery({ queryKey: ['interest-qualifications', interestId], @@ -87,88 +92,122 @@ export function QualificationChecklist({ const fullyQualified = data.data.fullyQualified; const showPromoteHint = fullyQualified && currentStage === 'enquiry'; + // Auto-collapse when fully confirmed — rep can expand to inspect. + // Force-expanded whenever there's still an outstanding item. + const expanded = manuallyExpanded || !fullyQualified; // Avoid referencing `params` in the JSX so the unused destructure passes // strict noUnused checks; it stays available for future deep-link hooks. void params; return (
-
-

Qualification

+
+ - + > + + + {c.label} + + {c.autoSatisfied && ( + + Auto + + )} + + {c.description ? ( +

{c.description}

+ ) : null} + {c.autoSatisfied && c.evidence ? ( +

+ {c.evidence} +

+ ) : null} + + + ))} + + ) : null} {showPromoteHint ? (
diff --git a/src/lib/services/qualification.service.ts b/src/lib/services/qualification.service.ts index 075cc555..4fbae1fd 100644 --- a/src/lib/services/qualification.service.ts +++ b/src/lib/services/qualification.service.ts @@ -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 = 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 · " — 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 ''; }