@open-mercato/core 0.5.1-develop.2912.8d7b1fef24 → 0.5.1-develop.2924.d13908516e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/dist/modules/customers/api/companies/[id]/people/route.js +12 -7
  2. package/dist/modules/customers/api/companies/[id]/people/route.js.map +2 -2
  3. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js +2 -1
  4. package/dist/modules/customers/backend/customers/companies-v2/[id]/page.js.map +2 -2
  5. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js +7 -2
  6. package/dist/modules/customers/backend/customers/people-v2/[id]/page.js.map +2 -2
  7. package/dist/modules/customers/commands/companies.js +93 -19
  8. package/dist/modules/customers/commands/companies.js.map +2 -2
  9. package/dist/modules/customers/commands/people.js +9 -1
  10. package/dist/modules/customers/commands/people.js.map +2 -2
  11. package/dist/modules/customers/commands/personCompanyLinks.js +2 -2
  12. package/dist/modules/customers/commands/personCompanyLinks.js.map +2 -2
  13. package/dist/modules/customers/components/detail/CompanyCard.js +32 -3
  14. package/dist/modules/customers/components/detail/CompanyCard.js.map +2 -2
  15. package/dist/modules/customers/components/detail/CompanyDetailTabs.js +37 -19
  16. package/dist/modules/customers/components/detail/CompanyDetailTabs.js.map +2 -2
  17. package/dist/modules/customers/components/detail/CompanyPeopleSection.js +7 -4
  18. package/dist/modules/customers/components/detail/CompanyPeopleSection.js.map +2 -2
  19. package/dist/modules/customers/components/detail/PersonCompaniesSection.js +63 -2
  20. package/dist/modules/customers/components/detail/PersonCompaniesSection.js.map +2 -2
  21. package/dist/modules/customers/components/detail/PersonDetailTabs.js +37 -19
  22. package/dist/modules/customers/components/detail/PersonDetailTabs.js.map +2 -2
  23. package/dist/modules/customers/components/detail/TasksSection.js +1 -11
  24. package/dist/modules/customers/components/detail/TasksSection.js.map +2 -2
  25. package/dist/modules/customers/components/formConfig.js +50 -39
  26. package/dist/modules/customers/components/formConfig.js.map +2 -2
  27. package/dist/modules/customers/events.js +3 -3
  28. package/dist/modules/customers/events.js.map +2 -2
  29. package/dist/modules/customers/lib/displayName.js +13 -1
  30. package/dist/modules/customers/lib/displayName.js.map +2 -2
  31. package/dist/modules/customers/lib/personCompanies.js +12 -7
  32. package/dist/modules/customers/lib/personCompanies.js.map +2 -2
  33. package/dist/modules/customers/lib/personCompanyLinkTable.js +5 -0
  34. package/dist/modules/customers/lib/personCompanyLinkTable.js.map +2 -2
  35. package/dist/modules/workflows/lib/activity-executor.js +21 -17
  36. package/dist/modules/workflows/lib/activity-executor.js.map +2 -2
  37. package/package.json +3 -3
  38. package/src/modules/customers/api/companies/[id]/people/route.ts +12 -7
  39. package/src/modules/customers/backend/customers/companies-v2/[id]/page.tsx +2 -1
  40. package/src/modules/customers/backend/customers/people-v2/[id]/page.tsx +12 -2
  41. package/src/modules/customers/commands/companies.ts +107 -19
  42. package/src/modules/customers/commands/people.ts +16 -1
  43. package/src/modules/customers/commands/personCompanyLinks.ts +3 -2
  44. package/src/modules/customers/components/detail/CompanyCard.tsx +28 -4
  45. package/src/modules/customers/components/detail/CompanyDetailTabs.tsx +18 -2
  46. package/src/modules/customers/components/detail/CompanyPeopleSection.tsx +8 -4
  47. package/src/modules/customers/components/detail/PersonCompaniesSection.tsx +66 -0
  48. package/src/modules/customers/components/detail/PersonDetailTabs.tsx +18 -2
  49. package/src/modules/customers/components/detail/TasksSection.tsx +1 -8
  50. package/src/modules/customers/components/formConfig.tsx +59 -40
  51. package/src/modules/customers/events.ts +3 -3
  52. package/src/modules/customers/i18n/de.json +10 -0
  53. package/src/modules/customers/i18n/en.json +10 -0
  54. package/src/modules/customers/i18n/es.json +10 -0
  55. package/src/modules/customers/i18n/pl.json +10 -0
  56. package/src/modules/customers/lib/displayName.ts +19 -0
  57. package/src/modules/customers/lib/personCompanies.ts +12 -7
  58. package/src/modules/customers/lib/personCompanyLinkTable.ts +14 -0
  59. package/src/modules/workflows/lib/activity-executor.ts +21 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/core",
3
- "version": "0.5.1-develop.2912.8d7b1fef24",
3
+ "version": "0.5.1-develop.2924.d13908516e",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "scripts": {
@@ -237,10 +237,10 @@
237
237
  "ts-pattern": "^5.0.0"
238
238
  },
239
239
  "peerDependencies": {
240
- "@open-mercato/shared": "0.5.1-develop.2912.8d7b1fef24"
240
+ "@open-mercato/shared": "0.5.1-develop.2924.d13908516e"
241
241
  },
242
242
  "devDependencies": {
243
- "@open-mercato/shared": "0.5.1-develop.2912.8d7b1fef24",
243
+ "@open-mercato/shared": "0.5.1-develop.2924.d13908516e",
244
244
  "@testing-library/dom": "^10.4.1",
245
245
  "@testing-library/jest-dom": "^6.9.1",
246
246
  "@testing-library/react": "^16.3.1",
@@ -13,7 +13,10 @@ import {
13
13
  CustomerPersonCompanyLink,
14
14
  CustomerPersonProfile,
15
15
  } from '../../../../data/entities'
16
- import { withActiveCustomerPersonCompanyLinkFilter } from '../../../../lib/personCompanyLinkTable'
16
+ import {
17
+ filterActivePersonCompanyLinks,
18
+ withActiveCustomerPersonCompanyLinkFilter,
19
+ } from '../../../../lib/personCompanyLinkTable'
17
20
 
18
21
  const paramsSchema = z.object({
19
22
  id: z.string().uuid(),
@@ -129,12 +132,14 @@ export async function GET(req: Request, ctx: { params?: { id?: string } }) {
129
132
  },
130
133
  'customers.companies.people.GET',
131
134
  )
132
- const links = await findWithDecryption(
133
- em,
134
- CustomerPersonCompanyLink,
135
- linkWhere,
136
- { populate: ['person'] },
137
- entityScope,
135
+ const links = filterActivePersonCompanyLinks(
136
+ await findWithDecryption(
137
+ em,
138
+ CustomerPersonCompanyLink,
139
+ linkWhere,
140
+ { populate: ['person'] },
141
+ entityScope,
142
+ ),
138
143
  )
139
144
 
140
145
  const personIds = links
@@ -255,7 +255,7 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
255
255
 
256
256
  // Section action (for tabs that expose add/create buttons)
257
257
  const handleSectionActionChange = React.useCallback((action: SectionAction | null) => {
258
- setSectionAction(action)
258
+ setSectionAction((prev) => (action !== null ? action : prev))
259
259
  }, [])
260
260
 
261
261
  const handleSectionAction = React.useCallback(() => {
@@ -420,6 +420,7 @@ export default function CompanyDetailV2Page({ params }: { params?: { id?: string
420
420
  peopleCount={data.counts?.people ?? 0}
421
421
  dealsCount={dealCount}
422
422
  activitiesCount={data.counts?.activities ?? 0}
423
+ sectionAction={sectionAction}
423
424
  >
424
425
  {activeTab === 'people' && (
425
426
  <CompanyPeopleSection
@@ -59,7 +59,6 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
59
59
 
60
60
  const formSchema = React.useMemo(() => createPersonEditSchema(), [])
61
61
  const fields = React.useMemo(() => createPersonEditFields(t), [t])
62
- const groups = React.useMemo(() => createPersonPersonalDataGroups(t), [t])
63
62
 
64
63
  const [data, setData] = React.useState<PersonOverview | null>(null)
65
64
  const [isLoading, setIsLoading] = React.useState(true)
@@ -101,6 +100,16 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
101
100
  ? data.person.displayName
102
101
  : t('customers.people.list.deleteFallbackName', 'this person')
103
102
 
103
+ const personDisplayNameForGroups =
104
+ typeof data?.person?.displayName === 'string' && data.person.displayName.trim().length
105
+ ? data.person.displayName.trim()
106
+ : null
107
+
108
+ const groups = React.useMemo(
109
+ () => createPersonPersonalDataGroups(t, { entityName: personDisplayNameForGroups }),
110
+ [t, personDisplayNameForGroups],
111
+ )
112
+
104
113
  const zoneSections = React.useMemo<ZoneSectionDescriptor[]>(() => [
105
114
  { id: 'personalData', icon: User, label: t('customers.people.form.groups.personalData', 'Personal data') },
106
115
  { id: 'companyRole', icon: Building2, label: t('customers.people.form.groups.companyRole', 'Company & role') },
@@ -249,7 +258,7 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
249
258
 
250
259
  // Section action (for tabs that expose add/create buttons)
251
260
  const handleSectionActionChange = React.useCallback((action: SectionAction | null) => {
252
- setSectionAction(action)
261
+ setSectionAction((prev) => (action !== null ? action : prev))
253
262
  }, [])
254
263
 
255
264
  React.useEffect(() => {
@@ -424,6 +433,7 @@ export default function PersonDetailV2Page({ params }: { params?: { id?: string
424
433
  dealsCount={dealCount}
425
434
  companiesCount={companyCount}
426
435
  tasksCount={todoCount}
436
+ sectionAction={sectionAction}
427
437
  >
428
438
  <div className="min-w-0">
429
439
  {(() => {
@@ -22,6 +22,7 @@ import {
22
22
  CustomerInteraction,
23
23
  CustomerTodoLink,
24
24
  CustomerEntity,
25
+ CustomerPersonCompanyLink,
25
26
  CustomerPersonProfile,
26
27
  CustomerTagAssignment,
27
28
  } from '../data/entities'
@@ -49,7 +50,7 @@ import {
49
50
  } from '@open-mercato/shared/lib/commands/customFieldSnapshots'
50
51
  import type { CrudIndexerConfig, CrudEventsConfig } from '@open-mercato/shared/lib/crud/types'
51
52
  import { E } from '#generated/entities.ids.generated'
52
- import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
53
+ import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
53
54
  import { CUSTOMER_ENTITY_ID } from '../lib/customFieldRouting'
54
55
  import { CustomFieldValue } from '@open-mercato/core/modules/entities/data/entities'
55
56
 
@@ -72,6 +73,41 @@ const companyCrudEvents: CrudEventsConfig<CustomerEntity> = {
72
73
  }),
73
74
  }
74
75
 
76
+ type CompanyDeleteBlockerCounts = {
77
+ personLinks: number
78
+ dealLinks: number
79
+ directPeople: number
80
+ }
81
+
82
+ function buildCompanyHasDependentsError(
83
+ translate: (key: string, fallback?: string, params?: Record<string, string | number>) => string,
84
+ counts: CompanyDeleteBlockerCounts,
85
+ ): CrudHttpError {
86
+ const blockers: string[] = []
87
+ if (counts.personLinks > 0) {
88
+ blockers.push(
89
+ translate('customers.companies.delete.blockers.persons', 'linked persons ({{count}})', { count: counts.personLinks }),
90
+ )
91
+ }
92
+ if (counts.dealLinks > 0) {
93
+ blockers.push(
94
+ translate('customers.companies.delete.blockers.deals', 'linked deals ({{count}})', { count: counts.dealLinks }),
95
+ )
96
+ }
97
+ if (counts.directPeople > 0) {
98
+ blockers.push(
99
+ translate('customers.companies.delete.blockers.directPeople', 'persons whose primary company is this one ({{count}})', { count: counts.directPeople }),
100
+ )
101
+ }
102
+ const summary = blockers.join(', ')
103
+ const message = translate(
104
+ 'customers.companies.delete.blocked',
105
+ 'Cannot delete company: {{blockers}}. Please unlink or reassign first.',
106
+ { blockers: summary },
107
+ )
108
+ return new CrudHttpError(422, { error: message, code: 'COMPANY_HAS_DEPENDENTS' })
109
+ }
110
+
75
111
  function companyEntityIndexEntry(entity: CustomerEntity): QueryIndexEventEntry {
76
112
  return {
77
113
  entityType: E.customers.customer_entity,
@@ -781,28 +817,80 @@ const deleteCompanyCommand: CommandHandler<{ body?: Record<string, unknown>; que
781
817
  },
782
818
  async execute(input, ctx) {
783
819
  const id = requireId(input, 'Company id required')
784
- const em = (ctx.container.resolve('em') as EntityManager).fork()
785
- const snapshot = await loadCompanySnapshot(em, id)
786
- const entity = await em.findOne(CustomerEntity, { id, deletedAt: null })
820
+ const baseEm = (ctx.container.resolve('em') as EntityManager).fork()
821
+ const snapshot = await loadCompanySnapshot(baseEm, id)
822
+ const entity = await baseEm.findOne(CustomerEntity, { id, deletedAt: null })
787
823
  const record = assertFound(entity, 'Company not found')
788
824
  ensureTenantScope(ctx, record.tenantId)
789
825
  ensureOrganizationScope(ctx, record.organizationId)
790
- const profile = await em.findOne(CustomerCompanyProfile, { entity: record })
791
- await em.nativeUpdate(CustomerPersonProfile, { company: record }, { company: null })
792
- await em.nativeDelete(CustomerDealCompanyLink, { company: record })
793
- await em.nativeDelete(CustomerActivity, { entity: record, organizationId: record.organizationId, tenantId: record.tenantId })
794
- await em.nativeDelete(CustomerInteraction, { entity: record, organizationId: record.organizationId, tenantId: record.tenantId })
795
- await em.nativeDelete(CustomerTodoLink, { entity: record, organizationId: record.organizationId, tenantId: record.tenantId })
796
- await em.nativeDelete(CustomerCompanyProfile, { entity: record, organizationId: record.organizationId, tenantId: record.tenantId })
797
- await em.nativeDelete(CustomerAddress, { entity: record, organizationId: record.organizationId, tenantId: record.tenantId })
798
- await em.nativeDelete(CustomerComment, { entity: record, organizationId: record.organizationId, tenantId: record.tenantId })
799
- await em.nativeDelete(CustomerTagAssignment, { entity: record, organizationId: record.organizationId, tenantId: record.tenantId })
800
- if (profile) {
801
- await em.nativeDelete(CustomFieldValue, { entityId: COMPANY_ENTITY_ID, recordId: profile.id })
826
+
827
+ const dependentScope = {
828
+ organizationId: record.organizationId,
829
+ tenantId: record.tenantId,
802
830
  }
803
- await em.nativeDelete(CustomFieldValue, { entityId: CUSTOMER_ENTITY_ID, recordId: record.id })
804
- em.remove(record)
805
- await em.flush()
831
+ const personLinks = await baseEm.count(CustomerPersonCompanyLink, {
832
+ company: record,
833
+ deletedAt: null,
834
+ ...dependentScope,
835
+ })
836
+ const dealLinks = await baseEm.count(CustomerDealCompanyLink, {
837
+ company: record,
838
+ })
839
+ const directPeople = await baseEm.count(CustomerPersonProfile, {
840
+ company: record,
841
+ ...dependentScope,
842
+ })
843
+ if (personLinks > 0 || dealLinks > 0 || directPeople > 0) {
844
+ const { translate } = await resolveTranslations()
845
+ throw buildCompanyHasDependentsError(translate, { personLinks, dealLinks, directPeople })
846
+ }
847
+
848
+ const profile = await baseEm.findOne(CustomerCompanyProfile, { entity: record })
849
+
850
+ await baseEm.transactional(async (em) => {
851
+ const recheckPersonLinks = await em.count(CustomerPersonCompanyLink, {
852
+ company: record,
853
+ deletedAt: null,
854
+ ...dependentScope,
855
+ })
856
+ const recheckDealLinks = await em.count(CustomerDealCompanyLink, {
857
+ company: record,
858
+ })
859
+ const recheckDirectPeople = await em.count(CustomerPersonProfile, {
860
+ company: record,
861
+ ...dependentScope,
862
+ })
863
+ if (recheckPersonLinks > 0 || recheckDealLinks > 0 || recheckDirectPeople > 0) {
864
+ const { translate } = await resolveTranslations()
865
+ throw buildCompanyHasDependentsError(translate, {
866
+ personLinks: recheckPersonLinks,
867
+ dealLinks: recheckDealLinks,
868
+ directPeople: recheckDirectPeople,
869
+ })
870
+ }
871
+
872
+ await em.nativeUpdate(CustomerPersonProfile, { company: record }, { company: null })
873
+ await em.nativeDelete(CustomerDealCompanyLink, { company: record })
874
+ await em.nativeDelete(CustomerActivity, { entity: record, organizationId: record.organizationId, tenantId: record.tenantId })
875
+ await em.nativeDelete(CustomerInteraction, { entity: record, organizationId: record.organizationId, tenantId: record.tenantId })
876
+ await em.nativeDelete(CustomerTodoLink, { entity: record, organizationId: record.organizationId, tenantId: record.tenantId })
877
+ await em.nativeDelete(CustomerCompanyProfile, { entity: record, organizationId: record.organizationId, tenantId: record.tenantId })
878
+ await em.nativeDelete(CustomerAddress, { entity: record, organizationId: record.organizationId, tenantId: record.tenantId })
879
+ await em.nativeDelete(CustomerComment, { entity: record, organizationId: record.organizationId, tenantId: record.tenantId })
880
+ await em.nativeDelete(CustomerTagAssignment, { entity: record, organizationId: record.organizationId, tenantId: record.tenantId })
881
+ if (profile) {
882
+ await em.nativeDelete(CustomFieldValue, { entityId: COMPANY_ENTITY_ID, recordId: profile.id })
883
+ }
884
+ await em.nativeDelete(CustomFieldValue, { entityId: CUSTOMER_ENTITY_ID, recordId: record.id })
885
+ const txEntity = await findOneWithDecryption(
886
+ em,
887
+ CustomerEntity,
888
+ { id: record.id },
889
+ undefined,
890
+ { tenantId: record.tenantId, organizationId: record.organizationId },
891
+ )
892
+ if (txEntity) em.remove(txEntity)
893
+ })
806
894
 
807
895
  const indexDeletes: QueryIndexEventEntry[] = []
808
896
  const memberUpserts: QueryIndexEventEntry[] = []
@@ -54,6 +54,7 @@ import {
54
54
  import type { CrudIndexerConfig, CrudEventsConfig } from '@open-mercato/shared/lib/crud/types'
55
55
  import { E } from '#generated/entities.ids.generated'
56
56
  import { findWithDecryption, findOneWithDecryption } from '@open-mercato/shared/lib/encryption/find'
57
+ import { deriveDisplayName, isDerivedDisplayName } from '../lib/displayName'
57
58
  import {
58
59
  loadPersonCompanyLinks,
59
60
  summarizePersonCompanies,
@@ -523,7 +524,10 @@ const createPersonCommand: CommandHandler<PersonCreateInput, { entityId: string;
523
524
  const timezone = normalizeOptionalString(parsed.timezone)
524
525
  const linkedInUrl = normalizeOptionalString(parsed.linkedInUrl)
525
526
  const twitterUrl = normalizeOptionalString(parsed.twitterUrl)
526
- const displayName = parsed.displayName?.trim() ?? ''
527
+ const displayNameInput = parsed.displayName?.trim() ?? ''
528
+ const displayName = displayNameInput.length > 0
529
+ ? displayNameInput
530
+ : deriveDisplayName(firstName, lastName)
527
531
  const nextInteractionName = parsed.nextInteraction?.name ? parsed.nextInteraction.name.trim() : null
528
532
  const nextInteractionRefId = normalizeOptionalString(parsed.nextInteraction?.refId)
529
533
  const nextInteractionIcon = normalizeOptionalString(parsed.nextInteraction?.icon)
@@ -715,6 +719,17 @@ const updatePersonCommand: CommandHandler<PersonUpdateInput, { entityId: string
715
719
  }
716
720
  }
717
721
 
722
+ if (
723
+ parsed.displayName === undefined
724
+ && (parsed.firstName !== undefined || parsed.lastName !== undefined)
725
+ && isDerivedDisplayName(record.displayName, profile.firstName, profile.lastName)
726
+ ) {
727
+ const nextFirst = parsed.firstName !== undefined ? parsed.firstName : profile.firstName
728
+ const nextLast = parsed.lastName !== undefined ? parsed.lastName : profile.lastName
729
+ const derived = deriveDisplayName(nextFirst, nextLast)
730
+ if (derived.length > 0) parsed.displayName = derived
731
+ }
732
+
718
733
  await withAtomicFlush(em, [
719
734
  () => {
720
735
  if (parsed.description !== undefined) record.description = normalizeOptionalString(parsed.description)
@@ -535,14 +535,15 @@ const deletePersonCompanyLinkCommand: CommandHandler<PersonCompanyLinkDeleteInpu
535
535
  const profile = await requirePersonProfile(em, person)
536
536
  const linkWasPrimary = link.isPrimary
537
537
 
538
+ const existingLinks = await loadPersonCompanyLinks(em, person)
539
+ const remainingLinks = existingLinks.filter((entry) => entry.id !== link.id)
540
+
538
541
  await withAtomicFlush(em, [
539
542
  () => {
540
543
  link.isPrimary = false
541
544
  link.deletedAt = new Date()
542
545
  },
543
546
  async () => {
544
- const existingLinks = await loadPersonCompanyLinks(em, person)
545
- const remainingLinks = existingLinks.filter((entry) => entry.id !== link.id)
546
547
  if (linkWasPrimary) {
547
548
  await promoteFallbackPrimaryLink(em, person, profile, remainingLinks, companyId)
548
549
  } else if (profile.company && typeof profile.company !== 'string' && profile.company.id === companyId) {
@@ -10,6 +10,7 @@ import {
10
10
  ExternalLink,
11
11
  FileText,
12
12
  Hash,
13
+ Link2Off,
13
14
  MapPin,
14
15
  TrendingUp,
15
16
  Zap,
@@ -17,6 +18,7 @@ import {
17
18
  Users,
18
19
  } from 'lucide-react'
19
20
  import { Badge } from '@open-mercato/ui/primitives/badge'
21
+ import { StatusBadge } from '@open-mercato/ui/primitives/status-badge'
20
22
  import { IconButton } from '@open-mercato/ui/primitives/icon-button'
21
23
  import { useT } from '@open-mercato/shared/lib/i18n/context'
22
24
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
@@ -67,6 +69,9 @@ type CompanyCardProps = {
67
69
  temperatureMap?: CustomerDictionaryMap | undefined
68
70
  renewalQuarterMap?: CustomerDictionaryMap | undefined
69
71
  roleMap?: CustomerDictionaryMap | undefined
72
+ onUnlink?: () => void | Promise<void>
73
+ unlinkLabel?: string
74
+ unlinkDisabled?: boolean
70
75
  }
71
76
 
72
77
  function copyToClipboard(text: string, t: ReturnType<typeof useT>) {
@@ -208,8 +213,12 @@ export function CompanyCard({
208
213
  temperatureMap,
209
214
  renewalQuarterMap,
210
215
  roleMap,
216
+ onUnlink,
217
+ unlinkLabel,
218
+ unlinkDisabled,
211
219
  }: CompanyCardProps) {
212
220
  const t = useT()
221
+ const resolvedUnlinkLabel = unlinkLabel ?? t('customers.people.detail.companies.unlinkAction', 'Unlink')
213
222
 
214
223
  const hasBillingSection =
215
224
  data.profile?.legalName ||
@@ -236,9 +245,12 @@ export function CompanyCard({
236
245
  <div className="flex flex-wrap items-center gap-2">
237
246
  <span className="break-words text-base font-bold">{data.displayName}</span>
238
247
  {data.isPrimary && (
239
- <Badge variant="default" className="rounded-sm px-1.5 py-0 text-overline font-bold uppercase tracking-wider">
240
- PRIMARY
241
- </Badge>
248
+ <StatusBadge
249
+ variant="info"
250
+ className="rounded-sm px-1.5 py-0 text-overline font-bold uppercase tracking-wider"
251
+ >
252
+ {t('customers.people.detail.companies.primaryBadge', 'Primary')}
253
+ </StatusBadge>
242
254
  )}
243
255
  </div>
244
256
  {data.subtitle && (
@@ -246,7 +258,19 @@ export function CompanyCard({
246
258
  )}
247
259
  </div>
248
260
  </div>
249
- <div className="self-start sm:self-auto">
261
+ <div className="flex items-center gap-1 self-start sm:self-auto">
262
+ {onUnlink ? (
263
+ <IconButton
264
+ variant="ghost"
265
+ size="sm"
266
+ type="button"
267
+ aria-label={resolvedUnlinkLabel}
268
+ disabled={unlinkDisabled}
269
+ onClick={() => { void onUnlink() }}
270
+ >
271
+ <Link2Off className="size-4" />
272
+ </IconButton>
273
+ ) : null}
250
274
  <Link href={`/backend/customers/companies-v2/${data.companyId}`}>
251
275
  <IconButton variant="ghost" size="sm" type="button">
252
276
  <ExternalLink className="size-4" />
@@ -11,7 +11,9 @@ import {
11
11
  Clock,
12
12
  History,
13
13
  Paperclip,
14
+ Plus,
14
15
  } from 'lucide-react'
16
+ import type { SectionAction } from '@open-mercato/ui/backend/detail'
15
17
 
16
18
  export type CompanyTabId =
17
19
  | 'people'
@@ -36,6 +38,7 @@ type CompanyDetailTabsProps = {
36
38
  dealsCount?: number
37
39
  activitiesCount?: number
38
40
  filesCount?: number
41
+ sectionAction?: SectionAction | null
39
42
  children: React.ReactNode
40
43
  }
41
44
 
@@ -80,6 +83,7 @@ export function CompanyDetailTabs({
80
83
  dealsCount = 0,
81
84
  activitiesCount = 0,
82
85
  filesCount = 0,
86
+ sectionAction = null,
83
87
  children,
84
88
  }: CompanyDetailTabsProps) {
85
89
  const t = useT()
@@ -134,8 +138,8 @@ export function CompanyDetailTabs({
134
138
  return (
135
139
  <div>
136
140
  {/* Tab navigation */}
137
- <div className="border-b" role="tablist" aria-label={t('customers.companies.detail.tabs.label', 'Company detail sections')}>
138
- <nav className="-mb-px flex gap-1 overflow-x-auto px-1">
141
+ <div className="flex items-end justify-between gap-2 border-b" role="tablist" aria-label={t('customers.companies.detail.tabs.label', 'Company detail sections')}>
142
+ <nav className="-mb-px flex flex-1 gap-1 overflow-x-auto px-1">
139
143
  {allTabs.map((tab) => {
140
144
  const isActive = activeTab === tab.id
141
145
  return (
@@ -161,6 +165,18 @@ export function CompanyDetailTabs({
161
165
  )
162
166
  })}
163
167
  </nav>
168
+ {sectionAction ? (
169
+ <Button
170
+ type="button"
171
+ size="sm"
172
+ onClick={sectionAction.onClick}
173
+ disabled={sectionAction.disabled}
174
+ className="mb-1.5 mr-1 shrink-0"
175
+ >
176
+ <Plus className="mr-1.5 h-4 w-4" />
177
+ {sectionAction.label}
178
+ </Button>
179
+ ) : null}
164
180
  </div>
165
181
 
166
182
  {/* Tab content */}
@@ -13,6 +13,7 @@ import {
13
13
  } from '@open-mercato/shared/lib/browser/safeLocalStorage'
14
14
  import { useT } from '@open-mercato/shared/lib/i18n/context'
15
15
  import { createTranslatorWithFallback } from '@open-mercato/shared/lib/i18n/translate'
16
+ import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent'
16
17
  import type { SectionAction, TabEmptyStateConfig, Translator } from './types'
17
18
  import { CreatePersonDialog } from './CreatePersonDialog'
18
19
  import { PersonCard } from './PersonCard'
@@ -319,6 +320,13 @@ export function CompanyPeopleSection({
319
320
  void loadVisiblePeople()
320
321
  }, [loadVisiblePeople])
321
322
 
323
+ useAppEvent('customers.person_company_link.deleted', (event) => {
324
+ const payload = event.payload as { companyEntityId?: string | null } | null | undefined
325
+ if (payload && payload.companyEntityId === companyId) {
326
+ void loadVisiblePeople()
327
+ }
328
+ }, [companyId, loadVisiblePeople])
329
+
322
330
  React.useEffect(() => {
323
331
  setListPage(1)
324
332
  }, [searchQuery, sortMode])
@@ -520,9 +528,6 @@ export function CompanyPeopleSection({
520
528
  companyId,
521
529
  },
522
530
  )
523
- applyPeopleChange((current) => current.filter((entry) => entry.id !== personId))
524
- setVisiblePeople((current) => current.filter((entry) => entry.id !== personId))
525
- setListTotalCount((current) => Math.max(0, current - 1))
526
531
  await loadVisiblePeople()
527
532
  flash(
528
533
  translate(
@@ -546,7 +551,6 @@ export function CompanyPeopleSection({
546
551
  }
547
552
  },
548
553
  [
549
- applyPeopleChange,
550
554
  companyId,
551
555
  loadVisiblePeople,
552
556
  onLoadingChange,
@@ -7,6 +7,8 @@ import { flash } from '@open-mercato/ui/backend/FlashMessages'
7
7
  import { apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
8
8
  import { Button } from '@open-mercato/ui/primitives/button'
9
9
  import { Input } from '@open-mercato/ui/primitives/input'
10
+ import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
11
+ import { useAppEvent } from '@open-mercato/ui/backend/injection/useAppEvent'
10
12
  import { CompanyCard, type EnrichedCompanyData } from './CompanyCard'
11
13
  import { useCustomerDictionary } from './hooks/useCustomerDictionary'
12
14
  import { LinkEntityDialog, type LinkEntityOption } from '../linking/LinkEntityDialog'
@@ -88,8 +90,10 @@ export function PersonCompaniesSection({
88
90
  runGuardedMutation,
89
91
  }: PersonCompaniesSectionProps) {
90
92
  const t = useT()
93
+ const { confirm, ConfirmDialogElement } = useConfirmDialog()
91
94
  const [items, setItems] = React.useState<EnrichedCompanyData[]>([])
92
95
  const [loading, setLoading] = React.useState(true)
96
+ const [unlinkingId, setUnlinkingId] = React.useState<string | null>(null)
93
97
  const [search, setSearch] = React.useState('')
94
98
  const [sort, setSort] = React.useState<'name-asc' | 'name-desc' | 'recent'>('name-asc')
95
99
  const [page, setPage] = React.useState(1)
@@ -309,6 +313,64 @@ export function PersonCompaniesSection({
309
313
  [linkedCompanies, linkedPrimaryId, loadData, onChanged, personId, runWriteMutation, t],
310
314
  )
311
315
 
316
+ const handleUnlink = React.useCallback(
317
+ async (companyId: string, displayName: string) => {
318
+ if (!companyId || unlinkingId) return
319
+ const confirmed = await confirm({
320
+ title: t('customers.people.detail.companies.unlinkConfirmTitle', 'Unlink company'),
321
+ description: t(
322
+ 'customers.people.detail.companies.unlinkConfirm',
323
+ 'Unlink {{company}} from {{person}}?',
324
+ { company: displayName, person: _personName },
325
+ ),
326
+ confirmText: t('customers.people.detail.companies.unlinkAction', 'Unlink'),
327
+ cancelText: t('customers.linking.actions.cancel', 'Cancel'),
328
+ })
329
+ if (!confirmed) return
330
+ setUnlinkingId(companyId)
331
+ try {
332
+ await runWriteMutation(
333
+ () =>
334
+ apiCallOrThrow(
335
+ `/api/customers/people/${encodeURIComponent(personId)}/companies/${encodeURIComponent(companyId)}`,
336
+ { method: 'DELETE' },
337
+ {
338
+ errorMessage: t(
339
+ 'customers.people.detail.companies.unlinkError',
340
+ 'Failed to unlink company.',
341
+ ),
342
+ },
343
+ ),
344
+ { companyId, personId, operation: 'unlinkPersonCompanyLink' },
345
+ )
346
+ await loadData({ showLoading: false })
347
+ await onChanged?.()
348
+ flash(
349
+ t('customers.people.detail.companies.unlinkSuccess', 'Company unlinked.'),
350
+ 'success',
351
+ )
352
+ } catch (error) {
353
+ const message = error instanceof Error
354
+ ? error.message
355
+ : t(
356
+ 'customers.people.detail.companies.unlinkError',
357
+ 'Failed to unlink company.',
358
+ )
359
+ flash(message, 'error')
360
+ } finally {
361
+ setUnlinkingId(null)
362
+ }
363
+ },
364
+ [_personName, confirm, loadData, onChanged, personId, runWriteMutation, t, unlinkingId],
365
+ )
366
+
367
+ useAppEvent('customers.person_company_link.deleted', (event) => {
368
+ const payload = event.payload as { personEntityId?: string | null } | null | undefined
369
+ if (payload && payload.personEntityId === personId) {
370
+ void loadData({ showLoading: false })
371
+ }
372
+ }, [personId, loadData])
373
+
312
374
  return (
313
375
  <>
314
376
  <div className="space-y-4">
@@ -399,6 +461,9 @@ export function PersonCompaniesSection({
399
461
  temperatureMap={temperatureDict?.map}
400
462
  renewalQuarterMap={renewalQuarterDict?.map}
401
463
  roleMap={roleDict?.map}
464
+ onUnlink={() => handleUnlink(item.companyId, item.displayName)}
465
+ unlinkLabel={t('customers.people.detail.companies.unlinkAction', 'Unlink')}
466
+ unlinkDisabled={unlinkingId === item.companyId}
402
467
  />
403
468
  ))}
404
469
  </div>
@@ -417,6 +482,7 @@ export function PersonCompaniesSection({
417
482
  onConfirm={handleLinkConfirm}
418
483
  runGuardedMutation={runWriteMutation}
419
484
  />
485
+ {ConfirmDialogElement}
420
486
  </>
421
487
  )
422
488
  }
@@ -11,7 +11,9 @@ import {
11
11
  Check,
12
12
  History,
13
13
  Paperclip,
14
+ Plus,
14
15
  } from 'lucide-react'
16
+ import type { SectionAction } from '@open-mercato/ui/backend/detail'
15
17
 
16
18
  export type PersonTabId =
17
19
  | 'activities'
@@ -38,6 +40,7 @@ type PersonDetailTabsProps = {
38
40
  companiesCount?: number
39
41
  tasksCount?: number
40
42
  filesCount?: number
43
+ sectionAction?: SectionAction | null
41
44
  children: React.ReactNode
42
45
  }
43
46
 
@@ -74,6 +77,7 @@ export function PersonDetailTabs({
74
77
  companiesCount = 0,
75
78
  tasksCount = 0,
76
79
  filesCount = 0,
80
+ sectionAction = null,
77
81
  children,
78
82
  }: PersonDetailTabsProps) {
79
83
  const t = useT()
@@ -134,8 +138,8 @@ export function PersonDetailTabs({
134
138
  return (
135
139
  <div>
136
140
  {/* Tab navigation — full width above both zones */}
137
- <div className="border-b" role="tablist" aria-label={t('customers.people.detail.tabs.label', 'Person detail sections')}>
138
- <nav className="-mb-px flex gap-1 overflow-x-auto px-1">
141
+ <div className="flex items-end justify-between gap-2 border-b" role="tablist" aria-label={t('customers.people.detail.tabs.label', 'Person detail sections')}>
142
+ <nav className="-mb-px flex flex-1 gap-1 overflow-x-auto px-1">
139
143
  {allTabs.map((tab) => {
140
144
  const isActive = activeTab === tab.id
141
145
  return (
@@ -161,6 +165,18 @@ export function PersonDetailTabs({
161
165
  )
162
166
  })}
163
167
  </nav>
168
+ {sectionAction ? (
169
+ <Button
170
+ type="button"
171
+ size="sm"
172
+ onClick={sectionAction.onClick}
173
+ disabled={sectionAction.disabled}
174
+ className="mb-1.5 mr-1 shrink-0"
175
+ >
176
+ <Plus className="mr-1.5 h-4 w-4" />
177
+ {sectionAction.label}
178
+ </Button>
179
+ ) : null}
164
180
  </div>
165
181
 
166
182
  {/* Two-column content below tabs */}
@@ -431,14 +431,7 @@ export function TasksSection({
431
431
  ) : null}
432
432
 
433
433
  {!isInitialLoading && !hasTasks ? (
434
- <TabEmptyState
435
- title={emptyState.title}
436
- action={{
437
- label: emptyState.actionLabel,
438
- onClick: openCreateDialog,
439
- disabled: isMutating || !entityId,
440
- }}
441
- />
434
+ <TabEmptyState title={emptyState.title} />
442
435
  ) : null}
443
436
 
444
437
  {!isInitialLoading && hasTasks ? (