Merge Profile Dialog
Merge Profile Dialog
Overview
The MergeProfileDialog component provides a reusable modal interface for merging duplicate profiles across the CRM system. It supports donors, volunteers, prospects, and contacts, allowing users to consolidate duplicate records while preserving all associated data.
Mounting rules (critical)
The dialog must be mounted in the same React tree as the control that opens it (open={mergeDialogOpen}). If a parent screen uses an early `return` for one view (e.g. donor profile vs list), do not render MergeProfileDialog only in the other branch — clicking Merge will set state but no dialog appears. DonorsCRM.tsx renders this dialog inside the profile branch and resets mergeDialogOpen when navigating back to the list.
Component File
src/components/shared/MergeProfileDialog.tsx
Features
- Universal Profile Support: Works with donors, volunteers, prospects, and contacts
- Two-Step Confirmation: Search/select target profile, then confirm merge
- Data Maximization: Fills missing fields on target from source profile
- Related Data Transfer: Automatically transfers all associated records
- Notes Merging: Appends source notes to target with merge timestamp
- Tags Deduplication: Merges and deduplicates tags arrays
- Dark Mode Styling: Follows the styling guide with dark button variant
UI Components
Step 1: Profile Selection
- Source Profile Card: Displays the profile to be deleted (red styling)
- Search Input: Debounced search for target profiles
- Search Results: Scrollable list of matching profiles
- Target Profile Card: Displays selected target profile (primary styling)
Step 2: Confirmation
- Warning Banner: Amber-colored alert explaining the irreversible action
- Side-by-Side Comparison: Source and target profiles with arrow
- Transfer Summary: List of data that will be transferred
- Confirm Button: Destructive variant for final confirmation
Props
| Prop | Type | Description |
|------|------|-------------|
| `open` | `boolean` | Controls dialog visibility |
| `onOpenChange` | `(open: boolean) => void` | Callback when dialog state changes |
| `profileType` | `'donor' \| 'volunteer' \| 'prospect' \| 'contact'` | Type of profile being merged |
| `sourceProfile` | `MergeableProfile` | The profile to be deleted |
| `searchProfiles` | `(query: string, excludeId: string) => Promise<MergeableProfile[]>` | Search function for finding target profiles |
| `onMerge` | `(sourceId: string, targetId: string) => Promise<void>` | Callback to execute the merge |
| `getProfileStats` | `(profile: MergeableProfile) => { label: string; value: string }[]` | Optional function to display profile stats |MergeableProfile Interface
interface MergeableProfile {
id: string;
firstName: string;
lastName: string;
email?: string;
phone?: string;
address?: string;
organizationId?: string;
organizationName?: string;
totalDonations?: number;
donationCount?: number;
totalHours?: number;
lastActivity?: string;
createdAt?: string;
type?: string;
status?: string;
}Database Functions
Located in src/lib/db.ts:
Merge Functions
| Function | Description |
|----------|-------------|
| `mergeDonors(sourceId, targetId)` | Merges donor profiles with donations, subscriptions, refunds, payment methods |
| `mergeVolunteers(sourceId, targetId)` | Merges volunteer profiles with hour records |
| `mergeProspects(sourceId, targetId)` | Merges prospect profiles with email history |
| `mergeContacts(sourceId, targetId)` | Merges contact profiles with email history |
Search Functions
| Function | Description |
|----------|-------------|
| `searchDonorsForMerge(query, excludeId, entityId)` | Search donors excluding source |
| `searchVolunteersForMerge(query, excludeId, entityId)` | Search volunteers excluding source |
| `searchProspectsForMerge(query, excludeId, entityId)` | Search prospects excluding source |
| `searchContactsForMerge(query, excludeId, entityId)` | Search contacts excluding source |
Data Transfer Details
Donors
- Donations: All donation records transferred to target
- Subscriptions: All recurring subscriptions transferred
- Refunds: All refund records transferred
- Payment Methods: All saved payment methods transferred
- Email Sends: Email history transferred
- Donor Portal Users: Source portal user deleted (can't have duplicates)
- Profile Fields: Missing email, phone, address, company filled from source
- Notes: Source notes appended with merge timestamp
- Tags: Merged and deduplicated
- Totals:
total_donatedanddonation_countsummed
Volunteers
- Volunteer Hours: All hour records transferred
- Profile Fields: Missing email, phone, address, emergency contact filled from source
- Skills: Arrays merged and deduplicated
- Notes: Source notes appended with merge timestamp
- Totals:
total_hourssummed
Prospects
- Email Sends: Email history transferred by recipient email
- Profile Fields: Missing email, phone, source filled from source
- Notes: Source notes appended with merge timestamp
Contacts
- Email Sends: Email history transferred by recipient email
- Profile Fields: Missing email, phone, structured address fields (
address_line1,address_line2,city,state,zip_code,country), company, andjob_titlefilled from source - Notes: Source notes appended with merge timestamp
- Tags: Merged and deduplicated
Integration
DonorsCRM
<MergeProfileDialog
open={mergeDialogOpen}
onOpenChange={setMergeDialogOpen}
profileType="donor"
sourceProfile={{
id: donorProfile.id,
firstName: donorProfile.name.split(' ')[0] || '',
lastName: donorProfile.name.split(' ').slice(1).join(' ') || '',
email: donorProfile.email || undefined,
phone: donorProfile.phone || undefined,
address: donorProfile.address || undefined,
totalDonations: donorProfile.totalLifetimeDonations,
donationCount: donorProfile.donationCount,
}}
searchProfiles={async (query, excludeId) => {
return searchDonorsForMerge(query, excludeId, selectedEntity);
}}
onMerge={async (sourceId, targetId) => {
await mergeDonors(sourceId, targetId);
queryClient.invalidateQueries({ queryKey: ['donors'] });
setView('list');
setSelectedDonor(null);
}}
getProfileStats={(profile) => [
{ label: 'Total Donated', value: `$${(profile.totalDonations || 0).toLocaleString()}` },
{ label: 'Donations', value: (profile.donationCount || 0).toString() },
]}
/>Button Styling
The Merge button uses dark styling per the STYLING-GUIDE.md:
<Button
variant="secondary"
className="bg-slate-700 hover:bg-slate-600 text-white dark:bg-slate-600 dark:hover:bg-slate-500"
onClick={() => setMergeDialogOpen(true)}
>
<Merge className="h-4 w-4 mr-2" />
Merge
</Button>User Flow
1. User navigates to a profile view (donor, volunteer, prospect, or contact) 2. User clicks the "Merge" button (dark styled, below Edit button) 3. Dialog opens showing the source profile on the left 4. User searches for the target profile using the search input 5. User selects a target profile from the search results 6. User clicks "Continue to Confirm" 7. Confirmation step shows warning and data transfer summary 8. User clicks "Confirm Merge" to execute 9. All data is transferred, source profile is deleted 10. User is returned to the list view with success toast
Error Handling
- Search errors display toast notification
- Merge errors display toast notification with error message
- Loading states shown during search and merge operations
- Validation prevents merge without target selection
Accessibility
- Keyboard navigation support via Dialog component
- Focus management on dialog open/close
- Screen reader friendly labels and descriptions
- Loading indicators for async operations
Related Documentation
Synced from IFMmvp-Frontend documentation: pages/components/19-MERGE-PROFILE-DIALOG.md
Ready to Get Started?
See how Alignmint can simplify your nonprofit's operations. Schedule a free demo with our team and we'll walk you through everything.
Questions? Email us at steven@getalignmint.org