ALL-1875 PR-5: Merge comparison UI + ContactDetailPage merge history + Find Duplicates action
queued## ALL-1875: Contact merge UI + auto duplicate detection — PR 5 of 5
### Goal
Build the side-by-side merge comparison view, wire the mergeCanonicalContacts mutation into a confirmation flow, and add merge history + Find Duplicates action to ContactDetailPage.
### Context
- Linear issue: ALL-1875
- Branch: `wilbo/all-1875-merge-ui`
- Worktree: `mono-all-1875-merge-ui`
- Depends on: PR-3 merged (mergeCanonicalContacts mutation + mergeHistory field available in GQL)
- App: `apps/dashboard`
### What to build
1. **Merge comparison page route**: `apps/dashboard/app/(shell)/ws/[id]/contacts/[contactId]/merge/[otherId]/page.tsx`
- Wraps `<ContactMergePage />`
2. **ContactMergePage component**: `apps/dashboard/components/pages/ContactMergePage.tsx`
- Fetches both contacts by ID
- Side-by-side field comparison table: name, email(s), phone(s), address, account numbers
- Show match signals highlighted (e.g. email glow when both contacts have the same email)
- Show associated resources counts: sites, devices, program enrollments, ThirdPartyClaims for each
- "Merge" button: opens a confirmation dialog summarizing what will be merged
- On confirm: calls `mergeCanonicalContacts(winnerId, loserId, reason: "Manual merge")` mutation
- On success: redirect to winner contact detail page with a success toast
- On error: surface the CanonicalContactMergeError code as a user-friendly message
- Winner/loser selection: allow the user to pick which contact to keep (default to contactId from the URL)
3. **Merge history section on ContactDetailPage**:
- Add a "Merge History" section at the bottom of ContactDetailPage.tsx
- Query `canonicalContact { mergeHistory { ... } }` (from PR-3)
- Shows a list of past merges: date, which contact was merged in, actor
- "Undo merge" button for each (calls `unmergeCanonicalContacts` if exposed — if mutation doesn't exist yet, render a disabled button with tooltip "Contact support to undo")
4. **Find Duplicates action on ContactDetailPage**:
- Add to the ActionMenu (already exists in ContactDetailPage.tsx)
- Navigates to `/ws/[id]/contacts/duplicates?contactId=[id]` (pre-filters the list to pairs involving this contact) OR directly to the merge page if a single high-confidence match exists
### Key existing files
- `apps/dashboard/components/pages/ContactDetailPage.tsx` — ActionMenu, Section, Card, SiteCard patterns; where to add merge history + action
- `apps/dashboard/components/pages/ContactsPage.tsx` — list + filter patterns
- `apps/dashboard/app/(shell)/ws/[id]/contacts/[contactId]/page.tsx` — existing route pattern
- Mutation `mergeCanonicalContacts` already in GQL schema — find it in `apps/dashboard/lib/@generated/graphql.ts`
### Acceptance test
- /ws/[id]/contacts/[id]/merge/[otherId] renders both contacts side-by-side
- Confirming merge calls `mergeCanonicalContacts` mutation and redirects to winner
- Merge history section on ContactDetailPage shows previous merges for a merged contact
- Find Duplicates action in ActionMenu navigates correctly
- TypeScript type-checks cleanly; no new TS errors
### Depends on
- PR-3 merged (fleet-task e53c7925-185a-486b-a817-827d49985451)
- PR-4 recommended (for the duplicates list navigation) but not hard-required
### Out of scope
- Unmerge mutation implementation (follow-up Linear if mergeHistory.unmerge is needed — note the backend already has CanonicalContactMergeClaimSnapshot for it)
- Fuzzy name match highlighting (nice-to-have; not blocking)
### Follow-up Linear issues to create after this PR ships
- Unmerge UI (backend mutation + frontend undo button) — separate ticket
- Workspace-level merge stats page — separate ticket
Event Timeline
created