import { useState } from 'react' import { AlertTriangle, ArrowRight, CopyCheck, ShieldQuestion } from 'lucide-react' import { Badge, Modal, Panel } from '../components/ui' import { Empty, Labeled, Note, btnGhost, btnPrimary, fieldCls } from '../components/form' import { CAP, can } from '../lib/can' import { useAuth } from '../lib/useAuth' import { useDuplicates, useMergeCandidates } from '../lib/workflowQueries' import type { DuplicatePair, MergeResult } from '../lib/workflow' // DUPLICATES AND MERGE. // // The same person arrives twice: once from a job board, once typed in by a recruiter who did not find the // first record. Both then accumulate history, and the bench count is wrong in a way nobody can see. // // A MERGE IS NOT REVERSIBLE AND THE SCREEN SAYS SO EVERY TIME. It moves roughly eighteen tables worth of // history onto one record and marks the other as merged. That is the right behaviour, and it is exactly why // this screen refuses to be quick: the choice of which record survives is a decision about which history is // the real one, so it is made deliberately, with a reason recorded, and never on a single click. // // THE PAIRS ARE A SUGGESTION, NOT A JUDGEMENT. The server says why it thinks two records are the same and how // confident it is. Two people genuinely do share a name, and merging them would be worse than the duplicate. export function Duplicates() { const { principal } = useAuth() const q = useDuplicates() const [pair, setPair] = useState(null) const [done, setDone] = useState(null) const mayMerge = can(principal, CAP.candidateWrite) const pairs = q.data?.duplicates ?? [] const strong = pairs.filter((p) => p.strength === 'strong') const weak = pairs.filter((p) => p.strength !== 'strong') if (q.isError) { return ( The duplicate review could not run. {(q.error as Error)?.message} ) } return (

These are records that look like the same person. Nothing here has been changed. Merging is permanent, so each pair is yours to confirm or leave alone.

{q.data?.note && (
{q.data.note}
)} {q.isLoading ? (

Comparing records

) : pairs.length === 0 ? ( ) : (
{strong.length > 0 && ( )} {weak.length > 0 && ( )}
)} {!mayMerge && pairs.length > 0 && (
You can see these but not merge them. Merging changes records, so it needs an account that works the bench.
)}
setPair(null)} onDone={setDone} /> setDone(null)} />
) } function Group({ title, pairs, mayMerge, onReview, caution, }: { title: string pairs: DuplicatePair[] mayMerge: boolean onReview: (p: DuplicatePair) => void caution?: string }) { return (

{title}

{caution && (

{caution}

)}
    {pairs.map((p) => (
  • {p.a_name} and {p.b_name} {p.strength === 'strong' ? 'strong match' : 'weak match'}
    {/* WHY the server thinks they are the same, in its own words. "Same email address" is a fact somebody can act on; a confidence score on its own is not. */}
    {p.reason}
  • ))}
) } function MergeModal({ pair, onClose, onDone, }: { pair: DuplicatePair | null onClose: () => void onDone: (r: MergeResult) => void }) { const merge = useMergeCandidates() // Which record SURVIVES. It defaults to neither, on purpose: the whole point of this screen is that // somebody chooses, and a default would be chosen for them by whichever record the server listed first. const [keep, setKeep] = useState<'a' | 'b' | ''>('') const [reason, setReason] = useState('') if (!pair) return null const keepID = keep === 'a' ? pair.a_id : keep === 'b' ? pair.b_id : '' const mergeID = keep === 'a' ? pair.b_id : keep === 'b' ? pair.a_id : '' const keepName = keep === 'a' ? pair.a_name : keep === 'b' ? pair.b_name : '' const mergeName = keep === 'a' ? pair.b_name : keep === 'b' ? pair.a_name : '' return (
{ e.preventDefault() if (!keepID || !reason.trim()) return merge.mutate( { keepId: keepID, mergeId: mergeID, reason: reason.trim() }, { onSuccess: (r) => { setKeep('') setReason('') onClose() onDone(r) }, }, ) }} > This cannot be undone. The history on both records moves onto the one you keep, and the other stops being a candidate.
Which record should survive setKeep('a')} note="Everything on the other record moves onto this one." /> setKeep('b')} note="Everything on the other record moves onto this one." />
setReason(e.target.value)} className={fieldCls} /> {keep && (

{mergeName} {keepName}

)} {merge.isError && {(merge.error as Error).message}}
) } function Choice({ on, name, note, onSelect, }: { on: boolean name: string note: string onSelect: () => void }) { return ( ) } // ResultModal reports what the merge ACTUALLY did, table by table, rather than saying it worked. // // A merge cannot move everything. Some rows are a record of a decision made about a specific candidate and // have to stay where they are, and one ledger belongs to the merge itself. Reporting a clean success while // silently leaving rows behind is how somebody later finds history they were told had moved. function ResultModal({ result, onClose }: { result: MergeResult | null; onClose: () => void }) { if (!result) return null const moved = Object.entries(result.moved ?? {}).filter(([, n]) => n > 0) const dropped = Object.entries(result.dropped ?? {}).filter(([, n]) => n > 0) const retained = result.retained_on_the_merged_record ?? [] return (
{moved.length === 0 ? ( There was no history to move. The duplicate record is now closed. ) : (

What moved

    {moved.map(([table, n]) => (
  • {humanTable(table)} {n}
  • ))}
)} {dropped.length > 0 && (

What was already on both

    {dropped.map(([table, n]) => (
  • {humanTable(table)} {n}
  • ))}

These were duplicates of rows the surviving record already had, so they were not copied twice.

)} {retained.length > 0 && (

What stayed on the closed record

    {retained.map((t) => (
  • {humanTable(t)}
  • ))}

These are records of decisions made about that specific candidate. Moving them would change what a past decision was about, so they stay where they were made.

)} {result.note && {result.note}}
) } // humanTable turns a table name into something a recruiter reads. An unknown name falls back to the raw one // with the underscores removed, so a table added later degrades to readable rather than to blank. function humanTable(t: string): string { const known: Record = { applications: 'Applications', candidate_skills: 'Skills', candidate_certifications: 'Certifications', candidate_customer_experience: 'Customer experience', candidate_documents: 'Documents', candidate_list_members: 'List memberships', messages: 'Messages', recruiting_activities: 'Activity history', tasks: 'Tasks', work_claims: 'Work claims', work_touches: 'Recent visits', interview_offers: 'Interview offers', job_applications: 'Inbound applications', aedt_candidate_notices: 'Automated decision notices', candidate_merges: 'Merge history', } return known[t] ?? t.replace(/_/g, ' ') }