@open-mercato/core 0.5.1-develop.2917.31ee9898e3 → 0.5.1-develop.2935.357c9db339

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/integrations/data/validators.js +1 -1
  36. package/dist/modules/integrations/data/validators.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/integrations/data/validators.ts +1 -1
@@ -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 ? (
@@ -7,6 +7,7 @@ import { Check, Pencil, Plus, Settings } from 'lucide-react'
7
7
  import { useT } from '@open-mercato/shared/lib/i18n/context'
8
8
  import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
9
9
  import { Button } from '@open-mercato/ui/primitives/button'
10
+ import { deriveDisplayName, isDerivedDisplayName } from '../lib/displayName'
10
11
  import {
11
12
  Dialog,
12
13
  DialogContent,
@@ -703,16 +704,18 @@ export const createDisplayNameSection = (t: Translator) =>
703
704
  function DisplayNameSection({ values, setValue, errors }: CrudFormGroupComponentProps) {
704
705
  const [editing, setEditing] = React.useState(false)
705
706
  const [manualOverride, setManualOverride] = React.useState(() => {
706
- const current = typeof values.displayName === 'string' ? values.displayName.trim() : ''
707
- return current.length > 0
707
+ const current = typeof values.displayName === 'string' ? values.displayName : ''
708
+ const firstInit = typeof values.firstName === 'string' ? values.firstName : ''
709
+ const lastInit = typeof values.lastName === 'string' ? values.lastName : ''
710
+ // Sticky-manual: treat as user-customized only when the persisted display name
711
+ // doesn't match the first+last derivation (matches the server-side rule in
712
+ // updatePersonCommand). Empty values are considered derived.
713
+ return !isDerivedDisplayName(current, firstInit, lastInit)
708
714
  })
709
715
 
710
716
  const first = typeof values.firstName === 'string' ? values.firstName.trim() : ''
711
717
  const last = typeof values.lastName === 'string' ? values.lastName.trim() : ''
712
- const derived = React.useMemo(() => {
713
- const parts = [first, last].filter((part) => !!part)
714
- return parts.join(' ').trim()
715
- }, [first, last])
718
+ const derived = React.useMemo(() => deriveDisplayName(first, last), [first, last])
716
719
 
717
720
  React.useEffect(() => {
718
721
  if (!manualOverride) {
@@ -1684,40 +1687,56 @@ export const createPersonEditGroups = (t: Translator): CrudFormGroup[] => [
1684
1687
  * Groups for the Person v2 "Dane osobowe" Figma layout (SPEC-048 mockup).
1685
1688
  * All groups in column 1 (Zone 1). Notes handled separately in Zone 2 tabs.
1686
1689
  */
1687
- export const createPersonPersonalDataGroups = (t: Translator): CrudFormGroup[] => [
1688
- {
1689
- id: 'personalData',
1690
- title: t('customers.people.form.groups.personalData', 'Personal data'),
1691
- column: 1,
1692
- fields: ['firstName', 'lastName', 'jobTitle', 'primaryEmail', 'primaryPhone'],
1693
- },
1694
- {
1695
- id: 'companyRole',
1696
- title: t('customers.people.form.groups.companyRole', 'Company & role'),
1697
- column: 1,
1698
- fields: ['companyEntityId', 'status', 'lifecycleStage', 'source'],
1699
- },
1700
- {
1701
- id: 'customFields',
1702
- title: t('customers.people.form.groups.customAttributes', 'Custom attributes'),
1703
- column: 1,
1704
- kind: 'customFields',
1705
- },
1706
- {
1707
- id: 'roles',
1708
- title: t('customers.people.form.groups.roles', 'My roles'),
1709
- column: 1,
1710
- component: ({ values }: CrudFormGroupComponentProps) => (
1711
- values.id ? (
1712
- <RolesSection
1713
- entityType="person"
1714
- entityId={values.id as string}
1715
- entityName={typeof values.displayName === 'string' ? values.displayName : null}
1716
- />
1717
- ) : null
1718
- ),
1719
- },
1720
- ]
1690
+ export const createPersonPersonalDataGroups = (
1691
+ t: Translator,
1692
+ options?: { entityName?: string | null },
1693
+ ): CrudFormGroup[] => {
1694
+ const entityName = options?.entityName?.trim() || null
1695
+ const rolesTitle = entityName
1696
+ ? t('customers.roles.groupTitle.person', 'My roles with {{name}}', { name: entityName })
1697
+ : t('customers.people.form.groups.roles', 'My roles')
1698
+ return [
1699
+ {
1700
+ id: 'personalDataDisplay',
1701
+ title: t('customers.people.form.groups.displayName', 'Display name'),
1702
+ column: 1,
1703
+ bare: true,
1704
+ component: createDisplayNameSection(t),
1705
+ },
1706
+ {
1707
+ id: 'personalData',
1708
+ title: t('customers.people.form.groups.personalData', 'Personal data'),
1709
+ column: 1,
1710
+ fields: ['firstName', 'lastName', 'jobTitle', 'primaryEmail', 'primaryPhone'],
1711
+ },
1712
+ {
1713
+ id: 'companyRole',
1714
+ title: t('customers.people.form.groups.companyRole', 'Company & role'),
1715
+ column: 1,
1716
+ fields: ['companyEntityId', 'status', 'lifecycleStage', 'source'],
1717
+ },
1718
+ {
1719
+ id: 'customFields',
1720
+ title: t('customers.people.form.groups.customAttributes', 'Custom attributes'),
1721
+ column: 1,
1722
+ kind: 'customFields',
1723
+ },
1724
+ {
1725
+ id: 'roles',
1726
+ title: rolesTitle,
1727
+ column: 1,
1728
+ component: ({ values }: CrudFormGroupComponentProps) => (
1729
+ values.id ? (
1730
+ <RolesSection
1731
+ entityType="person"
1732
+ entityId={values.id as string}
1733
+ entityName={typeof values.displayName === 'string' ? values.displayName : null}
1734
+ />
1735
+ ) : null
1736
+ ),
1737
+ },
1738
+ ]
1739
+ }
1721
1740
 
1722
1741
  // ---------------------------------------------------------------------------
1723
1742
  // Edit-mode payload builders
@@ -75,9 +75,9 @@ const events = [
75
75
  { id: 'customers.label_assignment.deleted', label: 'Label Unassigned', entity: 'label_assignment', category: 'crud' },
76
76
 
77
77
  // Person-Company Links
78
- { id: 'customers.person_company_link.created', label: 'Person Linked To Company', entity: 'person_company_link', category: 'crud' },
79
- { id: 'customers.person_company_link.updated', label: 'Person-Company Link Updated', entity: 'person_company_link', category: 'crud' },
80
- { id: 'customers.person_company_link.deleted', label: 'Person Unlinked From Company', entity: 'person_company_link', category: 'crud' },
78
+ { id: 'customers.person_company_link.created', label: 'Person Linked To Company', entity: 'person_company_link', category: 'crud', clientBroadcast: true },
79
+ { id: 'customers.person_company_link.updated', label: 'Person-Company Link Updated', entity: 'person_company_link', category: 'crud', clientBroadcast: true },
80
+ { id: 'customers.person_company_link.deleted', label: 'Person Unlinked From Company', entity: 'person_company_link', category: 'crud', clientBroadcast: true },
81
81
  ] as const
82
82
 
83
83
  export const eventsConfig = createModuleEvents({
@@ -195,6 +195,10 @@
195
195
  "customers.companies.dashboard.seeAllActivity": "Alle {{count}} Aktivitäten anzeigen",
196
196
  "customers.companies.dashboard.showAll": "Show all",
197
197
  "customers.companies.dashboard.upcomingMeetings": "Anstehende Meetings",
198
+ "customers.companies.delete.blocked": "Unternehmen kann nicht gelöscht werden: {{blockers}}. Bitte zuerst Verknüpfungen lösen oder neu zuweisen.",
199
+ "customers.companies.delete.blockers.deals": "verknüpfte Deals ({{count}})",
200
+ "customers.companies.delete.blockers.directPeople": "Personen, deren primäres Unternehmen dies ist ({{count}})",
201
+ "customers.companies.delete.blockers.persons": "verknüpfte Personen ({{count}})",
198
202
  "customers.companies.detail.actions.addDeal": "Deal hinzufügen",
199
203
  "customers.companies.detail.actions.backToList": "Zurück zu Unternehmen",
200
204
  "customers.companies.detail.actions.cancel": "Abbrechen",
@@ -1189,6 +1193,11 @@
1189
1193
  "customers.people.detail.companies.sortRecent": "Sort: Recently active",
1190
1194
  "customers.people.detail.companies.subtitle": "Link one or more companies and choose the primary relationship for this person.",
1191
1195
  "customers.people.detail.companies.summary": "{{count}} verknüpfte Firmen",
1196
+ "customers.people.detail.companies.unlinkAction": "Verknüpfung lösen",
1197
+ "customers.people.detail.companies.unlinkConfirm": "Verknüpfung von {{company}} mit {{person}} lösen?",
1198
+ "customers.people.detail.companies.unlinkConfirmTitle": "Firma entkoppeln",
1199
+ "customers.people.detail.companies.unlinkError": "Verknüpfung der Firma konnte nicht gelöst werden.",
1200
+ "customers.people.detail.companies.unlinkSuccess": "Firma entkoppelt.",
1192
1201
  "customers.people.detail.company.current": "{{company}}",
1193
1202
  "customers.people.detail.company.empty": "Kein Unternehmen zugewiesen",
1194
1203
  "customers.people.detail.company.label": "Unternehmen",
@@ -1530,6 +1539,7 @@
1530
1539
  "customers.people.form.groups.custom": "Individuelle Felder",
1531
1540
  "customers.people.form.groups.customAttributes": "Custom attributes",
1532
1541
  "customers.people.form.groups.details": "Details",
1542
+ "customers.people.form.groups.displayName": "Anzeigename",
1533
1543
  "customers.people.form.groups.notes": "Notizen",
1534
1544
  "customers.people.form.groups.personalData": "Personal data",
1535
1545
  "customers.people.form.groups.roles": "My roles",
@@ -195,6 +195,10 @@
195
195
  "customers.companies.dashboard.seeAllActivity": "See all {{count}} activities",
196
196
  "customers.companies.dashboard.showAll": "Show all",
197
197
  "customers.companies.dashboard.upcomingMeetings": "Upcoming meetings",
198
+ "customers.companies.delete.blocked": "Cannot delete company: {{blockers}}. Please unlink or reassign first.",
199
+ "customers.companies.delete.blockers.deals": "linked deals ({{count}})",
200
+ "customers.companies.delete.blockers.directPeople": "persons whose primary company is this one ({{count}})",
201
+ "customers.companies.delete.blockers.persons": "linked persons ({{count}})",
198
202
  "customers.companies.detail.actions.addDeal": "Add deal",
199
203
  "customers.companies.detail.actions.backToList": "Back to companies",
200
204
  "customers.companies.detail.actions.cancel": "Cancel",
@@ -1189,6 +1193,11 @@
1189
1193
  "customers.people.detail.companies.sortRecent": "Sort: Recently active",
1190
1194
  "customers.people.detail.companies.subtitle": "Link one or more companies and choose the primary relationship for this person.",
1191
1195
  "customers.people.detail.companies.summary": "{{count}} linked companies",
1196
+ "customers.people.detail.companies.unlinkAction": "Unlink",
1197
+ "customers.people.detail.companies.unlinkConfirm": "Unlink {{company}} from {{person}}?",
1198
+ "customers.people.detail.companies.unlinkConfirmTitle": "Unlink company",
1199
+ "customers.people.detail.companies.unlinkError": "Failed to unlink company.",
1200
+ "customers.people.detail.companies.unlinkSuccess": "Company unlinked.",
1192
1201
  "customers.people.detail.company.current": "{{company}}",
1193
1202
  "customers.people.detail.company.empty": "No company assigned",
1194
1203
  "customers.people.detail.company.label": "Company",
@@ -1530,6 +1539,7 @@
1530
1539
  "customers.people.form.groups.custom": "Custom fields",
1531
1540
  "customers.people.form.groups.customAttributes": "Custom attributes",
1532
1541
  "customers.people.form.groups.details": "Details",
1542
+ "customers.people.form.groups.displayName": "Display name",
1533
1543
  "customers.people.form.groups.notes": "Notes",
1534
1544
  "customers.people.form.groups.personalData": "Personal data",
1535
1545
  "customers.people.form.groups.roles": "My roles",
@@ -195,6 +195,10 @@
195
195
  "customers.companies.dashboard.seeAllActivity": "Ver todas las {{count}} actividades",
196
196
  "customers.companies.dashboard.showAll": "Show all",
197
197
  "customers.companies.dashboard.upcomingMeetings": "Próximas reuniones",
198
+ "customers.companies.delete.blocked": "No se puede eliminar la empresa: {{blockers}}. Por favor, desvincula o reasigna primero.",
199
+ "customers.companies.delete.blockers.deals": "oportunidades vinculadas ({{count}})",
200
+ "customers.companies.delete.blockers.directPeople": "personas cuya empresa principal es esta ({{count}})",
201
+ "customers.companies.delete.blockers.persons": "personas vinculadas ({{count}})",
198
202
  "customers.companies.detail.actions.addDeal": "Agregar oportunidad",
199
203
  "customers.companies.detail.actions.backToList": "Volver a empresas",
200
204
  "customers.companies.detail.actions.cancel": "Cancelar",
@@ -1189,6 +1193,11 @@
1189
1193
  "customers.people.detail.companies.sortRecent": "Sort: Recently active",
1190
1194
  "customers.people.detail.companies.subtitle": "Link one or more companies and choose the primary relationship for this person.",
1191
1195
  "customers.people.detail.companies.summary": "{{count}} empresas vinculadas",
1196
+ "customers.people.detail.companies.unlinkAction": "Desvincular",
1197
+ "customers.people.detail.companies.unlinkConfirm": "¿Desvincular {{company}} de {{person}}?",
1198
+ "customers.people.detail.companies.unlinkConfirmTitle": "Desvincular empresa",
1199
+ "customers.people.detail.companies.unlinkError": "No se pudo desvincular la empresa.",
1200
+ "customers.people.detail.companies.unlinkSuccess": "Empresa desvinculada.",
1192
1201
  "customers.people.detail.company.current": "{{company}}",
1193
1202
  "customers.people.detail.company.empty": "Ninguna empresa asignada",
1194
1203
  "customers.people.detail.company.label": "Empresa",
@@ -1530,6 +1539,7 @@
1530
1539
  "customers.people.form.groups.custom": "Campos personalizados",
1531
1540
  "customers.people.form.groups.customAttributes": "Custom attributes",
1532
1541
  "customers.people.form.groups.details": "Detalles",
1542
+ "customers.people.form.groups.displayName": "Nombre para mostrar",
1533
1543
  "customers.people.form.groups.notes": "Notas",
1534
1544
  "customers.people.form.groups.personalData": "Personal data",
1535
1545
  "customers.people.form.groups.roles": "My roles",
@@ -195,6 +195,10 @@
195
195
  "customers.companies.dashboard.seeAllActivity": "Zobacz wszystkie {{count}} aktywności",
196
196
  "customers.companies.dashboard.showAll": "Show all",
197
197
  "customers.companies.dashboard.upcomingMeetings": "Nadchodzące spotkania",
198
+ "customers.companies.delete.blocked": "Nie można usunąć firmy: {{blockers}}. Najpierw odłącz lub przepisz powiązania.",
199
+ "customers.companies.delete.blockers.deals": "powiązane szanse ({{count}})",
200
+ "customers.companies.delete.blockers.directPeople": "osoby, dla których ta firma jest podstawowa ({{count}})",
201
+ "customers.companies.delete.blockers.persons": "powiązane osoby ({{count}})",
198
202
  "customers.companies.detail.actions.addDeal": "Dodaj szansę",
199
203
  "customers.companies.detail.actions.backToList": "Powrót do listy firm",
200
204
  "customers.companies.detail.actions.cancel": "Anuluj",
@@ -1189,6 +1193,11 @@
1189
1193
  "customers.people.detail.companies.sortRecent": "Sort: Recently active",
1190
1194
  "customers.people.detail.companies.subtitle": "Link one or more companies and choose the primary relationship for this person.",
1191
1195
  "customers.people.detail.companies.summary": "{{count}} powiązanych firm",
1196
+ "customers.people.detail.companies.unlinkAction": "Odłącz",
1197
+ "customers.people.detail.companies.unlinkConfirm": "Odłączyć firmę {{company}} od {{person}}?",
1198
+ "customers.people.detail.companies.unlinkConfirmTitle": "Odłącz firmę",
1199
+ "customers.people.detail.companies.unlinkError": "Nie udało się odłączyć firmy.",
1200
+ "customers.people.detail.companies.unlinkSuccess": "Firma odłączona.",
1192
1201
  "customers.people.detail.company.current": "{{company}}",
1193
1202
  "customers.people.detail.company.empty": "Brak przypisanej firmy",
1194
1203
  "customers.people.detail.company.label": "Firma",
@@ -1530,6 +1539,7 @@
1530
1539
  "customers.people.form.groups.custom": "Pola niestandardowe",
1531
1540
  "customers.people.form.groups.customAttributes": "Atrybuty niestandardowe",
1532
1541
  "customers.people.form.groups.details": "Szczegóły",
1542
+ "customers.people.form.groups.displayName": "Nazwa wyświetlana",
1533
1543
  "customers.people.form.groups.notes": "Notatki",
1534
1544
  "customers.people.form.groups.personalData": "Dane osobowe",
1535
1545
  "customers.people.form.groups.roles": "Moje role",
@@ -1,3 +1,22 @@
1
+ export function deriveDisplayName(
2
+ firstName: string | null | undefined,
3
+ lastName: string | null | undefined,
4
+ ): string {
5
+ const first = (firstName ?? '').trim()
6
+ const last = (lastName ?? '').trim()
7
+ return [first, last].filter((part) => part.length > 0).join(' ').trim()
8
+ }
9
+
10
+ export function isDerivedDisplayName(
11
+ current: string | null | undefined,
12
+ firstName: string | null | undefined,
13
+ lastName: string | null | undefined,
14
+ ): boolean {
15
+ const trimmed = (current ?? '').trim()
16
+ if (trimmed.length === 0) return true
17
+ return trimmed === deriveDisplayName(firstName, lastName)
18
+ }
19
+
1
20
  export function deriveDisplayNameFromEmail(email: string | null | undefined): string | null {
2
21
  if (typeof email !== 'string') return null
3
22
  const trimmed = email.trim()