Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / frontend/src/screens/Duplicates.tsx |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | frontend/src/screens/Duplicates.tsx |
| Original SHA-256 | ff565f87b3e4701bc8fb526ae20f6ae8642df7e36574cc8db4b023e4cbbea7e6 |
| Git blob SHA | e2f7128668caffe038847056a2f14246d5daf968 |
| Reading-copy SHA-256 | ff565f87b3e4701bc8fb526ae20f6ae8642df7e36574cc8db4b023e4cbbea7e6 |
Recovered source transcript
Download complete source text ↓385 lines · 13.8 KB
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<DuplicatePair | null>(null)
const [done, setDone] = useState<MergeResult | null>(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 (
<Panel title="Possible duplicates">
<Note tone="risk">The duplicate review could not run. {(q.error as Error)?.message}</Note>
</Panel>
)
}
return (
<div className="space-y-4">
<Panel
title="Possible duplicates"
hint={pairs.length === 1 ? '1 pair to review' : `${pairs.length} pairs to review`}
>
<p className="pb-2 text-[11px] leading-relaxed text-faint">
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.
</p>
{q.data?.note && (
<div className="mb-3">
<Note tone="muted">{q.data.note}</Note>
</div>
)}
{q.isLoading ? (
<p className="py-6 text-center text-sm text-muted">Comparing records</p>
) : pairs.length === 0 ? (
<Empty
title="No duplicates found"
reason="Nothing in the records you can see looks like the same person entered twice."
/>
) : (
<div className="space-y-4">
{strong.length > 0 && (
<Group
title="Almost certainly the same person"
pairs={strong}
mayMerge={mayMerge}
onReview={setPair}
/>
)}
{weak.length > 0 && (
<Group
title="Worth a look"
pairs={weak}
mayMerge={mayMerge}
onReview={setPair}
caution="These share less. Two people really do have the same name, so read both records before merging."
/>
)}
</div>
)}
{!mayMerge && pairs.length > 0 && (
<div className="mt-3">
<Note tone="muted">
You can see these but not merge them. Merging changes records, so it needs an account that
works the bench.
</Note>
</div>
)}
</Panel>
<MergeModal pair={pair} onClose={() => setPair(null)} onDone={setDone} />
<ResultModal result={done} onClose={() => setDone(null)} />
</div>
)
}
function Group({
title,
pairs,
mayMerge,
onReview,
caution,
}: {
title: string
pairs: DuplicatePair[]
mayMerge: boolean
onReview: (p: DuplicatePair) => void
caution?: string
}) {
return (
<section>
<h4 className="mb-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-muted">{title}</h4>
{caution && (
<p className="mb-2 flex items-start gap-1.5 text-[11px] text-faint">
<ShieldQuestion size={12} className="mt-0.5 shrink-0" />
{caution}
</p>
)}
<ul className="space-y-2">
{pairs.map((p) => (
<li
key={`${p.a_id}:${p.b_id}`}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-line/60 bg-navy-950/30 px-3 py-2"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-semibold text-ink">{p.a_name}</span>
<span className="text-faint">and</span>
<span className="font-semibold text-ink">{p.b_name}</span>
<Badge color={p.strength === 'strong' ? 'var(--color-warn)' : 'var(--color-blue)'} subtle>
{p.strength === 'strong' ? 'strong match' : 'weak match'}
</Badge>
</div>
{/* 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. */}
<div className="text-[11px] text-faint">{p.reason}</div>
</div>
<button type="button" disabled={!mayMerge} className={btnGhost} onClick={() => onReview(p)}>
<CopyCheck size={13} /> Review them
</button>
</li>
))}
</ul>
</section>
)
}
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 (
<Modal open={!!pair} onClose={onClose} title="Merge two records">
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault()
if (!keepID || !reason.trim()) return
merge.mutate(
{ keepId: keepID, mergeId: mergeID, reason: reason.trim() },
{
onSuccess: (r) => {
setKeep('')
setReason('')
onClose()
onDone(r)
},
},
)
}}
>
<Note tone="warn">
<AlertTriangle size={11} className="mr-1 inline" />
This cannot be undone. The history on both records moves onto the one you keep, and the other stops
being a candidate.
</Note>
<fieldset className="space-y-2">
<legend className="text-[11px] font-bold uppercase tracking-wider text-muted">
Which record should survive
</legend>
<Choice
on={keep === 'a'}
name={pair.a_name}
onSelect={() => setKeep('a')}
note="Everything on the other record moves onto this one."
/>
<Choice
on={keep === 'b'}
name={pair.b_name}
onSelect={() => setKeep('b')}
note="Everything on the other record moves onto this one."
/>
</fieldset>
<Labeled
label="Why are these the same person"
hint="recorded on the merge, so somebody reading this record in a year knows what happened"
>
<input
value={reason}
maxLength={240}
placeholder="Same person, applied twice with two email addresses"
onChange={(e) => setReason(e.target.value)}
className={fieldCls}
/>
</Labeled>
{keep && (
<p className="flex flex-wrap items-center gap-1.5 text-[11px] text-muted">
<span className="font-semibold text-ink">{mergeName}</span>
<ArrowRight size={11} />
<span className="font-semibold text-ink">{keepName}</span>
</p>
)}
{merge.isError && <Note tone="risk">{(merge.error as Error).message}</Note>}
<div className="flex gap-2">
<button
type="submit"
disabled={!keepID || !reason.trim() || merge.isPending}
className={btnPrimary}
>
{merge.isPending ? 'Merging' : 'Merge them'}
</button>
<button type="button" className={btnGhost} onClick={onClose}>
Leave them alone
</button>
</div>
</form>
</Modal>
)
}
function Choice({
on,
name,
note,
onSelect,
}: {
on: boolean
name: string
note: string
onSelect: () => void
}) {
return (
<button
type="button"
onClick={onSelect}
aria-pressed={on}
className={`flex w-full flex-col items-start rounded-md border px-3 py-2 text-left transition ${
on ? 'border-blue/60 bg-blue/10' : 'border-line/60 hover:border-line'
}`}
>
<span className="text-sm font-semibold text-ink">Keep {name}</span>
<span className="text-[11px] text-faint">{note}</span>
</button>
)
}
// 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 (
<Modal open={!!result} onClose={onClose} title="The records are merged">
<div className="space-y-3">
{moved.length === 0 ? (
<Note tone="muted">There was no history to move. The duplicate record is now closed.</Note>
) : (
<div>
<h4 className="mb-1 text-[11px] font-bold uppercase tracking-wider text-muted">What moved</h4>
<ul className="space-y-0.5 text-sm text-muted">
{moved.map(([table, n]) => (
<li key={table} className="flex justify-between">
<span>{humanTable(table)}</span>
<span className="tabular-nums text-ink">{n}</span>
</li>
))}
</ul>
</div>
)}
{dropped.length > 0 && (
<div>
<h4 className="mb-1 text-[11px] font-bold uppercase tracking-wider text-muted">
What was already on both
</h4>
<ul className="space-y-0.5 text-sm text-muted">
{dropped.map(([table, n]) => (
<li key={table} className="flex justify-between">
<span>{humanTable(table)}</span>
<span className="tabular-nums text-ink">{n}</span>
</li>
))}
</ul>
<p className="mt-1 text-[11px] text-faint">
These were duplicates of rows the surviving record already had, so they were not copied twice.
</p>
</div>
)}
{retained.length > 0 && (
<div>
<h4 className="mb-1 text-[11px] font-bold uppercase tracking-wider text-muted">
What stayed on the closed record
</h4>
<ul className="list-inside list-disc text-sm text-muted">
{retained.map((t) => (
<li key={t}>{humanTable(t)}</li>
))}
</ul>
<p className="mt-1 text-[11px] text-faint">
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.
</p>
</div>
)}
{result.note && <Note tone="muted">{result.note}</Note>}
<button type="button" className={btnPrimary} onClick={onClose}>
Done
</button>
</div>
</Modal>
)
}
// 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<string, string> = {
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, ' ')
}