@open-mercato/core 0.5.1-develop.2800.bfe2178a4f → 0.5.1-develop.2851.2854b4507f

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 (91) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/generated/entities/action_log/index.js +4 -0
  3. package/dist/generated/entities/action_log/index.js.map +2 -2
  4. package/dist/generated/entity-fields-registry.js +2 -0
  5. package/dist/generated/entity-fields-registry.js.map +2 -2
  6. package/dist/modules/audit_logs/data/entities.js +10 -1
  7. package/dist/modules/audit_logs/data/entities.js.map +2 -2
  8. package/dist/modules/audit_logs/data/validators.js +2 -0
  9. package/dist/modules/audit_logs/data/validators.js.map +2 -2
  10. package/dist/modules/audit_logs/migrations/Migration20260423202109.js +15 -0
  11. package/dist/modules/audit_logs/migrations/Migration20260423202109.js.map +7 -0
  12. package/dist/modules/audit_logs/services/accessLogService.js +3 -2
  13. package/dist/modules/audit_logs/services/accessLogService.js.map +3 -3
  14. package/dist/modules/audit_logs/services/actionLogService.js +13 -2
  15. package/dist/modules/audit_logs/services/actionLogService.js.map +3 -3
  16. package/dist/modules/auth/cli.js.map +2 -2
  17. package/dist/modules/customers/api/entity-roles-factory.js +3 -18
  18. package/dist/modules/customers/api/entity-roles-factory.js.map +2 -2
  19. package/dist/modules/customers/api/interactions/cancel/route.js +7 -2
  20. package/dist/modules/customers/api/interactions/cancel/route.js.map +2 -2
  21. package/dist/modules/customers/api/interactions/complete/route.js +7 -2
  22. package/dist/modules/customers/api/interactions/complete/route.js.map +2 -2
  23. package/dist/modules/customers/backend/customers/deals/page.js +45 -44
  24. package/dist/modules/customers/backend/customers/deals/page.js.map +2 -2
  25. package/dist/modules/customers/commands/comments.js +6 -0
  26. package/dist/modules/customers/commands/comments.js.map +2 -2
  27. package/dist/modules/customers/components/detail/AssignRoleDialog.js +41 -13
  28. package/dist/modules/customers/components/detail/AssignRoleDialog.js.map +2 -2
  29. package/dist/modules/customers/components/detail/CompanyDetailHeader.js +30 -0
  30. package/dist/modules/customers/components/detail/CompanyDetailHeader.js.map +2 -2
  31. package/dist/modules/customers/components/detail/DealDetailHeader.js +32 -0
  32. package/dist/modules/customers/components/detail/DealDetailHeader.js.map +2 -2
  33. package/dist/modules/customers/components/detail/DealWonPopup.js +2 -2
  34. package/dist/modules/customers/components/detail/DealWonPopup.js.map +2 -2
  35. package/dist/modules/customers/components/detail/InlineActivityComposer.js +62 -6
  36. package/dist/modules/customers/components/detail/InlineActivityComposer.js.map +2 -2
  37. package/dist/modules/customers/components/detail/ObjectHistoryButton.js +39 -0
  38. package/dist/modules/customers/components/detail/ObjectHistoryButton.js.map +7 -0
  39. package/dist/modules/customers/components/detail/PersonDetailHeader.js +30 -0
  40. package/dist/modules/customers/components/detail/PersonDetailHeader.js.map +2 -2
  41. package/dist/modules/customers/components/detail/RolesSection.js +14 -4
  42. package/dist/modules/customers/components/detail/RolesSection.js.map +3 -3
  43. package/dist/modules/customers/components/formConfig.js +16 -2
  44. package/dist/modules/customers/components/formConfig.js.map +2 -2
  45. package/dist/modules/customers/lib/displayName.js +15 -0
  46. package/dist/modules/customers/lib/displayName.js.map +7 -0
  47. package/dist/modules/customers/lib/interactionReadModel.js +1 -2
  48. package/dist/modules/customers/lib/interactionReadModel.js.map +2 -2
  49. package/dist/modules/customers/lib/operationMetadata.js +21 -0
  50. package/dist/modules/customers/lib/operationMetadata.js.map +7 -0
  51. package/dist/modules/messages/components/MessagesInboxPageClient.js +106 -107
  52. package/dist/modules/messages/components/MessagesInboxPageClient.js.map +2 -2
  53. package/dist/modules/messages/components/useMessagesInboxBulkActions.js +235 -0
  54. package/dist/modules/messages/components/useMessagesInboxBulkActions.js.map +7 -0
  55. package/generated/entities/action_log/index.ts +2 -0
  56. package/generated/entity-fields-registry.ts +2 -0
  57. package/package.json +3 -3
  58. package/src/modules/audit_logs/data/entities.ts +7 -0
  59. package/src/modules/audit_logs/data/validators.ts +2 -0
  60. package/src/modules/audit_logs/migrations/.snapshot-open-mercato.json +51 -5
  61. package/src/modules/audit_logs/migrations/Migration20260423202109.ts +15 -0
  62. package/src/modules/audit_logs/services/accessLogService.ts +1 -3
  63. package/src/modules/audit_logs/services/actionLogService.ts +11 -6
  64. package/src/modules/auth/cli.ts +1 -1
  65. package/src/modules/customers/api/entity-roles-factory.ts +3 -23
  66. package/src/modules/customers/api/interactions/cancel/route.ts +7 -2
  67. package/src/modules/customers/api/interactions/complete/route.ts +7 -2
  68. package/src/modules/customers/backend/customers/deals/page.tsx +48 -44
  69. package/src/modules/customers/commands/comments.ts +6 -0
  70. package/src/modules/customers/components/detail/AssignRoleDialog.tsx +37 -9
  71. package/src/modules/customers/components/detail/CompanyDetailHeader.tsx +25 -0
  72. package/src/modules/customers/components/detail/DealDetailHeader.tsx +29 -0
  73. package/src/modules/customers/components/detail/DealWonPopup.tsx +2 -2
  74. package/src/modules/customers/components/detail/InlineActivityComposer.tsx +65 -6
  75. package/src/modules/customers/components/detail/ObjectHistoryButton.tsx +47 -0
  76. package/src/modules/customers/components/detail/PersonDetailHeader.tsx +25 -0
  77. package/src/modules/customers/components/detail/RolesSection.tsx +20 -1
  78. package/src/modules/customers/components/formConfig.tsx +14 -2
  79. package/src/modules/customers/i18n/de.json +12 -0
  80. package/src/modules/customers/i18n/en.json +12 -0
  81. package/src/modules/customers/i18n/es.json +13 -1
  82. package/src/modules/customers/i18n/pl.json +13 -1
  83. package/src/modules/customers/lib/displayName.ts +16 -0
  84. package/src/modules/customers/lib/interactionReadModel.ts +1 -7
  85. package/src/modules/customers/lib/operationMetadata.ts +38 -0
  86. package/src/modules/messages/components/MessagesInboxPageClient.tsx +17 -29
  87. package/src/modules/messages/components/useMessagesInboxBulkActions.ts +324 -0
  88. package/src/modules/messages/i18n/de.json +8 -0
  89. package/src/modules/messages/i18n/en.json +8 -0
  90. package/src/modules/messages/i18n/es.json +8 -0
  91. package/src/modules/messages/i18n/pl.json +8 -0
@@ -14,6 +14,7 @@ import {
14
14
  validateCrudMutationGuard,
15
15
  } from '@open-mercato/shared/lib/crud/mutation-guard'
16
16
  import { resolveAuthActorId } from '../../../lib/interactionRequestContext'
17
+ import { withOperationMetadata } from '../../../lib/operationMetadata'
17
18
 
18
19
  export const metadata = {
19
20
  POST: { requireAuth: true, requireFeatures: ['customers.interactions.manage'] },
@@ -56,7 +57,7 @@ export async function POST(req: Request) {
56
57
  }
57
58
 
58
59
  const commandBus = ctx.container.resolve('commandBus') as CommandBus
59
- await commandBus.execute<InteractionCompleteInput, { interactionId: string }>(
60
+ const { logEntry } = await commandBus.execute<InteractionCompleteInput, { interactionId: string }>(
60
61
  'customers.interactions.complete',
61
62
  { input: parsed, ctx },
62
63
  )
@@ -73,7 +74,11 @@ export async function POST(req: Request) {
73
74
  metadata: guardResult.metadata ?? null,
74
75
  })
75
76
  }
76
- return NextResponse.json({ ok: true })
77
+ return withOperationMetadata(
78
+ NextResponse.json({ ok: true }),
79
+ logEntry,
80
+ { resourceKind: 'customers.interaction', resourceId: parsed.id },
81
+ )
77
82
  } catch (err) {
78
83
  if (err instanceof CrudHttpError) {
79
84
  return NextResponse.json(err.body, { status: err.status })
@@ -9,7 +9,7 @@ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
9
9
  import { DataTable, type DataTableExportFormat, withDataTableNamespaces } from '@open-mercato/ui/backend/DataTable'
10
10
  import type { FilterDef, FilterValues } from '@open-mercato/ui/backend/FilterBar'
11
11
  import type { AdvancedFilterState } from '@open-mercato/shared/lib/query/advanced-filter'
12
- import { serializeAdvancedFilter } from '@open-mercato/shared/lib/query/advanced-filter'
12
+ import { deserializeAdvancedFilter, serializeAdvancedFilter } from '@open-mercato/shared/lib/query/advanced-filter'
13
13
  import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
14
14
  import { buildCrudExportUrl, deleteCrud } from '@open-mercato/ui/backend/utils/crud'
15
15
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
@@ -292,7 +292,16 @@ export default function CustomersDealsPage() {
292
292
  const [reloadToken, setReloadToken] = React.useState(0)
293
293
  const [pendingDeleteId, setPendingDeleteId] = React.useState<string | null>(null)
294
294
  const [filterValues, setFilterValues] = React.useState<FilterValues>({})
295
- const [advancedFilterState, setAdvancedFilterState] = React.useState<AdvancedFilterState>({ logic: 'and', conditions: [] })
295
+ const [advancedFilterState, setAdvancedFilterState] = React.useState<AdvancedFilterState>(() => {
296
+ const params = searchParams
297
+ if (!params) return { logic: 'and', conditions: [] }
298
+ const record: Record<string, string> = {}
299
+ params.forEach((value, key) => {
300
+ if (key.startsWith('filter[')) record[key] = value
301
+ })
302
+ const hydrated = deserializeAdvancedFilter(record)
303
+ return hydrated ?? { logic: 'and', conditions: [] }
304
+ })
296
305
  const [cacheStatus, setCacheStatus] = React.useState<'hit' | 'miss' | null>(null)
297
306
 
298
307
  const initialPersonIds = React.useMemo(
@@ -353,7 +362,6 @@ export default function CustomersDealsPage() {
353
362
  const base = buildPersonLabel(record)
354
363
  let previousLabel = idToLabel[record.id]
355
364
  if (previousLabel) {
356
- // remove previous label before reassigning
357
365
  delete labelToId[previousLabel]
358
366
  occupied.delete(previousLabel)
359
367
  }
@@ -511,10 +519,9 @@ export default function CustomersDealsPage() {
511
519
  })
512
520
  }, [scopeVersion, reloadToken])
513
521
 
514
- const syncFilterLabels = React.useCallback((
522
+ const syncFilterIds = React.useCallback((
515
523
  key: 'people' | 'companies',
516
524
  ids: string[],
517
- idToLabel: Record<string, string>,
518
525
  ) => {
519
526
  setFilterValues((prev) => {
520
527
  const current = Array.isArray(prev[key]) ? (prev[key] as string[]) : []
@@ -524,24 +531,18 @@ export default function CustomersDealsPage() {
524
531
  delete next[key]
525
532
  return next
526
533
  }
527
- const labels: string[] = []
528
- ids.forEach((id) => {
529
- const label = idToLabel[id]
530
- if (label && !labels.includes(label)) labels.push(label)
531
- })
532
- if (labels.length < ids.length) return prev
533
- if (arraysEqual(current, labels)) return prev
534
- return { ...prev, [key]: labels }
534
+ if (arraysEqual(current, ids)) return prev
535
+ return { ...prev, [key]: [...ids] }
535
536
  })
536
537
  }, [])
537
538
 
538
539
  React.useEffect(() => {
539
- syncFilterLabels('people', selectedPersonIds, peopleState.idToLabel)
540
- }, [selectedPersonIds, peopleState.idToLabel, syncFilterLabels])
540
+ syncFilterIds('people', selectedPersonIds)
541
+ }, [selectedPersonIds, syncFilterIds])
541
542
 
542
543
  React.useEffect(() => {
543
- syncFilterLabels('companies', selectedCompanyIds, companiesState.idToLabel)
544
- }, [selectedCompanyIds, companiesState.idToLabel, syncFilterLabels])
544
+ syncFilterIds('companies', selectedCompanyIds)
545
+ }, [selectedCompanyIds, syncFilterIds])
545
546
 
546
547
  const handleSearchChange = React.useCallback((value: string) => {
547
548
  setSearch(value.trim())
@@ -550,39 +551,34 @@ export default function CustomersDealsPage() {
550
551
 
551
552
  const handleFiltersApply = React.useCallback((values: FilterValues) => {
552
553
  const next: FilterValues = { ...values }
554
+
553
555
  const rawPeople = Array.isArray(values.people) ? (values.people as string[]) : []
554
- const nextPersonIds: string[] = []
555
- rawPeople.forEach((value) => {
556
- const trimmed = typeof value === 'string' ? value.trim() : ''
557
- if (!trimmed) return
558
- const mapped = peopleState.labelToId[trimmed]
559
- if (mapped && !nextPersonIds.includes(mapped)) nextPersonIds.push(mapped)
560
- })
556
+ const nextPersonIds = Array.from(
557
+ new Set(
558
+ rawPeople
559
+ .map((value) => (typeof value === 'string' ? value.trim() : ''))
560
+ .filter((value) => value.length > 0 && isUuid(value)),
561
+ ),
562
+ )
561
563
  setSelectedPersonIds(nextPersonIds)
562
- if (nextPersonIds.length) {
563
- next.people = Array.from(new Set(rawPeople.map((value) => (typeof value === 'string' ? value.trim() : '')).filter((value) => value.length > 0)))
564
- } else {
565
- delete next.people
566
- }
564
+ if (nextPersonIds.length) next.people = nextPersonIds
565
+ else delete next.people
567
566
 
568
567
  const rawCompanies = Array.isArray(values.companies) ? (values.companies as string[]) : []
569
- const nextCompanyIds: string[] = []
570
- rawCompanies.forEach((value) => {
571
- const trimmed = typeof value === 'string' ? value.trim() : ''
572
- if (!trimmed) return
573
- const mapped = companiesState.labelToId[trimmed]
574
- if (mapped && !nextCompanyIds.includes(mapped)) nextCompanyIds.push(mapped)
575
- })
568
+ const nextCompanyIds = Array.from(
569
+ new Set(
570
+ rawCompanies
571
+ .map((value) => (typeof value === 'string' ? value.trim() : ''))
572
+ .filter((value) => value.length > 0 && isUuid(value)),
573
+ ),
574
+ )
576
575
  setSelectedCompanyIds(nextCompanyIds)
577
- if (nextCompanyIds.length) {
578
- next.companies = Array.from(new Set(rawCompanies.map((value) => (typeof value === 'string' ? value.trim() : '')).filter((value) => value.length > 0)))
579
- } else {
580
- delete next.companies
581
- }
576
+ if (nextCompanyIds.length) next.companies = nextCompanyIds
577
+ else delete next.companies
582
578
 
583
579
  setFilterValues(next)
584
580
  setPage(1)
585
- }, [peopleState.labelToId, companiesState.labelToId])
581
+ }, [])
586
582
 
587
583
  const handleFiltersClear = React.useCallback(() => {
588
584
  setFilterValues({})
@@ -703,11 +699,15 @@ export default function CustomersDealsPage() {
703
699
  if (selectedPersonIds.length) selectedPersonIds.forEach((id) => params.append('personId', id))
704
700
  if (selectedCompanyIds.length) selectedCompanyIds.forEach((id) => params.append('companyId', id))
705
701
  if (page > 1) params.set('page', String(page))
702
+ const advancedParams = serializeAdvancedFilter(advancedFilterState)
703
+ for (const [key, val] of Object.entries(advancedParams)) {
704
+ params.set(key, val)
705
+ }
706
706
  const next = params.toString()
707
707
  if (queryRef.current === next) return
708
708
  queryRef.current = next
709
709
  router.replace(next ? `${pathname}?${next}` : pathname, { scroll: false })
710
- }, [pathname, router, page, search, selectedPersonIds, selectedCompanyIds])
710
+ }, [pathname, router, page, search, selectedPersonIds, selectedCompanyIds, advancedFilterState])
711
711
 
712
712
  const handleRefresh = React.useCallback(() => {
713
713
  peopleCacheRef.current.clear()
@@ -806,6 +806,8 @@ export default function CustomersDealsPage() {
806
806
  const personOptions = peopleState.options
807
807
  const companyOptions = companiesState.options
808
808
 
809
+ const peopleIdToLabel = peopleState.idToLabel
810
+ const companyIdToLabel = companiesState.idToLabel
809
811
  const filters = React.useMemo<FilterDef[]>(() => [
810
812
  {
811
813
  id: 'people',
@@ -814,6 +816,7 @@ export default function CustomersDealsPage() {
814
816
  options: personOptions,
815
817
  loadOptions: loadPeopleOptions,
816
818
  placeholder: t('customers.deals.list.filters.peoplePlaceholder'),
819
+ formatValue: (value: string) => peopleIdToLabel[value] ?? value,
817
820
  },
818
821
  {
819
822
  id: 'companies',
@@ -822,8 +825,9 @@ export default function CustomersDealsPage() {
822
825
  options: companyOptions,
823
826
  loadOptions: loadCompanyOptions,
824
827
  placeholder: t('customers.deals.list.filters.companiesPlaceholder'),
828
+ formatValue: (value: string) => companyIdToLabel[value] ?? value,
825
829
  },
826
- ], [companyOptions, loadCompanyOptions, loadPeopleOptions, personOptions, t])
830
+ ], [companyIdToLabel, companyOptions, loadCompanyOptions, loadPeopleOptions, peopleIdToLabel, personOptions, t])
827
831
 
828
832
  const { data: customFieldDefs = [] } = useCustomFieldDefs([E.customers.customer_deal], {
829
833
  keyExtras: [scopeVersion, reloadToken],
@@ -133,6 +133,8 @@ const createCommentCommand: CommandHandler<CommentCreateInput, { commentId: stri
133
133
  tenantId: snapshot?.tenantId ?? null,
134
134
  organizationId: snapshot?.organizationId ?? null,
135
135
  snapshotAfter: snapshot ?? null,
136
+ relatedResourceKind: snapshot?.dealId ? 'customers.deal' : null,
137
+ relatedResourceId: snapshot?.dealId ?? null,
136
138
  payload: {
137
139
  undo: {
138
140
  after: snapshot ?? null,
@@ -226,6 +228,8 @@ const updateCommentCommand: CommandHandler<CommentUpdateInput, { commentId: stri
226
228
  organizationId: before.organizationId,
227
229
  snapshotBefore: before,
228
230
  snapshotAfter: afterSnapshot ?? null,
231
+ relatedResourceKind: (afterSnapshot?.dealId ?? before.dealId) ? 'customers.deal' : null,
232
+ relatedResourceId: afterSnapshot?.dealId ?? before.dealId ?? null,
229
233
  changes,
230
234
  payload: {
231
235
  undo: {
@@ -332,6 +336,8 @@ const deleteCommentCommand: CommandHandler<{ body?: Record<string, unknown>; que
332
336
  tenantId: before.tenantId,
333
337
  organizationId: before.organizationId,
334
338
  snapshotBefore: before,
339
+ relatedResourceKind: before.dealId ? 'customers.deal' : null,
340
+ relatedResourceId: before.dealId ?? null,
335
341
  payload: {
336
342
  undo: {
337
343
  before,
@@ -1,7 +1,8 @@
1
1
  'use client'
2
2
 
3
3
  import * as React from 'react'
4
- import { Search, Check } from 'lucide-react'
4
+ import Link from 'next/link'
5
+ import { Search, Check, Settings2 } from 'lucide-react'
5
6
  import { useT } from '@open-mercato/shared/lib/i18n/context'
6
7
  import { Button } from '@open-mercato/ui/primitives/button'
7
8
  import { Badge } from '@open-mercato/ui/primitives/badge'
@@ -18,6 +19,8 @@ import type { RoleAssignment } from './RoleAssignmentRow'
18
19
  import { fetchAssignableStaffMembersPage } from './assignableStaff'
19
20
  import { getInitials } from './utils'
20
21
 
22
+ const MANAGE_ROLE_TYPES_HREF = '/backend/config/customers'
23
+
21
24
  type StaffMember = {
22
25
  id: string
23
26
  displayName: string
@@ -34,6 +37,7 @@ interface AssignRoleDialogProps {
34
37
  existingRoleTypes?: Set<string>
35
38
  existingAssignments?: RoleAssignment[]
36
39
  initialRoleType?: string | null
40
+ canManageRoleTypes?: boolean
37
41
  }
38
42
 
39
43
  type StepId = 1 | 2 | 3
@@ -55,6 +59,7 @@ export function AssignRoleDialog({
55
59
  existingRoleTypes,
56
60
  existingAssignments = [],
57
61
  initialRoleType = null,
62
+ canManageRoleTypes = false,
58
63
  }: AssignRoleDialogProps) {
59
64
  const t = useT()
60
65
  const [step, setStep] = React.useState<StepId>(1)
@@ -401,6 +406,16 @@ export function AssignRoleDialog({
401
406
  </Badge>
402
407
  ) : null}
403
408
  </div>
409
+ {canManageRoleTypes ? (
410
+ <Link
411
+ href={MANAGE_ROLE_TYPES_HREF}
412
+ className="mt-3 inline-flex items-center gap-1.5 text-xs font-medium text-primary hover:underline"
413
+ data-testid="assign-role-dialog-manage-role-types"
414
+ >
415
+ <Settings2 className="size-3.5" aria-hidden="true" />
416
+ {t('customers.roles.dialog.manageRoleTypes', 'Manage role types')}
417
+ </Link>
418
+ ) : null}
404
419
  </div>
405
420
 
406
421
  {!availableRoleTypes.length ? (
@@ -434,14 +449,27 @@ export function AssignRoleDialog({
434
449
  </Badge>
435
450
  </div>
436
451
  </div>
437
- <Button
438
- type="button"
439
- variant="outline"
440
- size="sm"
441
- onClick={() => setStep(1)}
442
- >
443
- {t('customers.roles.dialog.change', 'Change')}
444
- </Button>
452
+ <div className="flex items-center gap-2">
453
+ {canManageRoleTypes ? (
454
+ <Button asChild variant="ghost" size="sm">
455
+ <Link
456
+ href={MANAGE_ROLE_TYPES_HREF}
457
+ data-testid="assign-role-dialog-manage-role-types-step2"
458
+ >
459
+ <Settings2 className="mr-1 size-3.5" aria-hidden="true" />
460
+ {t('customers.roles.dialog.manageRoleTypes', 'Manage role types')}
461
+ </Link>
462
+ </Button>
463
+ ) : null}
464
+ <Button
465
+ type="button"
466
+ variant="outline"
467
+ size="sm"
468
+ onClick={() => setStep(1)}
469
+ >
470
+ {t('customers.roles.dialog.change', 'Change')}
471
+ </Button>
472
+ </div>
445
473
  </div>
446
474
  </div>
447
475
 
@@ -7,7 +7,9 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
7
7
  import { Button } from '@open-mercato/ui/primitives/button'
8
8
  import { IconButton } from '@open-mercato/ui/primitives/icon-button'
9
9
  import { Badge } from '@open-mercato/ui/primitives/badge'
10
+ import { SendObjectMessageDialog } from '@open-mercato/ui/backend/messages'
10
11
  import { CompanyTagsDialog } from './CompanyTagsDialog'
12
+ import { ObjectHistoryButton } from './ObjectHistoryButton'
11
13
  import { invalidateCustomerDictionary, useCustomerDictionary } from './hooks/useCustomerDictionary'
12
14
  import { renderDictionaryIcon } from '../../../dictionaries/components/dictionaryAppearance'
13
15
  import type { TagSummary } from './types'
@@ -16,6 +18,8 @@ import type { CompanyOverview } from '../formConfig'
16
18
  import type { CustomerDictionaryMap } from '@open-mercato/core/modules/customers/lib/dictionaries'
17
19
  import { formatFallbackLabel } from './utils'
18
20
 
21
+ const HEADER_ICON_BUTTON_CLASS = 'size-8 rounded-md'
22
+
19
23
  type CompanyDetailHeaderProps = {
20
24
  data: CompanyOverview
21
25
  onTagsChange: (tags: TagSummary[]) => void
@@ -191,6 +195,27 @@ export function CompanyDetailHeader({
191
195
  {/* Right side: actions */}
192
196
  <div className="flex w-full shrink-0 flex-col items-start gap-3 sm:w-auto sm:items-end">
193
197
  <div className="flex w-full flex-wrap items-center justify-start gap-2 sm:w-auto sm:justify-end">
198
+ <SendObjectMessageDialog
199
+ object={{
200
+ entityModule: 'customers',
201
+ entityType: 'company',
202
+ entityId: company.id,
203
+ previewData: {
204
+ title: displayName,
205
+ subtitle: company.primaryEmail ?? profile?.websiteUrl ?? undefined,
206
+ },
207
+ }}
208
+ viewHref={`/backend/customers/companies-v2/${company.id}`}
209
+ buttonVariant="outline"
210
+ buttonSize="icon"
211
+ buttonClassName={HEADER_ICON_BUTTON_CLASS}
212
+ buttonLabel={t('customers.companies.detail.actions.sendMessage', 'Send message')}
213
+ />
214
+ <ObjectHistoryButton
215
+ resourceKind="customers.company"
216
+ resourceId={company.id}
217
+ organizationId={company.organizationId ?? undefined}
218
+ />
194
219
  <IconButton
195
220
  variant="outline"
196
221
  size="sm"
@@ -6,7 +6,9 @@ import { cn } from '@open-mercato/shared/lib/utils'
6
6
  import { useT } from '@open-mercato/shared/lib/i18n/context'
7
7
  import { Button } from '@open-mercato/ui/primitives/button'
8
8
  import { IconButton } from '@open-mercato/ui/primitives/icon-button'
9
+ import { SendObjectMessageDialog } from '@open-mercato/ui/backend/messages'
9
10
  import { Popover, PopoverContent, PopoverTrigger } from '@open-mercato/ui/primitives/popover'
11
+ import { ObjectHistoryButton } from './ObjectHistoryButton'
10
12
  import { useCustomerDictionary } from './hooks/useCustomerDictionary'
11
13
  import { formatFallbackLabel } from './utils'
12
14
  import { isTerminalPipelineOutcomeLabel } from './pipelineStageUtils'
@@ -74,6 +76,8 @@ const headerChipDotVariantClasses: Record<HeaderChipVariant, string> = {
74
76
  neutral: 'bg-status-neutral-icon',
75
77
  }
76
78
 
79
+ const HEADER_ICON_BUTTON_CLASS = 'size-8 rounded-md'
80
+
77
81
  function HeaderChip({
78
82
  children,
79
83
  icon,
@@ -227,6 +231,9 @@ export function DealDetailHeader({
227
231
  const showStatusChip = statusLabel && (!deal.closureOutcome || !isTerminalPipelineOutcomeLabel(deal.status))
228
232
  const pipelineBadgeLabel = pipelineName ?? null
229
233
  const canMoveStage = stageOptions.length > 0 && !deal.closureOutcome && typeof onStageChange === 'function'
234
+ const messageSubtitle = React.useMemo(() => (
235
+ [companyLabel, amountLabel].filter(Boolean).join(' · ') || statusLabel || undefined
236
+ ), [amountLabel, companyLabel, statusLabel])
230
237
 
231
238
  return (
232
239
  <div className="flex flex-col gap-5 xl:flex-row xl:items-start xl:justify-between">
@@ -307,6 +314,28 @@ export function DealDetailHeader({
307
314
  isSaving={isStageSaving}
308
315
  />
309
316
  ) : null}
317
+ <SendObjectMessageDialog
318
+ object={{
319
+ entityModule: 'customers',
320
+ entityType: 'deal',
321
+ entityId: deal.id,
322
+ previewData: {
323
+ title: deal.title || t('customers.deals.detail.untitled', 'Untitled deal'),
324
+ subtitle: messageSubtitle,
325
+ },
326
+ }}
327
+ viewHref={`/backend/customers/deals/${deal.id}`}
328
+ buttonVariant="outline"
329
+ buttonSize="icon"
330
+ buttonClassName={HEADER_ICON_BUTTON_CLASS}
331
+ buttonLabel={t('customers.deals.detail.actions.sendMessage', 'Send message')}
332
+ />
333
+ <ObjectHistoryButton
334
+ resourceKind="customers.deal"
335
+ resourceId={deal.id}
336
+ organizationId={deal.organizationId ?? undefined}
337
+ includeRelated
338
+ />
310
339
  <IconButton
311
340
  variant="outline"
312
341
  size="sm"
@@ -85,11 +85,11 @@ export function DealWonPopup({
85
85
 
86
86
  return (
87
87
  <Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose() }}>
88
- <DialogContent className="overflow-hidden p-0 sm:max-w-[420px]">
88
+ <DialogContent className="max-h-[90vh] overflow-hidden p-0 sm:max-w-[420px]">
89
89
  <VisuallyHidden>
90
90
  <DialogTitle>{t('customers.deals.detail.won.title', 'Closed successfully')}</DialogTitle>
91
91
  </VisuallyHidden>
92
- <div className="overflow-hidden rounded-2xl bg-card">
92
+ <div className="max-h-[90vh] overflow-y-auto rounded-2xl bg-card">
93
93
  <div className="px-6 pb-5 pt-6">
94
94
  {/* TODO(ds-review): decorative gradient — consider defining a named gradient token if reused */}
95
95
  <div className="flex h-[200px] items-center justify-center rounded-2xl bg-[linear-gradient(135deg,rgba(141,150,244,0.5),rgba(198,203,254,0.95))] text-foreground">
@@ -1,12 +1,13 @@
1
1
  'use client'
2
2
 
3
3
  import * as React from 'react'
4
- import { SquarePen, Calendar, Check } from 'lucide-react'
4
+ import { SquarePen, Calendar, Check, ChevronDown, ChevronUp } from 'lucide-react'
5
5
  import { z } from 'zod'
6
6
  import { useT } from '@open-mercato/shared/lib/i18n/context'
7
7
  import { apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
8
8
  import { flash } from '@open-mercato/ui/backend/FlashMessages'
9
9
  import { Button } from '@open-mercato/ui/primitives/button'
10
+ import { usePersistedBooleanFlag } from '@open-mercato/ui/backend/crud/usePersistedBooleanFlag'
10
11
  import { ActivityTypeSelector, type ActivityType } from './ActivityTypeSelector'
11
12
  import { MiniWeekCalendar } from './MiniWeekCalendar'
12
13
 
@@ -66,6 +67,24 @@ export function InlineActivityComposer({
66
67
  const [errors, setErrors] = React.useState<Record<string, string>>({})
67
68
  const descriptionRef = React.useRef<HTMLTextAreaElement>(null)
68
69
 
70
+ const weekPreviewStorageKey = `om:inline-composer:week-preview:${entityType}`
71
+ const { value: weekPreviewHidden, toggle: toggleWeekPreview } = usePersistedBooleanFlag(
72
+ weekPreviewStorageKey,
73
+ false,
74
+ )
75
+
76
+ const resizeDescription = React.useCallback(() => {
77
+ const el = descriptionRef.current
78
+ if (!el) return
79
+ el.style.height = 'auto'
80
+ const maxHeight = 200
81
+ el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`
82
+ }, [])
83
+
84
+ React.useEffect(() => {
85
+ resizeDescription()
86
+ }, [description, resizeDescription])
87
+
69
88
  const handleTypeSelect = React.useCallback((type: ActivityType) => {
70
89
  setSelectedType((previous) => (previous === type ? null : type))
71
90
  setErrors({})
@@ -191,19 +210,26 @@ export function InlineActivityComposer({
191
210
  {/* Description + date row */}
192
211
  <div className="mt-4 flex items-start gap-3">
193
212
  <div className="flex-1">
213
+ <label
214
+ htmlFor="inline-activity-composer-description"
215
+ className="mb-1 block text-xs font-medium text-muted-foreground"
216
+ >
217
+ {t('customers.activityComposer.descriptionLabel', 'Description')}
218
+ </label>
194
219
  <textarea
195
220
  ref={descriptionRef}
221
+ id="inline-activity-composer-description"
196
222
  value={description}
197
223
  onChange={(event) => setDescription(event.target.value)}
198
224
  placeholder={t('customers.activityComposer.descriptionPlaceholder', 'What happened?')}
199
- className="min-h-[44px] w-full resize-none rounded-lg border bg-background px-3 py-2.5 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
200
- rows={1}
225
+ className="min-h-[72px] w-full resize-none rounded-lg border bg-background px-3 py-2.5 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring"
226
+ rows={3}
201
227
  />
202
228
  {errors.description ? (
203
229
  <p className="mt-1 text-xs text-destructive">{errors.description}</p>
204
230
  ) : null}
205
231
  </div>
206
- <label className="flex shrink-0 cursor-pointer items-center gap-2 rounded-lg border bg-background px-3 py-2.5 text-sm text-muted-foreground">
232
+ <label className="mt-[22px] flex shrink-0 cursor-pointer items-center gap-2 rounded-lg border bg-background px-3 py-2.5 text-sm text-muted-foreground">
207
233
  <Calendar className="size-4" />
208
234
  <span className="text-sm font-medium text-foreground">{formatDateBadge(occurredAt, t)}</span>
209
235
  <input
@@ -215,9 +241,42 @@ export function InlineActivityComposer({
215
241
  </label>
216
242
  </div>
217
243
 
218
- {/* Mini calendar preview */}
219
244
  <div className="mt-4">
220
- <MiniWeekCalendar entityId={entityId} useCanonicalInteractions={useCanonicalInteractions} refreshRef={calendarRefreshRef} />
245
+ <div className="mb-2 flex items-center justify-between">
246
+ <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
247
+ {t('customers.activityComposer.weekPreviewTitle', 'This week')}
248
+ </span>
249
+ <Button
250
+ type="button"
251
+ variant="ghost"
252
+ size="sm"
253
+ onClick={toggleWeekPreview}
254
+ aria-expanded={!weekPreviewHidden}
255
+ aria-controls="inline-activity-composer-week-preview"
256
+ className="h-auto px-2 py-1 text-xs text-muted-foreground hover:text-foreground"
257
+ >
258
+ {weekPreviewHidden ? (
259
+ <>
260
+ <ChevronDown className="mr-1 size-3" />
261
+ {t('customers.activityComposer.showWeekPreview', 'Show week preview')}
262
+ </>
263
+ ) : (
264
+ <>
265
+ <ChevronUp className="mr-1 size-3" />
266
+ {t('customers.activityComposer.hideWeekPreview', 'Hide week preview')}
267
+ </>
268
+ )}
269
+ </Button>
270
+ </div>
271
+ {!weekPreviewHidden ? (
272
+ <div id="inline-activity-composer-week-preview">
273
+ <MiniWeekCalendar
274
+ entityId={entityId}
275
+ useCanonicalInteractions={useCanonicalInteractions}
276
+ refreshRef={calendarRefreshRef}
277
+ />
278
+ </div>
279
+ ) : null}
221
280
  </div>
222
281
 
223
282
  {/* Scheduled for (optional) */}
@@ -0,0 +1,47 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { VersionHistoryAction } from '@open-mercato/ui/backend/version-history'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import type { VersionHistoryConfig } from '@open-mercato/ui/backend/version-history'
7
+
8
+ export type ObjectHistoryButtonProps = {
9
+ resourceKind: VersionHistoryConfig['resourceKind']
10
+ resourceId: VersionHistoryConfig['resourceId']
11
+ resourceIdFallback?: VersionHistoryConfig['resourceIdFallback']
12
+ organizationId?: VersionHistoryConfig['organizationId']
13
+ includeRelated?: VersionHistoryConfig['includeRelated']
14
+ }
15
+
16
+ const OUTLINE_ICON_BUTTON_CLASSES =
17
+ 'size-8 rounded-md border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50'
18
+
19
+ export function ObjectHistoryButton({
20
+ resourceKind,
21
+ resourceId,
22
+ resourceIdFallback,
23
+ organizationId,
24
+ includeRelated,
25
+ }: ObjectHistoryButtonProps) {
26
+ const t = useT()
27
+ const config = React.useMemo<VersionHistoryConfig>(
28
+ () => ({
29
+ resourceKind,
30
+ resourceId,
31
+ resourceIdFallback,
32
+ organizationId,
33
+ includeRelated,
34
+ }),
35
+ [resourceKind, resourceId, resourceIdFallback, organizationId, includeRelated],
36
+ )
37
+
38
+ return (
39
+ <VersionHistoryAction
40
+ config={config}
41
+ t={t}
42
+ buttonClassName={OUTLINE_ICON_BUTTON_CLASSES}
43
+ />
44
+ )
45
+ }
46
+
47
+ export default ObjectHistoryButton
@@ -8,7 +8,9 @@ import { useT } from '@open-mercato/shared/lib/i18n/context'
8
8
  import { Button } from '@open-mercato/ui/primitives/button'
9
9
  import { IconButton } from '@open-mercato/ui/primitives/icon-button'
10
10
  import { Badge } from '@open-mercato/ui/primitives/badge'
11
+ import { SendObjectMessageDialog } from '@open-mercato/ui/backend/messages'
11
12
  import { useQueryClient } from '@tanstack/react-query'
13
+ import { ObjectHistoryButton } from './ObjectHistoryButton'
12
14
  import { PersonTagsDialog } from './PersonTagsDialog'
13
15
  import { useCustomerDictionary, invalidateCustomerDictionary } from './hooks/useCustomerDictionary'
14
16
  import { renderDictionaryIcon } from '../../../dictionaries/components/dictionaryAppearance'
@@ -18,6 +20,8 @@ import type { PersonOverview } from '../formConfig'
18
20
  import type { CustomerDictionaryMap } from '@open-mercato/core/modules/customers/lib/dictionaries'
19
21
  import { getInitials, formatFallbackLabel } from './utils'
20
22
 
23
+ const HEADER_ICON_BUTTON_CLASS = 'size-8 rounded-md'
24
+
21
25
  type PersonDetailHeaderProps = {
22
26
  data: PersonOverview
23
27
  onTagsChange: (tags: TagSummary[]) => void
@@ -243,6 +247,27 @@ export function PersonDetailHeader({
243
247
 
244
248
  {/* Right side: actions */}
245
249
  <div className="flex w-full shrink-0 items-center justify-start gap-2 sm:w-auto sm:justify-end">
250
+ <SendObjectMessageDialog
251
+ object={{
252
+ entityModule: 'customers',
253
+ entityType: 'person',
254
+ entityId: person.id,
255
+ previewData: {
256
+ title: displayName,
257
+ subtitle: person.primaryEmail ?? companyName ?? undefined,
258
+ },
259
+ }}
260
+ viewHref={`/backend/customers/people-v2/${person.id}`}
261
+ buttonVariant="outline"
262
+ buttonSize="icon"
263
+ buttonClassName={HEADER_ICON_BUTTON_CLASS}
264
+ buttonLabel={t('customers.people.detail.actions.sendMessage', 'Send message')}
265
+ />
266
+ <ObjectHistoryButton
267
+ resourceKind="customers.person"
268
+ resourceId={person.id}
269
+ organizationId={person.organizationId ?? undefined}
270
+ />
246
271
  <IconButton
247
272
  variant="outline"
248
273
  size="sm"