ALL-1875 PR-4: Duplicate candidates list UI + ContactsPage tab
queued## ALL-1875: Contact merge UI + auto duplicate detection — PR 4 of 5
### Goal
Build the Duplicate Candidates list view in the dashboard — a new tab or sub-page under /ws/[id]/contacts showing detected candidate pairs.
### Context
- Linear issue: ALL-1875
- Branch: `wilbo/all-1875-duplicate-candidates-ui`
- Worktree: `mono-all-1875-duplicate-candidates-ui`
- Depends on: PR-3 merged (GraphQL query `duplicateContactCandidates` available)
- App: `apps/dashboard`
- Route structure: `app/(shell)/ws/[id]/contacts/`
### What to build
1. **New route**: `apps/dashboard/app/(shell)/ws/[id]/contacts/duplicates/page.tsx`
- Wraps `<DuplicateCandidatesPage />`
2. **Page component**: `apps/dashboard/components/pages/DuplicateCandidatesPage.tsx`
- Query `duplicateContactCandidates(workspaceId, first: 50, orderBy: confidence_desc)`
- Card-per-pair layout: two contact name+email+phone chips side by side
- Match signals shown as badges (EMAIL MATCH, PHONE MATCH, etc.)
- Confidence score badge (e.g. "High" for >=0.8, "Medium" 0.5–0.8)
- Actions per row: "Review & Merge" (navigates to merge comparison UI, PR-5) + "Not Duplicates" (calls `dismissContactDuplicate`)
- Filter: confidence (all / high / medium) + sort (confidence / date detected)
- Empty state when no candidates
- Loading skeleton
3. **Navigation entry**: Add "Duplicates" tab or link inside the contacts section nav (look at how contacts page handles tabs, follow the same pattern)
4. **Hook**: `apps/dashboard/hooks/useDuplicateCandidates.ts`
- Wraps the GraphQL query with pagination and filter state
5. **Codegen**: Run `yarn codegen` on the dashboard to get typed GQL ops for `duplicateContactCandidates` and `dismissContactDuplicate`.
### Key existing files
- `apps/dashboard/components/pages/ContactsPage.tsx` — contacts list reference pattern
- `apps/dashboard/components/pages/ContactDetailPage.tsx` — contact card/detail patterns, edges component imports
- `apps/dashboard/app/(shell)/ws/[id]/contacts/page.tsx` — route wiring pattern
- `apps/dashboard/hooks/useContacts.ts` — hook pattern for contacts query
- `apps/dashboard/lib/@generated/graphql.ts` — regenerated by codegen
### Acceptance test
- Navigating to /ws/[id]/contacts/duplicates shows candidate pairs
- Clicking "Not Duplicates" dismisses the pair (row disappears or shows dismissed state)
- Clicking "Review & Merge" navigates to /ws/[id]/contacts/[id]/merge/[otherId] (stub route ok if PR-5 not done)
- Empty state shown when no candidates exist
- TypeScript type-checks cleanly
### Depends on
- PR-3 merged (fleet-task e53c7925-185a-486b-a817-827d49985451)
### Out of scope
- The merge comparison/execution UI (PR-5)
- Merge history on ContactDetailPage (that's in PR-5)
Event Timeline
created