Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
558 changes: 557 additions & 1 deletion packages/core/records.ts

Large diffs are not rendered by default.

21 changes: 20 additions & 1 deletion packages/i18n/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2452,5 +2452,24 @@
"edit": "Edit action reason",
"original": "Original action reason",
"hint": "Replace imported JSON with readable notes after reviewing the source. The original is kept, and editing clears any message approval."
}
},
"merge": "Merge",
"mergeDuplicate": "Merge duplicate…",
"mergePerson": "Merge person",
"mergeCompany": "Merge company",
"mergeRecords": "Merge records",
"mergeRecordsSubtitle": "Review conflicting fields and pick which values to keep.",
"recordsMerged": "Records merged",
"canonicalRecord": "Canonical record (kept)",
"duplicateRecord": "Duplicate record (merged & removed)",
"selectDuplicateRecord": "Select record to merge…",
"swapCanonical": "Swap canonical record",
"mergeNotice": "Relationships, actions, conversations, meetings, and deals from both records will be moved to the canonical record. Private conversations remain private to their owners. The duplicate record will be removed.",
"mergeFieldConflict": "Conflicting field",
"mergeUseLeft": "Keep canonical value",
"mergeUseRight": "Use duplicate value",
"mergeSubmit": "Confirm & merge",
"mergeSelectPrompt": "Choose a duplicate record to merge into this one",
"cannotMergeSelf": "Cannot merge a record into itself",
"searchRecordPlaceholder": "Type to search records…"
}
11 changes: 11 additions & 0 deletions packages/operations/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ import {
companyArchiveSchema,
companySchema,
meetingSchema,
mergeRecordsSchema,
opportunityChangeSchema,
opportunityCreateSchema,
opportunityRemovalSchema,
Expand Down Expand Up @@ -1244,6 +1245,16 @@ export const operations: Operation[] = [
schema: companyArchiveSchema,
run: (c, input) => records(c).archiveCompany(c.principal, input),
}),
operation({
api: "crm",
method: "POST",
operation: "merge-records",
name: "merge_records",
description:
"Merge duplicate person or company records into a single canonical record within the same organization. Moves relationships, conversations, actions, meetings, and deals atomically inside one transaction. Overwrites canonical fields with chosen values. Preserves private conversation ownership and records the merge in change_events.",
schema: mergeRecordsSchema,
run: (c, input) => records(c).mergeRecords(c.principal, input),
}),
operation({
api: "crm",
method: "POST",
Expand Down
160 changes: 160 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -6279,3 +6279,163 @@ nav.list-pagination > label:has(select) {
flex: 1 1 200px;
}
}

.merge-dialog {
width: min(760px, 92vw);
max-height: 88vh;
overflow-y: auto;
}
.merge-dialog-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.merge-dialog-header h2 {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
}
.merge-select-step {
display: flex;
flex-direction: column;
gap: 12px;
}
.candidate-list {
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
max-height: 260px;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.candidate-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
background: transparent;
border: none;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
text-align: left;
transition: background var(--duration-fast);
}
.candidate-item:last-child {
border-bottom: none;
}
.candidate-item:hover {
background: var(--surface-hover);
}
.candidate-name {
font-weight: 500;
}
.empty-candidates {
padding: 16px;
text-align: center;
margin: 0;
}
.merge-records-header {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 12px;
align-items: center;
margin-bottom: 12px;
}
.record-header-card {
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 12px;
background: var(--surface);
display: flex;
flex-direction: column;
gap: 6px;
}
.record-header-card h3 {
margin: 0;
font-size: var(--text-base);
word-break: break-word;
}
.swap-button {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: var(--text-dense);
white-space: nowrap;
}
.change-candidate-btn {
align-self: flex-start;
font-size: var(--text-dense);
padding: 0;
text-decoration: underline;
cursor: pointer;
}
.field-comparisons-table {
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
overflow: hidden;
display: flex;
flex-direction: column;
}
.comparison-row {
display: grid;
grid-template-columns: 150px 1fr;
border-bottom: 1px solid var(--border-color);
padding: 8px 12px;
align-items: center;
gap: 12px;
}
.comparison-row:last-child {
border-bottom: none;
}
.comparison-row.conflict {
background: var(--surface-subtle);
}
.field-label {
display: flex;
flex-direction: column;
gap: 2px;
font-size: var(--text-dense);
font-weight: 500;
}
.conflict-tag {
font-size: 10px;
text-transform: uppercase;
color: var(--amber);
font-weight: 600;
letter-spacing: 0.05em;
}
.field-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.field-option {
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: 6px 10px;
display: flex;
align-items: center;
gap: 6px;
background: var(--surface);
color: inherit;
font-size: var(--text-dense);
cursor: pointer;
text-align: left;
transition: all var(--duration-fast);
}
.field-option.selected {
border-color: var(--color-brand);
background: var(--brand-wash);
font-weight: 500;
}
.option-value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.merge-notice {
font-size: var(--text-dense);
line-height: 1.5;
margin-top: 12px;
}
44 changes: 44 additions & 0 deletions src/components/crm-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,50 @@ function CrmShell({ children }: { children: ReactNode }) {
crm.setPersonDialog(true);
},
},
...(section === "people" &&
(recordId || crm.selection.selected[0] || paletteFocus.current)
? [
{
id: "merge-person",
title: t.mergePerson,
shortcut: "",
run: () => {
const target =
recordId ||
crm.selection.selected[0] ||
paletteFocus.current;
if (target)
crm.openRecordDialog({
kind: "merge",
id: target,
entity: "person",
});
},
},
]
: []),
...(section === "companies" &&
(recordId || crm.selection.selected[0] || paletteFocus.current)
? [
{
id: "merge-company",
title: t.mergeCompany,
shortcut: "",
run: () => {
const target =
recordId ||
crm.selection.selected[0] ||
paletteFocus.current;
if (target)
crm.openRecordDialog({
kind: "merge",
id: target,
entity: "company",
});
},
},
]
: []),
...(section === "sequences" || outreachTab === "sequences"
? [
{
Expand Down
6 changes: 4 additions & 2 deletions src/components/crm/crm-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,10 @@ export type ViewVerb =
| "list-layout";
export type ViewVerbs = Partial<Record<ViewVerb, () => boolean>>;
export interface RecordDialogState {
kind: "person" | "company" | "meeting" | "opportunity";
kind: "person" | "company" | "meeting" | "opportunity" | "merge";
id?: string;
relationshipId?: string;
entity?: "person" | "company";
}
interface Origin {
path: string;
Expand Down Expand Up @@ -794,7 +795,8 @@ function useCrmState({
}

export type Crm = ReturnType<typeof useCrmState>;
const CrmContext = createContext<Crm | null>(null);
export type CrmContextValue = Crm;
export const CrmContext = createContext<Crm | null>(null);

function CrmState({ children, ...props }: CrmProps & { children: ReactNode }) {
const crm = useCrmState(props);
Expand Down
7 changes: 7 additions & 0 deletions src/components/records/company-record.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ export function CompanyRecord({ companyId }: { companyId: string }) {
crm.openRecordDialog({ kind: "company", id: active.id })
}
onArchive={() => void archive.archive("company", active)}
onMerge={() =>
crm.openRecordDialog({
kind: "merge",
id: active.id,
entity: "company",
})
}
/>
) : (
<ArchivedNotice
Expand Down
Loading
Loading