@open-mercato/ui 0.4.2-canary-c02407ff85
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.
- package/build.mjs +62 -0
- package/dist/backend/AppShell.js +902 -0
- package/dist/backend/AppShell.js.map +7 -0
- package/dist/backend/ConfirmDialog.js +17 -0
- package/dist/backend/ConfirmDialog.js.map +7 -0
- package/dist/backend/ContextHelp.js +31 -0
- package/dist/backend/ContextHelp.js.map +7 -0
- package/dist/backend/CrudForm.js +2028 -0
- package/dist/backend/CrudForm.js.map +7 -0
- package/dist/backend/DataTable.js +1363 -0
- package/dist/backend/DataTable.js.map +7 -0
- package/dist/backend/EmptyState.js +52 -0
- package/dist/backend/EmptyState.js.map +7 -0
- package/dist/backend/FilterBar.js +140 -0
- package/dist/backend/FilterBar.js.map +7 -0
- package/dist/backend/FilterOverlay.js +279 -0
- package/dist/backend/FilterOverlay.js.map +7 -0
- package/dist/backend/FlashMessages.js +66 -0
- package/dist/backend/FlashMessages.js.map +7 -0
- package/dist/backend/JsonBuilder.js +322 -0
- package/dist/backend/JsonBuilder.js.map +7 -0
- package/dist/backend/JsonDisplay.js +203 -0
- package/dist/backend/JsonDisplay.js.map +7 -0
- package/dist/backend/Page.js +27 -0
- package/dist/backend/Page.js.map +7 -0
- package/dist/backend/PerspectiveSidebar.js +282 -0
- package/dist/backend/PerspectiveSidebar.js.map +7 -0
- package/dist/backend/RowActions.js +148 -0
- package/dist/backend/RowActions.js.map +7 -0
- package/dist/backend/TruncatedCell.js +92 -0
- package/dist/backend/TruncatedCell.js.map +7 -0
- package/dist/backend/UserMenu.js +107 -0
- package/dist/backend/UserMenu.js.map +7 -0
- package/dist/backend/ValueIcons.js +34 -0
- package/dist/backend/ValueIcons.js.map +7 -0
- package/dist/backend/custom-fields/FieldDefinitionsEditor.js +1264 -0
- package/dist/backend/custom-fields/FieldDefinitionsEditor.js.map +7 -0
- package/dist/backend/custom-fields/FieldDefinitionsManager.js +332 -0
- package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +7 -0
- package/dist/backend/dashboard/DashboardScreen.js +578 -0
- package/dist/backend/dashboard/DashboardScreen.js.map +7 -0
- package/dist/backend/dashboard/index.js +5 -0
- package/dist/backend/dashboard/index.js.map +7 -0
- package/dist/backend/dashboard/widgetRegistry.js +55 -0
- package/dist/backend/dashboard/widgetRegistry.js.map +7 -0
- package/dist/backend/detail/ActivitiesSection.js +962 -0
- package/dist/backend/detail/ActivitiesSection.js.map +7 -0
- package/dist/backend/detail/AddressEditor.js +413 -0
- package/dist/backend/detail/AddressEditor.js.map +7 -0
- package/dist/backend/detail/AddressTiles.js +437 -0
- package/dist/backend/detail/AddressTiles.js.map +7 -0
- package/dist/backend/detail/AddressesSection.js +264 -0
- package/dist/backend/detail/AddressesSection.js.map +7 -0
- package/dist/backend/detail/AttachmentDeleteDialog.js +41 -0
- package/dist/backend/detail/AttachmentDeleteDialog.js.map +7 -0
- package/dist/backend/detail/AttachmentMetadataDialog.js +517 -0
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +7 -0
- package/dist/backend/detail/AttachmentsSection.js +367 -0
- package/dist/backend/detail/AttachmentsSection.js.map +7 -0
- package/dist/backend/detail/CustomDataSection.js +433 -0
- package/dist/backend/detail/CustomDataSection.js.map +7 -0
- package/dist/backend/detail/DetailFieldsSection.js +75 -0
- package/dist/backend/detail/DetailFieldsSection.js.map +7 -0
- package/dist/backend/detail/ErrorMessage.js +28 -0
- package/dist/backend/detail/ErrorMessage.js.map +7 -0
- package/dist/backend/detail/InlineEditors.js +681 -0
- package/dist/backend/detail/InlineEditors.js.map +7 -0
- package/dist/backend/detail/LoadingMessage.js +14 -0
- package/dist/backend/detail/LoadingMessage.js.map +7 -0
- package/dist/backend/detail/NotesSection.js +1032 -0
- package/dist/backend/detail/NotesSection.js.map +7 -0
- package/dist/backend/detail/TabEmptyState.js +25 -0
- package/dist/backend/detail/TabEmptyState.js.map +7 -0
- package/dist/backend/detail/TagsSection.js +254 -0
- package/dist/backend/detail/TagsSection.js.map +7 -0
- package/dist/backend/detail/addressFormat.js +77 -0
- package/dist/backend/detail/addressFormat.js.map +7 -0
- package/dist/backend/detail/index.js +34 -0
- package/dist/backend/detail/index.js.map +7 -0
- package/dist/backend/fields/registry.generated.js +8 -0
- package/dist/backend/fields/registry.generated.js.map +7 -0
- package/dist/backend/fields/registry.js +29 -0
- package/dist/backend/fields/registry.js.map +7 -0
- package/dist/backend/indexes/PartialIndexBanner.js +58 -0
- package/dist/backend/indexes/PartialIndexBanner.js.map +7 -0
- package/dist/backend/indexes/store.js +62 -0
- package/dist/backend/indexes/store.js.map +7 -0
- package/dist/backend/injection/InjectionSpot.js +179 -0
- package/dist/backend/injection/InjectionSpot.js.map +7 -0
- package/dist/backend/injection/PageInjectionBoundary.js +26 -0
- package/dist/backend/injection/PageInjectionBoundary.js.map +7 -0
- package/dist/backend/injection/helpers.js +26 -0
- package/dist/backend/injection/helpers.js.map +7 -0
- package/dist/backend/injection/widgetRegistry.js +55 -0
- package/dist/backend/injection/widgetRegistry.js.map +7 -0
- package/dist/backend/inputs/ComboboxInput.js +225 -0
- package/dist/backend/inputs/ComboboxInput.js.map +7 -0
- package/dist/backend/inputs/LookupSelect.js +191 -0
- package/dist/backend/inputs/LookupSelect.js.map +7 -0
- package/dist/backend/inputs/PhoneNumberField.js +100 -0
- package/dist/backend/inputs/PhoneNumberField.js.map +7 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js +92 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +7 -0
- package/dist/backend/inputs/TagsInput.js +222 -0
- package/dist/backend/inputs/TagsInput.js.map +7 -0
- package/dist/backend/inputs/index.js +6 -0
- package/dist/backend/inputs/index.js.map +7 -0
- package/dist/backend/operations/LastOperationBanner.js +80 -0
- package/dist/backend/operations/LastOperationBanner.js.map +7 -0
- package/dist/backend/operations/store.js +183 -0
- package/dist/backend/operations/store.js.map +7 -0
- package/dist/backend/schedule/ScheduleAgenda.js +107 -0
- package/dist/backend/schedule/ScheduleAgenda.js.map +7 -0
- package/dist/backend/schedule/ScheduleGrid.js +107 -0
- package/dist/backend/schedule/ScheduleGrid.js.map +7 -0
- package/dist/backend/schedule/ScheduleToolbar.js +166 -0
- package/dist/backend/schedule/ScheduleToolbar.js.map +7 -0
- package/dist/backend/schedule/ScheduleView.js +165 -0
- package/dist/backend/schedule/ScheduleView.js.map +7 -0
- package/dist/backend/schedule/index.js +6 -0
- package/dist/backend/schedule/index.js.map +7 -0
- package/dist/backend/schedule/recurrence.js +83 -0
- package/dist/backend/schedule/recurrence.js.map +7 -0
- package/dist/backend/schedule/types.js +1 -0
- package/dist/backend/schedule/types.js.map +7 -0
- package/dist/backend/upgrades/UpgradeActionBanner.js +91 -0
- package/dist/backend/upgrades/UpgradeActionBanner.js.map +7 -0
- package/dist/backend/utils/api.js +127 -0
- package/dist/backend/utils/api.js.map +7 -0
- package/dist/backend/utils/apiCall.js +48 -0
- package/dist/backend/utils/apiCall.js.map +7 -0
- package/dist/backend/utils/crud.js +126 -0
- package/dist/backend/utils/crud.js.map +7 -0
- package/dist/backend/utils/customFieldColumns.js +56 -0
- package/dist/backend/utils/customFieldColumns.js.map +7 -0
- package/dist/backend/utils/customFieldDefs.js +143 -0
- package/dist/backend/utils/customFieldDefs.js.map +7 -0
- package/dist/backend/utils/customFieldFilters.js +126 -0
- package/dist/backend/utils/customFieldFilters.js.map +7 -0
- package/dist/backend/utils/customFieldForms.js +162 -0
- package/dist/backend/utils/customFieldForms.js.map +7 -0
- package/dist/backend/utils/customFieldValues.js +26 -0
- package/dist/backend/utils/customFieldValues.js.map +7 -0
- package/dist/backend/utils/flash.js +16 -0
- package/dist/backend/utils/flash.js.map +7 -0
- package/dist/backend/utils/nav.js +185 -0
- package/dist/backend/utils/nav.js.map +7 -0
- package/dist/backend/utils/serverErrors.js +230 -0
- package/dist/backend/utils/serverErrors.js.map +7 -0
- package/dist/frontend/AuthFooter.js +23 -0
- package/dist/frontend/AuthFooter.js.map +7 -0
- package/dist/frontend/LanguageSwitcher.js +57 -0
- package/dist/frontend/LanguageSwitcher.js.map +7 -0
- package/dist/frontend/Layout.js +14 -0
- package/dist/frontend/Layout.js.map +7 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +7 -0
- package/dist/primitives/DataLoader.js +67 -0
- package/dist/primitives/DataLoader.js.map +7 -0
- package/dist/primitives/ErrorNotice.js +20 -0
- package/dist/primitives/ErrorNotice.js.map +7 -0
- package/dist/primitives/alert.js +38 -0
- package/dist/primitives/alert.js.map +7 -0
- package/dist/primitives/badge.js +28 -0
- package/dist/primitives/badge.js.map +7 -0
- package/dist/primitives/button.js +44 -0
- package/dist/primitives/button.js.map +7 -0
- package/dist/primitives/card.js +91 -0
- package/dist/primitives/card.js.map +7 -0
- package/dist/primitives/checkbox.js +28 -0
- package/dist/primitives/checkbox.js.map +7 -0
- package/dist/primitives/dialog.js +90 -0
- package/dist/primitives/dialog.js.map +7 -0
- package/dist/primitives/input.js +22 -0
- package/dist/primitives/input.js.map +7 -0
- package/dist/primitives/label.js +21 -0
- package/dist/primitives/label.js.map +7 -0
- package/dist/primitives/separator.js +9 -0
- package/dist/primitives/separator.js.map +7 -0
- package/dist/primitives/spinner.js +24 -0
- package/dist/primitives/spinner.js.map +7 -0
- package/dist/primitives/switch.js +80 -0
- package/dist/primitives/switch.js.map +7 -0
- package/dist/primitives/table.js +29 -0
- package/dist/primitives/table.js.map +7 -0
- package/dist/primitives/tabs.js +87 -0
- package/dist/primitives/tabs.js.map +7 -0
- package/dist/primitives/textarea.js +21 -0
- package/dist/primitives/textarea.js.map +7 -0
- package/dist/primitives/tooltip.js +60 -0
- package/dist/primitives/tooltip.js.map +7 -0
- package/dist/theme/QueryProvider.js +44 -0
- package/dist/theme/QueryProvider.js.map +7 -0
- package/dist/theme/ThemeProvider.js +95 -0
- package/dist/theme/ThemeProvider.js.map +7 -0
- package/dist/theme/ThemeToggle.js +88 -0
- package/dist/theme/ThemeToggle.js.map +7 -0
- package/dist/theme/index.js +10 -0
- package/dist/theme/index.js.map +7 -0
- package/dist/types/react-big-calendar.d.js +1 -0
- package/dist/types/react-big-calendar.d.js.map +7 -0
- package/jest.config.cjs +23 -0
- package/jest.setup.ts +55 -0
- package/package.json +105 -0
- package/src/backend/AppShell.tsx +1096 -0
- package/src/backend/ConfirmDialog.tsx +19 -0
- package/src/backend/ContextHelp.tsx +38 -0
- package/src/backend/CrudForm.tsx +2503 -0
- package/src/backend/DataTable.tsx +1730 -0
- package/src/backend/EmptyState.tsx +65 -0
- package/src/backend/FilterBar.tsx +161 -0
- package/src/backend/FilterOverlay.tsx +328 -0
- package/src/backend/FlashMessages.tsx +82 -0
- package/src/backend/JsonBuilder.tsx +362 -0
- package/src/backend/JsonDisplay.tsx +254 -0
- package/src/backend/Page.tsx +30 -0
- package/src/backend/PerspectiveSidebar.tsx +337 -0
- package/src/backend/RowActions.tsx +151 -0
- package/src/backend/TruncatedCell.tsx +133 -0
- package/src/backend/UserMenu.tsx +118 -0
- package/src/backend/ValueIcons.tsx +48 -0
- package/src/backend/__tests__/AppShell.test.tsx +115 -0
- package/src/backend/__tests__/CrudForm.render.test.tsx +30 -0
- package/src/backend/__tests__/DataTable.render.test.tsx +48 -0
- package/src/backend/__tests__/custom-field-filters.test.ts +72 -0
- package/src/backend/__tests__/custom-field-forms.test.ts +54 -0
- package/src/backend/__tests__/serverErrors.test.ts +83 -0
- package/src/backend/custom-fields/FieldDefinitionsEditor.tsx +1292 -0
- package/src/backend/custom-fields/FieldDefinitionsManager.tsx +381 -0
- package/src/backend/dashboard/DashboardScreen.tsx +684 -0
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +112 -0
- package/src/backend/dashboard/index.ts +1 -0
- package/src/backend/dashboard/widgetRegistry.ts +68 -0
- package/src/backend/detail/ActivitiesSection.tsx +1284 -0
- package/src/backend/detail/AddressEditor.tsx +472 -0
- package/src/backend/detail/AddressTiles.tsx +587 -0
- package/src/backend/detail/AddressesSection.tsx +346 -0
- package/src/backend/detail/AttachmentDeleteDialog.tsx +56 -0
- package/src/backend/detail/AttachmentMetadataDialog.tsx +672 -0
- package/src/backend/detail/AttachmentsSection.tsx +414 -0
- package/src/backend/detail/CustomDataSection.tsx +530 -0
- package/src/backend/detail/DetailFieldsSection.tsx +147 -0
- package/src/backend/detail/ErrorMessage.tsx +32 -0
- package/src/backend/detail/InlineEditors.tsx +877 -0
- package/src/backend/detail/LoadingMessage.tsx +14 -0
- package/src/backend/detail/NotesSection.tsx +1275 -0
- package/src/backend/detail/TabEmptyState.tsx +48 -0
- package/src/backend/detail/TagsSection.tsx +314 -0
- package/src/backend/detail/addressFormat.tsx +121 -0
- package/src/backend/detail/index.ts +44 -0
- package/src/backend/fields/registry.generated.ts +8 -0
- package/src/backend/fields/registry.ts +38 -0
- package/src/backend/indexes/PartialIndexBanner.tsx +68 -0
- package/src/backend/indexes/store.ts +88 -0
- package/src/backend/injection/InjectionSpot.tsx +236 -0
- package/src/backend/injection/PageInjectionBoundary.tsx +31 -0
- package/src/backend/injection/helpers.ts +35 -0
- package/src/backend/injection/widgetRegistry.ts +68 -0
- package/src/backend/inputs/ComboboxInput.tsx +269 -0
- package/src/backend/inputs/LookupSelect.tsx +247 -0
- package/src/backend/inputs/PhoneNumberField.tsx +129 -0
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +128 -0
- package/src/backend/inputs/TagsInput.tsx +259 -0
- package/src/backend/inputs/index.ts +5 -0
- package/src/backend/operations/LastOperationBanner.tsx +85 -0
- package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +99 -0
- package/src/backend/operations/store.ts +230 -0
- package/src/backend/schedule/ScheduleAgenda.tsx +136 -0
- package/src/backend/schedule/ScheduleGrid.tsx +136 -0
- package/src/backend/schedule/ScheduleToolbar.tsx +178 -0
- package/src/backend/schedule/ScheduleView.tsx +198 -0
- package/src/backend/schedule/index.ts +5 -0
- package/src/backend/schedule/recurrence.ts +99 -0
- package/src/backend/schedule/types.ts +26 -0
- package/src/backend/upgrades/UpgradeActionBanner.tsx +128 -0
- package/src/backend/utils/__tests__/apiCall.test.ts +109 -0
- package/src/backend/utils/__tests__/crud.test.ts +87 -0
- package/src/backend/utils/__tests__/customFieldDefs.test.ts +25 -0
- package/src/backend/utils/__tests__/customFieldValues.test.ts +35 -0
- package/src/backend/utils/api.ts +149 -0
- package/src/backend/utils/apiCall.ts +96 -0
- package/src/backend/utils/crud.ts +174 -0
- package/src/backend/utils/customFieldColumns.ts +71 -0
- package/src/backend/utils/customFieldDefs.ts +245 -0
- package/src/backend/utils/customFieldFilters.ts +145 -0
- package/src/backend/utils/customFieldForms.ts +196 -0
- package/src/backend/utils/customFieldValues.ts +41 -0
- package/src/backend/utils/flash.ts +17 -0
- package/src/backend/utils/nav.ts +238 -0
- package/src/backend/utils/serverErrors.ts +302 -0
- package/src/frontend/AuthFooter.tsx +29 -0
- package/src/frontend/LanguageSwitcher.tsx +66 -0
- package/src/frontend/Layout.tsx +13 -0
- package/src/index.ts +32 -0
- package/src/primitives/DataLoader.tsx +92 -0
- package/src/primitives/ErrorNotice.tsx +26 -0
- package/src/primitives/alert.tsx +52 -0
- package/src/primitives/badge.tsx +31 -0
- package/src/primitives/button.tsx +47 -0
- package/src/primitives/card.tsx +92 -0
- package/src/primitives/checkbox.tsx +28 -0
- package/src/primitives/dialog.tsx +110 -0
- package/src/primitives/input.tsx +20 -0
- package/src/primitives/label.tsx +18 -0
- package/src/primitives/separator.tsx +7 -0
- package/src/primitives/spinner.tsx +27 -0
- package/src/primitives/switch.tsx +86 -0
- package/src/primitives/table.tsx +27 -0
- package/src/primitives/tabs.tsx +128 -0
- package/src/primitives/textarea.tsx +20 -0
- package/src/primitives/tooltip.tsx +85 -0
- package/src/theme/QueryProvider.tsx +46 -0
- package/src/theme/ThemeProvider.tsx +120 -0
- package/src/theme/ThemeToggle.tsx +88 -0
- package/src/theme/index.ts +3 -0
- package/src/types/react-big-calendar.d.ts +16 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +9 -0
- package/watch.mjs +6 -0
|
@@ -0,0 +1,1284 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import Link from 'next/link'
|
|
5
|
+
import { ArrowUpRightSquare, Pencil, Plus, Trash2 } from 'lucide-react'
|
|
6
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
7
|
+
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
8
|
+
import { createCrudFormError } from '@open-mercato/ui/backend/utils/serverErrors'
|
|
9
|
+
import { CrudForm, type CrudField, type CrudFormGroup } from '@open-mercato/ui/backend/CrudForm'
|
|
10
|
+
import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customFieldValues'
|
|
11
|
+
import { DictionaryEntrySelect, type DictionarySelectLabels } from '@open-mercato/core/modules/dictionaries/components/DictionaryEntrySelect'
|
|
12
|
+
import type { AppearanceSelectorLabels } from '@open-mercato/core/modules/dictionaries/components/AppearanceSelector'
|
|
13
|
+
import { LoadingMessage, TabEmptyState } from './'
|
|
14
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
15
|
+
import { createTranslatorWithFallback } from '@open-mercato/shared/lib/i18n/translate'
|
|
16
|
+
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
|
|
17
|
+
|
|
18
|
+
type Translator = (key: string, fallback?: string, params?: Record<string, string | number>) => string
|
|
19
|
+
|
|
20
|
+
export type ActivitySummary = {
|
|
21
|
+
id: string
|
|
22
|
+
activityType: string
|
|
23
|
+
subject?: string | null
|
|
24
|
+
body?: string | null
|
|
25
|
+
occurredAt?: string | null
|
|
26
|
+
createdAt: string
|
|
27
|
+
appearanceIcon?: string | null
|
|
28
|
+
appearanceColor?: string | null
|
|
29
|
+
entityId?: string | null
|
|
30
|
+
authorUserId?: string | null
|
|
31
|
+
authorName?: string | null
|
|
32
|
+
authorEmail?: string | null
|
|
33
|
+
dealId?: string | null
|
|
34
|
+
dealTitle?: string | null
|
|
35
|
+
customFields?: Array<{ key: string; label?: string | null; value: unknown }>
|
|
36
|
+
customValues?: Record<string, unknown> | null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type SectionAction = {
|
|
40
|
+
label: React.ReactNode
|
|
41
|
+
onClick: () => void
|
|
42
|
+
disabled?: boolean
|
|
43
|
+
icon?: React.ReactNode
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type TabEmptyStateConfig = {
|
|
47
|
+
title: string
|
|
48
|
+
actionLabel: string
|
|
49
|
+
description?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type ActivityCreatePayload = {
|
|
53
|
+
entityId: string
|
|
54
|
+
activityType: string
|
|
55
|
+
subject?: string | null
|
|
56
|
+
body?: string | null
|
|
57
|
+
occurredAt?: string | null
|
|
58
|
+
dealId?: string | null
|
|
59
|
+
customFields?: Record<string, unknown>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type ActivityUpdatePayload = Partial<ActivityCreatePayload>
|
|
63
|
+
|
|
64
|
+
export type ActivitiesDataAdapter<C = unknown> = {
|
|
65
|
+
list: (params: { entityId: string | null; dealId?: string | null; context?: C }) => Promise<ActivitySummary[]>
|
|
66
|
+
create: (params: ActivityCreatePayload & { context?: C }) => Promise<void>
|
|
67
|
+
update: (params: { id: string; patch: ActivityUpdatePayload; context?: C }) => Promise<void>
|
|
68
|
+
delete: (params: { id: string; context?: C }) => Promise<void>
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type DictionaryOption = {
|
|
72
|
+
value: string
|
|
73
|
+
label: string
|
|
74
|
+
color: string | null
|
|
75
|
+
icon: string | null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type ActivityTypePresentation = {
|
|
79
|
+
label: string
|
|
80
|
+
icon?: string | null
|
|
81
|
+
color?: string | null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type PendingAction =
|
|
85
|
+
| { kind: 'create' }
|
|
86
|
+
| { kind: 'update'; id: string }
|
|
87
|
+
| { kind: 'delete'; id: string }
|
|
88
|
+
|
|
89
|
+
const INVALID_DATE_MESSAGE = 'invalidDate'
|
|
90
|
+
|
|
91
|
+
const schema = {
|
|
92
|
+
validate(values: Record<string, unknown>) {
|
|
93
|
+
const result: { ok: boolean; errors?: Array<{ path: string; message: string }> } = { ok: true }
|
|
94
|
+
const activityType = typeof values.activityType === 'string' ? values.activityType.trim() : ''
|
|
95
|
+
if (!activityType) {
|
|
96
|
+
result.ok = false
|
|
97
|
+
result.errors = [{ path: 'activityType', message: 'required' }]
|
|
98
|
+
return result
|
|
99
|
+
}
|
|
100
|
+
const occurredAt = typeof values.occurredAt === 'string' ? values.occurredAt.trim() : ''
|
|
101
|
+
if (occurredAt.length) {
|
|
102
|
+
const parsed = new Date(occurredAt)
|
|
103
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
104
|
+
result.ok = false
|
|
105
|
+
result.errors = [{ path: 'occurredAt', message: INVALID_DATE_MESSAGE }]
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function toLocalDateTimeInput(value?: string | null): string {
|
|
113
|
+
if (!value) return ''
|
|
114
|
+
const date = new Date(value)
|
|
115
|
+
if (Number.isNaN(date.getTime())) return ''
|
|
116
|
+
const pad = (input: number) => `${input}`.padStart(2, '0')
|
|
117
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(
|
|
118
|
+
date.getMinutes(),
|
|
119
|
+
)}`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function formatDateTime(value?: string | null): string | null {
|
|
123
|
+
if (!value) return null
|
|
124
|
+
const date = new Date(value)
|
|
125
|
+
if (Number.isNaN(date.getTime())) return null
|
|
126
|
+
return date.toLocaleString()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function formatRelativeTime(value?: string | null): string | null {
|
|
130
|
+
if (!value) return null
|
|
131
|
+
const date = new Date(value)
|
|
132
|
+
if (Number.isNaN(date.getTime())) return null
|
|
133
|
+
const now = Date.now()
|
|
134
|
+
const diffSeconds = (date.getTime() - now) / 1000
|
|
135
|
+
const absSeconds = Math.abs(diffSeconds)
|
|
136
|
+
const rtf =
|
|
137
|
+
typeof Intl !== 'undefined' && typeof Intl.RelativeTimeFormat === 'function'
|
|
138
|
+
? new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
|
139
|
+
: null
|
|
140
|
+
const format = (unit: Intl.RelativeTimeFormatUnit, divisor: number) => {
|
|
141
|
+
const valueToFormat = Math.round(diffSeconds / divisor)
|
|
142
|
+
if (rtf) return rtf.format(valueToFormat, unit)
|
|
143
|
+
const suffix = valueToFormat <= 0 ? 'ago' : 'from now'
|
|
144
|
+
const magnitude = Math.abs(valueToFormat)
|
|
145
|
+
return `${magnitude} ${unit}${magnitude === 1 ? '' : 's'} ${suffix}`
|
|
146
|
+
}
|
|
147
|
+
if (absSeconds < 45) return format('second', 1)
|
|
148
|
+
if (absSeconds < 45 * 60) return format('minute', 60)
|
|
149
|
+
if (absSeconds < 24 * 60 * 60) return format('hour', 60 * 60)
|
|
150
|
+
if (absSeconds < 7 * 24 * 60 * 60) return format('day', 24 * 60 * 60)
|
|
151
|
+
if (absSeconds < 30 * 24 * 60 * 60) return format('week', 7 * 24 * 60 * 60)
|
|
152
|
+
if (absSeconds < 365 * 24 * 60 * 60) return format('month', 30 * 24 * 60 * 60)
|
|
153
|
+
return format('year', 365 * 24 * 60 * 60)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
type TimelineItemHeaderProps = {
|
|
157
|
+
title: React.ReactNode
|
|
158
|
+
subtitle?: React.ReactNode
|
|
159
|
+
timestamp?: string | Date | null
|
|
160
|
+
fallbackTimestampLabel?: React.ReactNode
|
|
161
|
+
icon?: string | null
|
|
162
|
+
color?: string | null
|
|
163
|
+
iconSize?: 'sm' | 'md'
|
|
164
|
+
className?: string
|
|
165
|
+
renderIcon?: (icon: string, className?: string) => React.ReactNode
|
|
166
|
+
renderColor?: (color: string, className?: string) => React.ReactNode
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function TimelineItemHeader({
|
|
170
|
+
title,
|
|
171
|
+
subtitle,
|
|
172
|
+
timestamp,
|
|
173
|
+
fallbackTimestampLabel,
|
|
174
|
+
icon,
|
|
175
|
+
color,
|
|
176
|
+
iconSize = 'md',
|
|
177
|
+
className,
|
|
178
|
+
renderIcon,
|
|
179
|
+
renderColor,
|
|
180
|
+
}: TimelineItemHeaderProps) {
|
|
181
|
+
const wrapperSize = iconSize === 'sm' ? 'h-6 w-6' : 'h-8 w-8'
|
|
182
|
+
const iconSizeClass = iconSize === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'
|
|
183
|
+
const resolvedTimestamp = React.useMemo(() => {
|
|
184
|
+
if (subtitle) return subtitle
|
|
185
|
+
if (!timestamp) return fallbackTimestampLabel ?? null
|
|
186
|
+
const value = typeof timestamp === 'string' ? timestamp : timestamp.toISOString()
|
|
187
|
+
const date = new Date(value)
|
|
188
|
+
if (Number.isNaN(date.getTime())) return fallbackTimestampLabel ?? null
|
|
189
|
+
const now = Date.now()
|
|
190
|
+
const diff = Math.abs(now - date.getTime())
|
|
191
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000
|
|
192
|
+
const relativeLabel = diff <= THIRTY_DAYS_MS ? formatRelativeTime(value) : null
|
|
193
|
+
const absoluteLabel = formatDateTime(value)
|
|
194
|
+
if (relativeLabel) {
|
|
195
|
+
return (
|
|
196
|
+
<span title={absoluteLabel ?? undefined}>
|
|
197
|
+
{relativeLabel}
|
|
198
|
+
</span>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
return absoluteLabel ?? fallbackTimestampLabel ?? null
|
|
202
|
+
}, [fallbackTimestampLabel, subtitle, timestamp])
|
|
203
|
+
|
|
204
|
+
const iconNode = icon && renderIcon ? renderIcon(icon, iconSizeClass) : null
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div className={['flex items-start gap-3', className].filter(Boolean).join(' ')}>
|
|
208
|
+
{iconNode ? (
|
|
209
|
+
<span className={['inline-flex items-center justify-center rounded border border-border bg-muted/40', wrapperSize].join(' ')}>
|
|
210
|
+
{iconNode}
|
|
211
|
+
</span>
|
|
212
|
+
) : null}
|
|
213
|
+
<div className="space-y-1">
|
|
214
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
215
|
+
<span className="text-sm font-semibold text-foreground">{title}</span>
|
|
216
|
+
{color && renderColor ? renderColor(color, 'h-3 w-3 rounded-full border border-border') : null}
|
|
217
|
+
</div>
|
|
218
|
+
{resolvedTimestamp ? <div className="text-xs text-muted-foreground">{resolvedTimestamp}</div> : null}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export type ActivityFormBaseValues = {
|
|
225
|
+
activityType: string
|
|
226
|
+
subject?: string | null
|
|
227
|
+
body?: string | null
|
|
228
|
+
occurredAt?: string | null
|
|
229
|
+
dealId?: string | null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export type ActivityFormSubmitPayload = {
|
|
233
|
+
base: ActivityFormBaseValues
|
|
234
|
+
custom: Record<string, unknown>
|
|
235
|
+
entityId?: string | null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
type ActivityFormProps = {
|
|
239
|
+
mode: 'create' | 'edit'
|
|
240
|
+
initialValues?: Partial<ActivityFormBaseValues & Record<string, unknown>>
|
|
241
|
+
onSubmit: (payload: ActivityFormSubmitPayload) => Promise<void>
|
|
242
|
+
onCancel: () => void
|
|
243
|
+
submitLabel?: string
|
|
244
|
+
cancelLabel?: string
|
|
245
|
+
isSubmitting?: boolean
|
|
246
|
+
activityTypeLabels: DictionarySelectLabels
|
|
247
|
+
loadActivityOptions: () => Promise<DictionaryOption[]>
|
|
248
|
+
createActivityOption?: (input: { value: string; label?: string; color?: string | null; icon?: string | null }) => Promise<DictionaryOption>
|
|
249
|
+
dealOptions?: Array<{ id: string; label: string }>
|
|
250
|
+
entityOptions?: Array<{ id: string; label: string }>
|
|
251
|
+
defaultEntityId?: string | null
|
|
252
|
+
manageHref?: string
|
|
253
|
+
customFieldEntityIds?: string[]
|
|
254
|
+
labelPrefix?: string
|
|
255
|
+
appearanceLabels?: AppearanceSelectorLabels
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function normalizeCustomFieldSubmitValue(value: unknown): unknown {
|
|
259
|
+
if (Array.isArray(value)) {
|
|
260
|
+
return value.filter((entry) => entry !== undefined)
|
|
261
|
+
}
|
|
262
|
+
if (value === undefined) return null
|
|
263
|
+
return value
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function buildActivityValidationError(errors: Array<{ path: string; message: string }>, translate: (key: string, fallback?: string) => string) {
|
|
267
|
+
const issue = errors[0]
|
|
268
|
+
if (!issue) {
|
|
269
|
+
throw createCrudFormError(translate('error', 'Failed to save activity.'))
|
|
270
|
+
}
|
|
271
|
+
const message = issue.message === INVALID_DATE_MESSAGE
|
|
272
|
+
? translate('invalidDate', 'Invalid date')
|
|
273
|
+
: translate('error', 'Failed to save activity.')
|
|
274
|
+
const field = issue.path
|
|
275
|
+
throw createCrudFormError(message, field ? { [field]: message } : undefined)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function ActivityForm({
|
|
279
|
+
mode,
|
|
280
|
+
initialValues,
|
|
281
|
+
onSubmit,
|
|
282
|
+
onCancel,
|
|
283
|
+
submitLabel,
|
|
284
|
+
cancelLabel,
|
|
285
|
+
isSubmitting = false,
|
|
286
|
+
activityTypeLabels,
|
|
287
|
+
loadActivityOptions,
|
|
288
|
+
createActivityOption,
|
|
289
|
+
dealOptions,
|
|
290
|
+
entityOptions,
|
|
291
|
+
defaultEntityId,
|
|
292
|
+
manageHref = '/backend/config/dictionaries',
|
|
293
|
+
customFieldEntityIds,
|
|
294
|
+
labelPrefix = 'customers.people.detail.activities',
|
|
295
|
+
appearanceLabels,
|
|
296
|
+
}: ActivityFormProps) {
|
|
297
|
+
const tHook = useT()
|
|
298
|
+
const t = React.useMemo<Translator>(() => createTranslatorWithFallback(tHook), [tHook])
|
|
299
|
+
const translate = React.useCallback(
|
|
300
|
+
(suffix: string, fallback?: string) => t(`${labelPrefix}.${suffix}`, fallback ?? ''),
|
|
301
|
+
[labelPrefix, t],
|
|
302
|
+
)
|
|
303
|
+
const [pending, setPending] = React.useState(false)
|
|
304
|
+
|
|
305
|
+
const normalizedDealOptions = React.useMemo(() => {
|
|
306
|
+
if (!Array.isArray(dealOptions)) return []
|
|
307
|
+
const seen = new Set<string>()
|
|
308
|
+
return dealOptions
|
|
309
|
+
.map((option) => {
|
|
310
|
+
if (!option || typeof option !== 'object') return null
|
|
311
|
+
const id = typeof option.id === 'string' ? option.id.trim() : ''
|
|
312
|
+
if (!id || seen.has(id)) return null
|
|
313
|
+
const label =
|
|
314
|
+
typeof option.label === 'string' && option.label.trim().length
|
|
315
|
+
? option.label.trim()
|
|
316
|
+
: id
|
|
317
|
+
seen.add(id)
|
|
318
|
+
return { id, label }
|
|
319
|
+
})
|
|
320
|
+
.filter((option): option is { id: string; label: string } => !!option)
|
|
321
|
+
}, [dealOptions])
|
|
322
|
+
|
|
323
|
+
const normalizedEntityOptions = React.useMemo(() => {
|
|
324
|
+
if (!Array.isArray(entityOptions)) return []
|
|
325
|
+
const seen = new Set<string>()
|
|
326
|
+
return entityOptions
|
|
327
|
+
.map((option) => {
|
|
328
|
+
if (!option || typeof option !== 'object') return null
|
|
329
|
+
const id = typeof option.id === 'string' ? option.id.trim() : ''
|
|
330
|
+
if (!id || seen.has(id)) return null
|
|
331
|
+
const label =
|
|
332
|
+
typeof option.label === 'string' && option.label.trim().length
|
|
333
|
+
? option.label.trim()
|
|
334
|
+
: id
|
|
335
|
+
seen.add(id)
|
|
336
|
+
return { id, label }
|
|
337
|
+
})
|
|
338
|
+
.filter((option): option is { id: string; label: string } => !!option)
|
|
339
|
+
}, [entityOptions])
|
|
340
|
+
|
|
341
|
+
const baseFields = React.useMemo<CrudField[]>(() => {
|
|
342
|
+
const fields: CrudField[] = []
|
|
343
|
+
|
|
344
|
+
if (normalizedEntityOptions.length) {
|
|
345
|
+
fields.push({
|
|
346
|
+
id: 'entityId',
|
|
347
|
+
label: translate('fields.entity', 'Assign to record'),
|
|
348
|
+
type: 'custom',
|
|
349
|
+
layout: 'half',
|
|
350
|
+
component: ({ value, setValue }) => {
|
|
351
|
+
const currentValue =
|
|
352
|
+
typeof value === 'string' && value.length ? value : normalizedEntityOptions[0]?.id ?? ''
|
|
353
|
+
return (
|
|
354
|
+
<select
|
|
355
|
+
className="h-9 w-full rounded border border-muted-foreground/40 bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
356
|
+
value={currentValue}
|
|
357
|
+
onChange={(event) => setValue(event.target.value)}
|
|
358
|
+
>
|
|
359
|
+
{normalizedEntityOptions.map((option) => (
|
|
360
|
+
<option key={option.id} value={option.id}>
|
|
361
|
+
{option.label}
|
|
362
|
+
</option>
|
|
363
|
+
))}
|
|
364
|
+
</select>
|
|
365
|
+
)
|
|
366
|
+
},
|
|
367
|
+
} as CrudField)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (normalizedDealOptions.length) {
|
|
371
|
+
fields.push({
|
|
372
|
+
id: 'dealId',
|
|
373
|
+
label: translate('fields.deal', 'Link to deal (optional)'),
|
|
374
|
+
type: 'custom',
|
|
375
|
+
layout: 'half',
|
|
376
|
+
component: ({ value, setValue }) => {
|
|
377
|
+
const currentValue = typeof value === 'string' ? value : ''
|
|
378
|
+
return (
|
|
379
|
+
<select
|
|
380
|
+
className="h-9 w-full rounded border border-muted-foreground/40 bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
381
|
+
value={currentValue}
|
|
382
|
+
onChange={(event) => setValue(event.target.value)}
|
|
383
|
+
>
|
|
384
|
+
<option value="">
|
|
385
|
+
{translate('fields.dealPlaceholder', 'No linked deal')}
|
|
386
|
+
</option>
|
|
387
|
+
{normalizedDealOptions.map((option) => (
|
|
388
|
+
<option key={option.id} value={option.id}>
|
|
389
|
+
{option.label}
|
|
390
|
+
</option>
|
|
391
|
+
))}
|
|
392
|
+
</select>
|
|
393
|
+
)
|
|
394
|
+
},
|
|
395
|
+
} as CrudField)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
fields.push({
|
|
399
|
+
id: 'activityType',
|
|
400
|
+
label: translate('fields.type', 'Activity type'),
|
|
401
|
+
type: 'custom',
|
|
402
|
+
required: true,
|
|
403
|
+
layout: 'half',
|
|
404
|
+
component: ({ value, setValue }) => (
|
|
405
|
+
<DictionaryEntrySelect
|
|
406
|
+
value={typeof value === 'string' ? value : undefined}
|
|
407
|
+
onChange={(next) => setValue(next ?? '')}
|
|
408
|
+
fetchOptions={loadActivityOptions}
|
|
409
|
+
createOption={createActivityOption}
|
|
410
|
+
labels={activityTypeLabels}
|
|
411
|
+
allowAppearance
|
|
412
|
+
allowInlineCreate
|
|
413
|
+
appearanceLabels={appearanceLabels}
|
|
414
|
+
selectClassName="w-full"
|
|
415
|
+
manageHref={manageHref}
|
|
416
|
+
/>
|
|
417
|
+
),
|
|
418
|
+
} as CrudField)
|
|
419
|
+
|
|
420
|
+
fields.push({
|
|
421
|
+
id: 'subject',
|
|
422
|
+
label: translate('fields.subject', 'Subject'),
|
|
423
|
+
type: 'text',
|
|
424
|
+
layout: 'half',
|
|
425
|
+
placeholder: translate('subjectPlaceholder', 'Add a subject (optional)'),
|
|
426
|
+
} as CrudField)
|
|
427
|
+
|
|
428
|
+
fields.push({
|
|
429
|
+
id: 'body',
|
|
430
|
+
label: translate('fields.body', 'Details'),
|
|
431
|
+
type: 'textarea',
|
|
432
|
+
placeholder: translate('bodyPlaceholder', 'Describe the interaction'),
|
|
433
|
+
} as CrudField)
|
|
434
|
+
|
|
435
|
+
fields.push({
|
|
436
|
+
id: 'occurredAt',
|
|
437
|
+
label: translate('fields.occurredAt', 'Occurred / will occur at'),
|
|
438
|
+
type: 'custom',
|
|
439
|
+
component: ({ value, setValue }) => (
|
|
440
|
+
<input
|
|
441
|
+
type="datetime-local"
|
|
442
|
+
className="w-full rounded-md border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
443
|
+
value={typeof value === 'string' ? value : ''}
|
|
444
|
+
onChange={(event) => setValue(event.target.value || '')}
|
|
445
|
+
onFocus={(event) => {
|
|
446
|
+
const target = event.currentTarget as HTMLInputElement & { showPicker?: () => void }
|
|
447
|
+
if (typeof target.showPicker === 'function') {
|
|
448
|
+
try { target.showPicker() } catch { /* ignore unsupported */ }
|
|
449
|
+
}
|
|
450
|
+
}}
|
|
451
|
+
onClick={(event) => {
|
|
452
|
+
const target = event.currentTarget as HTMLInputElement & { showPicker?: () => void }
|
|
453
|
+
if (typeof target.showPicker === 'function') {
|
|
454
|
+
try { target.showPicker() } catch { /* ignore unsupported */ }
|
|
455
|
+
}
|
|
456
|
+
}}
|
|
457
|
+
/>
|
|
458
|
+
),
|
|
459
|
+
layout: 'half',
|
|
460
|
+
} as CrudField)
|
|
461
|
+
|
|
462
|
+
return fields
|
|
463
|
+
}, [
|
|
464
|
+
activityTypeLabels,
|
|
465
|
+
appearanceLabels,
|
|
466
|
+
createActivityOption,
|
|
467
|
+
loadActivityOptions,
|
|
468
|
+
manageHref,
|
|
469
|
+
normalizedDealOptions,
|
|
470
|
+
normalizedEntityOptions,
|
|
471
|
+
translate,
|
|
472
|
+
])
|
|
473
|
+
|
|
474
|
+
const baseFieldIds = React.useMemo(() => new Set(baseFields.map((field) => field.id)), [baseFields])
|
|
475
|
+
|
|
476
|
+
const groups = React.useMemo<CrudFormGroup[]>(() => {
|
|
477
|
+
const detailFields: string[] = []
|
|
478
|
+
if (normalizedEntityOptions.length) detailFields.push('entityId')
|
|
479
|
+
if (normalizedDealOptions.length) detailFields.push('dealId')
|
|
480
|
+
detailFields.push('activityType', 'subject', 'occurredAt', 'body')
|
|
481
|
+
const baseGroups: CrudFormGroup[] = [
|
|
482
|
+
{
|
|
483
|
+
id: 'details',
|
|
484
|
+
title: translate('form.details', 'Activity details'),
|
|
485
|
+
column: 1,
|
|
486
|
+
fields: detailFields,
|
|
487
|
+
},
|
|
488
|
+
]
|
|
489
|
+
baseGroups.push({
|
|
490
|
+
id: 'custom',
|
|
491
|
+
title: translate('form.customFields', 'Custom fields'),
|
|
492
|
+
column: 2,
|
|
493
|
+
kind: 'customFields',
|
|
494
|
+
})
|
|
495
|
+
return baseGroups
|
|
496
|
+
}, [normalizedDealOptions.length, normalizedEntityOptions.length, translate])
|
|
497
|
+
|
|
498
|
+
const handleSubmit = React.useCallback(
|
|
499
|
+
async (values: Record<string, unknown>) => {
|
|
500
|
+
if (pending || isSubmitting) return
|
|
501
|
+
setPending(true)
|
|
502
|
+
try {
|
|
503
|
+
const parsed = schema.validate(values)
|
|
504
|
+
if (!parsed.ok) {
|
|
505
|
+
throw buildActivityValidationError(parsed.errors ?? [], translate)
|
|
506
|
+
}
|
|
507
|
+
const rawEntityId = typeof values.entityId === 'string' ? values.entityId.trim() : ''
|
|
508
|
+
const resolvedEntityId = rawEntityId || (typeof defaultEntityId === 'string' ? defaultEntityId : '')
|
|
509
|
+
const rawDealId = typeof values.dealId === 'string' ? values.dealId.trim() : ''
|
|
510
|
+
const base: ActivityFormBaseValues = {
|
|
511
|
+
activityType: typeof values.activityType === 'string' ? values.activityType.trim() : '',
|
|
512
|
+
subject: typeof values.subject === 'string' && values.subject.trim().length ? values.subject.trim() : undefined,
|
|
513
|
+
body: typeof values.body === 'string' && values.body.trim().length ? values.body.trim() : undefined,
|
|
514
|
+
occurredAt: typeof values.occurredAt === 'string' && values.occurredAt.trim().length
|
|
515
|
+
? new Date(values.occurredAt as string).toISOString()
|
|
516
|
+
: undefined,
|
|
517
|
+
dealId: rawDealId.length ? rawDealId : undefined,
|
|
518
|
+
}
|
|
519
|
+
const reservedCustomKeys = new Set(['entityId', 'dealId'])
|
|
520
|
+
const customEntries = collectCustomFieldValues(values, {
|
|
521
|
+
transform: (value) => normalizeCustomFieldSubmitValue(value),
|
|
522
|
+
accept: (fieldId) => !reservedCustomKeys.has(fieldId),
|
|
523
|
+
})
|
|
524
|
+
Object.entries(values).forEach(([key, value]) => {
|
|
525
|
+
if (key.startsWith('cf_')) return
|
|
526
|
+
if (!baseFieldIds.has(key) && key !== 'id') {
|
|
527
|
+
if (reservedCustomKeys.has(key)) return
|
|
528
|
+
customEntries[key] = normalizeCustomFieldSubmitValue(value)
|
|
529
|
+
}
|
|
530
|
+
})
|
|
531
|
+
await onSubmit({ base, custom: customEntries, entityId: resolvedEntityId.length ? resolvedEntityId : undefined })
|
|
532
|
+
} finally {
|
|
533
|
+
setPending(false)
|
|
534
|
+
}
|
|
535
|
+
},
|
|
536
|
+
[baseFieldIds, defaultEntityId, isSubmitting, onSubmit, pending, translate],
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
const embeddedInitialValues = React.useMemo(() => {
|
|
540
|
+
const occurredAt = toLocalDateTimeInput(initialValues?.occurredAt ?? null)
|
|
541
|
+
const resolvedEntity = (() => {
|
|
542
|
+
const raw = typeof (initialValues as Record<string, unknown> | undefined)?.entityId === 'string'
|
|
543
|
+
? (initialValues as Record<string, unknown>).entityId as string
|
|
544
|
+
: typeof defaultEntityId === 'string'
|
|
545
|
+
? defaultEntityId
|
|
546
|
+
: normalizedEntityOptions[0]?.id ?? ''
|
|
547
|
+
return raw ?? ''
|
|
548
|
+
})()
|
|
549
|
+
const resolvedDeal = typeof (initialValues as Record<string, unknown> | undefined)?.dealId === 'string'
|
|
550
|
+
? (initialValues as Record<string, unknown>).dealId as string
|
|
551
|
+
: ''
|
|
552
|
+
|
|
553
|
+
return {
|
|
554
|
+
entityId: resolvedEntity,
|
|
555
|
+
dealId: resolvedDeal,
|
|
556
|
+
activityType: initialValues?.activityType ?? '',
|
|
557
|
+
subject: initialValues?.subject ?? '',
|
|
558
|
+
body: initialValues?.body ?? '',
|
|
559
|
+
occurredAt,
|
|
560
|
+
...Object.fromEntries(
|
|
561
|
+
Object.entries(initialValues ?? {})
|
|
562
|
+
.filter(([key]) => {
|
|
563
|
+
if (!key.startsWith('cf_')) return false
|
|
564
|
+
const trimmed = key.slice(3)
|
|
565
|
+
return trimmed !== 'entityId' && trimmed !== 'dealId'
|
|
566
|
+
})
|
|
567
|
+
.map(([key, value]) => [key, value]),
|
|
568
|
+
),
|
|
569
|
+
}
|
|
570
|
+
}, [defaultEntityId, initialValues, normalizedEntityOptions])
|
|
571
|
+
|
|
572
|
+
return (
|
|
573
|
+
<CrudForm<Record<string, unknown>>
|
|
574
|
+
embedded
|
|
575
|
+
fields={baseFields}
|
|
576
|
+
groups={groups}
|
|
577
|
+
initialValues={embeddedInitialValues}
|
|
578
|
+
onSubmit={handleSubmit}
|
|
579
|
+
submitLabel={submitLabel ?? (mode === 'edit'
|
|
580
|
+
? translate('update', 'Update activity (⌘/Ctrl + Enter)')
|
|
581
|
+
: translate('save', 'Save activity (⌘/Ctrl + Enter)'))}
|
|
582
|
+
extraActions={(
|
|
583
|
+
<Button
|
|
584
|
+
type="button"
|
|
585
|
+
variant="outline"
|
|
586
|
+
onClick={onCancel}
|
|
587
|
+
disabled={pending || isSubmitting}
|
|
588
|
+
>
|
|
589
|
+
{cancelLabel ?? translate('cancel', 'Cancel')}
|
|
590
|
+
</Button>
|
|
591
|
+
)}
|
|
592
|
+
entityIds={customFieldEntityIds}
|
|
593
|
+
/>
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
type ActivityDialogProps = {
|
|
598
|
+
open: boolean
|
|
599
|
+
mode: 'create' | 'edit'
|
|
600
|
+
onOpenChange: (next: boolean) => void
|
|
601
|
+
initialValues?: Partial<ActivityFormBaseValues & Record<string, unknown>>
|
|
602
|
+
onSubmit: (payload: ActivityFormSubmitPayload) => Promise<void>
|
|
603
|
+
isSubmitting?: boolean
|
|
604
|
+
activityTypeLabels: DictionarySelectLabels
|
|
605
|
+
loadActivityOptions: () => Promise<DictionaryOption[]>
|
|
606
|
+
createActivityOption?: (input: { value: string; label?: string; color?: string | null; icon?: string | null }) => Promise<DictionaryOption>
|
|
607
|
+
titles?: {
|
|
608
|
+
create?: string
|
|
609
|
+
edit?: string
|
|
610
|
+
}
|
|
611
|
+
submitLabels?: {
|
|
612
|
+
create?: string
|
|
613
|
+
edit?: string
|
|
614
|
+
}
|
|
615
|
+
cancelLabel?: string
|
|
616
|
+
dealOptions?: Array<{ id: string; label: string }>
|
|
617
|
+
entityOptions?: Array<{ id: string; label: string }>
|
|
618
|
+
defaultEntityId?: string | null
|
|
619
|
+
manageHref?: string
|
|
620
|
+
customFieldEntityIds?: string[]
|
|
621
|
+
labelPrefix?: string
|
|
622
|
+
appearanceLabels?: AppearanceSelectorLabels
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function ActivityDialog({
|
|
626
|
+
open,
|
|
627
|
+
mode,
|
|
628
|
+
onOpenChange,
|
|
629
|
+
initialValues,
|
|
630
|
+
onSubmit,
|
|
631
|
+
isSubmitting,
|
|
632
|
+
activityTypeLabels,
|
|
633
|
+
loadActivityOptions,
|
|
634
|
+
createActivityOption,
|
|
635
|
+
titles,
|
|
636
|
+
submitLabels,
|
|
637
|
+
cancelLabel,
|
|
638
|
+
dealOptions,
|
|
639
|
+
entityOptions,
|
|
640
|
+
defaultEntityId,
|
|
641
|
+
manageHref,
|
|
642
|
+
customFieldEntityIds,
|
|
643
|
+
labelPrefix = 'customers.people.detail.activities',
|
|
644
|
+
appearanceLabels,
|
|
645
|
+
}: ActivityDialogProps) {
|
|
646
|
+
const tHook = useT()
|
|
647
|
+
const t = React.useMemo<Translator>(() => createTranslatorWithFallback(tHook), [tHook])
|
|
648
|
+
const translate = React.useCallback(
|
|
649
|
+
(suffix: string, fallback?: string, params?: Record<string, string | number>) =>
|
|
650
|
+
t(`${labelPrefix}.${suffix}`, fallback ?? '', params),
|
|
651
|
+
[labelPrefix, t],
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
const dialogTitle =
|
|
655
|
+
mode === 'edit'
|
|
656
|
+
? titles?.edit ?? translate('editTitle', 'Edit activity')
|
|
657
|
+
: titles?.create ?? translate('addTitle', 'Add activity')
|
|
658
|
+
|
|
659
|
+
const resolvedSubmitLabel =
|
|
660
|
+
mode === 'edit'
|
|
661
|
+
? submitLabels?.edit ?? translate('update', 'Update activity (⌘/Ctrl + Enter)')
|
|
662
|
+
: submitLabels?.create ?? translate('save', 'Save activity (⌘/Ctrl + Enter)')
|
|
663
|
+
|
|
664
|
+
const resolvedCancelLabel = cancelLabel ?? translate('cancel', 'Cancel')
|
|
665
|
+
|
|
666
|
+
const handleCancel = React.useCallback(() => {
|
|
667
|
+
onOpenChange(false)
|
|
668
|
+
}, [onOpenChange])
|
|
669
|
+
|
|
670
|
+
return (
|
|
671
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
672
|
+
<DialogContent className="sm:max-w-3xl">
|
|
673
|
+
<DialogHeader>
|
|
674
|
+
<DialogTitle>{dialogTitle}</DialogTitle>
|
|
675
|
+
</DialogHeader>
|
|
676
|
+
<ActivityForm
|
|
677
|
+
mode={mode}
|
|
678
|
+
initialValues={initialValues}
|
|
679
|
+
onSubmit={onSubmit}
|
|
680
|
+
onCancel={handleCancel}
|
|
681
|
+
submitLabel={resolvedSubmitLabel}
|
|
682
|
+
cancelLabel={resolvedCancelLabel}
|
|
683
|
+
isSubmitting={isSubmitting}
|
|
684
|
+
activityTypeLabels={activityTypeLabels}
|
|
685
|
+
loadActivityOptions={loadActivityOptions}
|
|
686
|
+
createActivityOption={createActivityOption}
|
|
687
|
+
dealOptions={dealOptions}
|
|
688
|
+
entityOptions={entityOptions}
|
|
689
|
+
defaultEntityId={defaultEntityId}
|
|
690
|
+
manageHref={manageHref}
|
|
691
|
+
customFieldEntityIds={customFieldEntityIds}
|
|
692
|
+
labelPrefix={labelPrefix}
|
|
693
|
+
appearanceLabels={appearanceLabels}
|
|
694
|
+
/>
|
|
695
|
+
</DialogContent>
|
|
696
|
+
</Dialog>
|
|
697
|
+
)
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
export type ActivitiesSectionProps<C = unknown> = {
|
|
701
|
+
entityId: string | null
|
|
702
|
+
dealId?: string | null
|
|
703
|
+
addActionLabel: string
|
|
704
|
+
emptyState: TabEmptyStateConfig
|
|
705
|
+
onActionChange?: (action: SectionAction | null) => void
|
|
706
|
+
onLoadingChange?: (isLoading: boolean) => void
|
|
707
|
+
dealOptions?: Array<{ id: string; label: string }>
|
|
708
|
+
entityOptions?: Array<{ id: string; label: string }>
|
|
709
|
+
defaultEntityId?: string | null
|
|
710
|
+
dataAdapter: ActivitiesDataAdapter<C>
|
|
711
|
+
dataContext?: C
|
|
712
|
+
activityTypeLabels: DictionarySelectLabels
|
|
713
|
+
loadActivityOptions: () => Promise<DictionaryOption[]>
|
|
714
|
+
createActivityOption?: (input: { value: string; label?: string; color?: string | null; icon?: string | null }) => Promise<DictionaryOption>
|
|
715
|
+
resolveActivityPresentation?: (activity: ActivitySummary) => ActivityTypePresentation
|
|
716
|
+
renderCustomFields?: (activity: ActivitySummary) => React.ReactNode
|
|
717
|
+
customFieldEntityIds?: string[]
|
|
718
|
+
labelPrefix?: string
|
|
719
|
+
renderIcon?: (icon: string, className?: string) => React.ReactNode
|
|
720
|
+
renderColor?: (color: string, className?: string) => React.ReactNode
|
|
721
|
+
appearanceLabels?: AppearanceSelectorLabels
|
|
722
|
+
dealLinkHref?: (dealId: string) => string
|
|
723
|
+
manageHref?: string
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
export function ActivitiesSection<C = unknown>({
|
|
727
|
+
entityId,
|
|
728
|
+
dealId,
|
|
729
|
+
addActionLabel,
|
|
730
|
+
emptyState,
|
|
731
|
+
onActionChange,
|
|
732
|
+
onLoadingChange,
|
|
733
|
+
dealOptions,
|
|
734
|
+
entityOptions,
|
|
735
|
+
defaultEntityId,
|
|
736
|
+
dataAdapter,
|
|
737
|
+
dataContext,
|
|
738
|
+
activityTypeLabels,
|
|
739
|
+
loadActivityOptions,
|
|
740
|
+
createActivityOption,
|
|
741
|
+
resolveActivityPresentation,
|
|
742
|
+
renderCustomFields,
|
|
743
|
+
customFieldEntityIds,
|
|
744
|
+
labelPrefix = 'customers.people.detail.activities',
|
|
745
|
+
renderIcon,
|
|
746
|
+
renderColor,
|
|
747
|
+
appearanceLabels,
|
|
748
|
+
dealLinkHref,
|
|
749
|
+
manageHref,
|
|
750
|
+
}: ActivitiesSectionProps<C>) {
|
|
751
|
+
const tHook = useT()
|
|
752
|
+
const baseTranslator = React.useMemo<Translator>(() => createTranslatorWithFallback(tHook), [tHook])
|
|
753
|
+
const translate = React.useCallback(
|
|
754
|
+
(suffix: string, fallback?: string, params?: Record<string, string | number>) =>
|
|
755
|
+
baseTranslator(`${labelPrefix}.${suffix}`, fallback ?? '', params),
|
|
756
|
+
[baseTranslator, labelPrefix],
|
|
757
|
+
)
|
|
758
|
+
const resolvedDefaultEntityId = React.useMemo(() => {
|
|
759
|
+
const primary = typeof entityId === 'string' ? entityId.trim() : ''
|
|
760
|
+
if (primary.length) return primary
|
|
761
|
+
const fallback = typeof defaultEntityId === 'string' ? defaultEntityId.trim() : ''
|
|
762
|
+
if (fallback.length) return fallback
|
|
763
|
+
if (Array.isArray(entityOptions)) {
|
|
764
|
+
for (const option of entityOptions) {
|
|
765
|
+
if (!option || typeof option !== 'object') continue
|
|
766
|
+
const id = typeof option.id === 'string' ? option.id.trim() : ''
|
|
767
|
+
if (id.length) return id
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
return ''
|
|
771
|
+
}, [defaultEntityId, entityId, entityOptions])
|
|
772
|
+
|
|
773
|
+
const resolveEntityForSubmission = React.useCallback(
|
|
774
|
+
(input?: string | null) => {
|
|
775
|
+
const candidate = typeof input === 'string' ? input.trim() : ''
|
|
776
|
+
if (candidate.length) return candidate
|
|
777
|
+
return resolvedDefaultEntityId.length ? resolvedDefaultEntityId : null
|
|
778
|
+
},
|
|
779
|
+
[resolvedDefaultEntityId],
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
const [activities, setActivities] = React.useState<ActivitySummary[]>([])
|
|
783
|
+
const [isLoading, setIsLoading] = React.useState<boolean>(() => {
|
|
784
|
+
const entity = typeof entityId === 'string' ? entityId.trim() : ''
|
|
785
|
+
const deal = typeof dealId === 'string' ? dealId.trim() : ''
|
|
786
|
+
return Boolean(entity || deal || resolvedDefaultEntityId)
|
|
787
|
+
})
|
|
788
|
+
const [loadError, setLoadError] = React.useState<string | null>(null)
|
|
789
|
+
const [pendingAction, setPendingAction] = React.useState<PendingAction | null>(null)
|
|
790
|
+
const [dialogOpen, setDialogOpen] = React.useState(false)
|
|
791
|
+
const [dialogMode, setDialogMode] = React.useState<'create' | 'edit'>('create')
|
|
792
|
+
const [editingActivityId, setEditingActivityId] = React.useState<string | null>(null)
|
|
793
|
+
const [initialValues, setInitialValues] = React.useState<Partial<ActivityFormBaseValues & Record<string, unknown>> | undefined>(undefined)
|
|
794
|
+
const [visibleCount, setVisibleCount] = React.useState(0)
|
|
795
|
+
const pendingCounterRef = React.useRef(0)
|
|
796
|
+
|
|
797
|
+
const t = translate
|
|
798
|
+
|
|
799
|
+
const pushLoading = React.useCallback(() => {
|
|
800
|
+
pendingCounterRef.current += 1
|
|
801
|
+
if (pendingCounterRef.current === 1) {
|
|
802
|
+
onLoadingChange?.(true)
|
|
803
|
+
}
|
|
804
|
+
}, [onLoadingChange])
|
|
805
|
+
|
|
806
|
+
const popLoading = React.useCallback(() => {
|
|
807
|
+
pendingCounterRef.current = Math.max(0, pendingCounterRef.current - 1)
|
|
808
|
+
if (pendingCounterRef.current === 0) {
|
|
809
|
+
onLoadingChange?.(false)
|
|
810
|
+
}
|
|
811
|
+
}, [onLoadingChange])
|
|
812
|
+
|
|
813
|
+
const updateVisibleCount = React.useCallback((length: number) => {
|
|
814
|
+
if (!length) {
|
|
815
|
+
setVisibleCount(0)
|
|
816
|
+
return
|
|
817
|
+
}
|
|
818
|
+
const baseline = Math.min(5, length)
|
|
819
|
+
setVisibleCount((prev) => {
|
|
820
|
+
if (prev >= length) {
|
|
821
|
+
return Math.min(prev, length)
|
|
822
|
+
}
|
|
823
|
+
return Math.min(Math.max(prev, baseline), length)
|
|
824
|
+
})
|
|
825
|
+
}, [])
|
|
826
|
+
|
|
827
|
+
const loadActivities = React.useCallback(async () => {
|
|
828
|
+
const queryEntityId = typeof entityId === 'string' ? entityId.trim() : ''
|
|
829
|
+
const queryDealId = typeof dealId === 'string' ? dealId.trim() : ''
|
|
830
|
+
if (!queryEntityId && !queryDealId) {
|
|
831
|
+
setActivities([])
|
|
832
|
+
setLoadError(null)
|
|
833
|
+
updateVisibleCount(0)
|
|
834
|
+
return
|
|
835
|
+
}
|
|
836
|
+
pushLoading()
|
|
837
|
+
setIsLoading(true)
|
|
838
|
+
try {
|
|
839
|
+
const items = await dataAdapter.list({
|
|
840
|
+
entityId: queryEntityId || null,
|
|
841
|
+
dealId: queryDealId || null,
|
|
842
|
+
context: dataContext,
|
|
843
|
+
})
|
|
844
|
+
setActivities(items)
|
|
845
|
+
setLoadError(null)
|
|
846
|
+
updateVisibleCount(items.length)
|
|
847
|
+
} catch (err) {
|
|
848
|
+
const message =
|
|
849
|
+
err instanceof Error
|
|
850
|
+
? err.message
|
|
851
|
+
: t('loadError', 'Failed to load activities.')
|
|
852
|
+
setLoadError(message)
|
|
853
|
+
} finally {
|
|
854
|
+
setIsLoading(false)
|
|
855
|
+
popLoading()
|
|
856
|
+
}
|
|
857
|
+
}, [dataAdapter, dataContext, dealId, entityId, popLoading, pushLoading, t, updateVisibleCount])
|
|
858
|
+
|
|
859
|
+
React.useEffect(() => {
|
|
860
|
+
updateVisibleCount(activities.length)
|
|
861
|
+
}, [activities.length, updateVisibleCount])
|
|
862
|
+
|
|
863
|
+
React.useEffect(() => {
|
|
864
|
+
const queryEntityId = typeof entityId === 'string' ? entityId.trim() : ''
|
|
865
|
+
const queryDealId = typeof dealId === 'string' ? dealId.trim() : ''
|
|
866
|
+
if (!queryEntityId && !queryDealId) {
|
|
867
|
+
setActivities([])
|
|
868
|
+
setLoadError(null)
|
|
869
|
+
setIsLoading(false)
|
|
870
|
+
pendingCounterRef.current = 0
|
|
871
|
+
onLoadingChange?.(false)
|
|
872
|
+
updateVisibleCount(0)
|
|
873
|
+
return
|
|
874
|
+
}
|
|
875
|
+
loadActivities().catch(() => {})
|
|
876
|
+
}, [dealId, entityId, loadActivities, onLoadingChange, updateVisibleCount])
|
|
877
|
+
|
|
878
|
+
const openCreateDialog = React.useCallback(() => {
|
|
879
|
+
setDialogMode('create')
|
|
880
|
+
setEditingActivityId(null)
|
|
881
|
+
setInitialValues(undefined)
|
|
882
|
+
setDialogOpen(true)
|
|
883
|
+
}, [])
|
|
884
|
+
|
|
885
|
+
const openEditDialog = React.useCallback((activity: ActivitySummary) => {
|
|
886
|
+
setDialogMode('edit')
|
|
887
|
+
setEditingActivityId(activity.id)
|
|
888
|
+
const baseValues: Partial<ActivityFormBaseValues & Record<string, unknown>> = {
|
|
889
|
+
activityType: activity.activityType,
|
|
890
|
+
subject: activity.subject ?? '',
|
|
891
|
+
body: activity.body ?? '',
|
|
892
|
+
occurredAt: activity.occurredAt ?? activity.createdAt ?? null,
|
|
893
|
+
dealId: activity.dealId ?? '',
|
|
894
|
+
entityId: activity.entityId ?? '',
|
|
895
|
+
}
|
|
896
|
+
const customEntries = Array.isArray(activity.customFields) ? activity.customFields : []
|
|
897
|
+
customEntries.forEach((entry) => {
|
|
898
|
+
if (entry.key === 'entityId' || entry.key === 'dealId') return
|
|
899
|
+
baseValues[`cf_${entry.key}`] = entry.value ?? null
|
|
900
|
+
})
|
|
901
|
+
setInitialValues(baseValues)
|
|
902
|
+
setDialogOpen(true)
|
|
903
|
+
}, [])
|
|
904
|
+
|
|
905
|
+
const closeDialog = React.useCallback(() => {
|
|
906
|
+
setDialogOpen(false)
|
|
907
|
+
setDialogMode('create')
|
|
908
|
+
setEditingActivityId(null)
|
|
909
|
+
setInitialValues(undefined)
|
|
910
|
+
}, [])
|
|
911
|
+
|
|
912
|
+
const handleDialogOpenChange = React.useCallback(
|
|
913
|
+
(next: boolean) => {
|
|
914
|
+
if (!next) {
|
|
915
|
+
closeDialog()
|
|
916
|
+
} else {
|
|
917
|
+
setDialogOpen(true)
|
|
918
|
+
}
|
|
919
|
+
},
|
|
920
|
+
[closeDialog],
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
const handleCreate = React.useCallback(
|
|
924
|
+
async ({ base, custom, entityId: formEntityId }: ActivityFormSubmitPayload) => {
|
|
925
|
+
const submissionEntityId = resolveEntityForSubmission(formEntityId)
|
|
926
|
+
if (!submissionEntityId) {
|
|
927
|
+
const message = t('entityMissing', 'Select a related record before saving.')
|
|
928
|
+
flash(message, 'error')
|
|
929
|
+
throw new Error(message)
|
|
930
|
+
}
|
|
931
|
+
setPendingAction({ kind: 'create' })
|
|
932
|
+
pushLoading()
|
|
933
|
+
try {
|
|
934
|
+
const payload: ActivityCreatePayload = {
|
|
935
|
+
entityId: submissionEntityId,
|
|
936
|
+
activityType: base.activityType,
|
|
937
|
+
subject: base.subject ?? undefined,
|
|
938
|
+
body: base.body ?? undefined,
|
|
939
|
+
occurredAt: base.occurredAt ?? undefined,
|
|
940
|
+
dealId: base.dealId ?? undefined,
|
|
941
|
+
customFields: Object.keys(custom).length ? custom : undefined,
|
|
942
|
+
}
|
|
943
|
+
await dataAdapter.create({ ...payload, context: dataContext })
|
|
944
|
+
await loadActivities()
|
|
945
|
+
flash(t('success', 'Activity saved'), 'success')
|
|
946
|
+
} catch (err) {
|
|
947
|
+
const message =
|
|
948
|
+
err instanceof Error
|
|
949
|
+
? err.message
|
|
950
|
+
: t('error', 'Failed to save activity')
|
|
951
|
+
throw err instanceof Error ? err : new Error(message)
|
|
952
|
+
} finally {
|
|
953
|
+
setPendingAction(null)
|
|
954
|
+
popLoading()
|
|
955
|
+
}
|
|
956
|
+
},
|
|
957
|
+
[dataAdapter, dataContext, loadActivities, popLoading, pushLoading, resolveEntityForSubmission, t],
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
const handleUpdate = React.useCallback(
|
|
961
|
+
async (activityId: string, { base, custom, entityId: formEntityId }: ActivityFormSubmitPayload) => {
|
|
962
|
+
const submissionEntityId = resolveEntityForSubmission(formEntityId)
|
|
963
|
+
if (!submissionEntityId) {
|
|
964
|
+
const message = t('entityMissing', 'Select a related record before saving.')
|
|
965
|
+
flash(message, 'error')
|
|
966
|
+
throw new Error(message)
|
|
967
|
+
}
|
|
968
|
+
setPendingAction({ kind: 'update', id: activityId })
|
|
969
|
+
pushLoading()
|
|
970
|
+
try {
|
|
971
|
+
const patch: ActivityUpdatePayload = {
|
|
972
|
+
entityId: submissionEntityId,
|
|
973
|
+
activityType: base.activityType,
|
|
974
|
+
subject: base.subject ?? undefined,
|
|
975
|
+
body: base.body ?? undefined,
|
|
976
|
+
occurredAt: base.occurredAt ?? undefined,
|
|
977
|
+
dealId: base.dealId ?? undefined,
|
|
978
|
+
customFields: Object.keys(custom).length ? custom : undefined,
|
|
979
|
+
}
|
|
980
|
+
await dataAdapter.update({ id: activityId, patch, context: dataContext })
|
|
981
|
+
await loadActivities()
|
|
982
|
+
flash(t('updateSuccess', 'Activity updated.'), 'success')
|
|
983
|
+
} catch (err) {
|
|
984
|
+
const message =
|
|
985
|
+
err instanceof Error
|
|
986
|
+
? err.message
|
|
987
|
+
: t('error', 'Failed to save activity')
|
|
988
|
+
throw err instanceof Error ? err : new Error(message)
|
|
989
|
+
} finally {
|
|
990
|
+
setPendingAction(null)
|
|
991
|
+
popLoading()
|
|
992
|
+
}
|
|
993
|
+
},
|
|
994
|
+
[dataAdapter, dataContext, loadActivities, popLoading, pushLoading, resolveEntityForSubmission, t],
|
|
995
|
+
)
|
|
996
|
+
|
|
997
|
+
const handleDelete = React.useCallback(
|
|
998
|
+
async (activity: ActivitySummary) => {
|
|
999
|
+
if (!activity.id) return
|
|
1000
|
+
const confirmed =
|
|
1001
|
+
typeof window === 'undefined'
|
|
1002
|
+
? true
|
|
1003
|
+
: window.confirm(
|
|
1004
|
+
t(
|
|
1005
|
+
'deleteConfirm',
|
|
1006
|
+
'Delete this activity? This action cannot be undone.',
|
|
1007
|
+
),
|
|
1008
|
+
)
|
|
1009
|
+
if (!confirmed) return
|
|
1010
|
+
setPendingAction({ kind: 'delete', id: activity.id })
|
|
1011
|
+
try {
|
|
1012
|
+
await dataAdapter.delete({ id: activity.id, context: dataContext })
|
|
1013
|
+
setActivities((prev) => prev.filter((existing) => existing.id !== activity.id))
|
|
1014
|
+
flash(t('deleteSuccess', 'Activity deleted.'), 'success')
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
const message =
|
|
1017
|
+
err instanceof Error
|
|
1018
|
+
? err.message
|
|
1019
|
+
: t('deleteError', 'Failed to delete activity.')
|
|
1020
|
+
flash(message, 'error')
|
|
1021
|
+
throw err instanceof Error ? err : new Error(message)
|
|
1022
|
+
} finally {
|
|
1023
|
+
setPendingAction(null)
|
|
1024
|
+
}
|
|
1025
|
+
},
|
|
1026
|
+
[dataAdapter, dataContext, t],
|
|
1027
|
+
)
|
|
1028
|
+
|
|
1029
|
+
const handleDialogSubmit = React.useCallback(
|
|
1030
|
+
async (payload: ActivityFormSubmitPayload) => {
|
|
1031
|
+
if (dialogMode === 'edit' && editingActivityId) {
|
|
1032
|
+
await handleUpdate(editingActivityId, payload)
|
|
1033
|
+
} else {
|
|
1034
|
+
await handleCreate(payload)
|
|
1035
|
+
}
|
|
1036
|
+
closeDialog()
|
|
1037
|
+
},
|
|
1038
|
+
[closeDialog, dialogMode, editingActivityId, handleCreate, handleUpdate],
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
React.useEffect(() => {
|
|
1042
|
+
if (!onActionChange) return
|
|
1043
|
+
if (activities.length === 0) {
|
|
1044
|
+
onActionChange(null)
|
|
1045
|
+
return () => {
|
|
1046
|
+
onActionChange(null)
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
const disabled = resolveEntityForSubmission(null) === null || pendingAction !== null || isLoading
|
|
1050
|
+
const action: SectionAction = {
|
|
1051
|
+
label: (
|
|
1052
|
+
<span className="inline-flex items-center gap-1.5">
|
|
1053
|
+
<Plus className="h-4 w-4" />
|
|
1054
|
+
{addActionLabel}
|
|
1055
|
+
</span>
|
|
1056
|
+
),
|
|
1057
|
+
onClick: () => {
|
|
1058
|
+
if (!disabled) openCreateDialog()
|
|
1059
|
+
},
|
|
1060
|
+
disabled,
|
|
1061
|
+
}
|
|
1062
|
+
onActionChange(action)
|
|
1063
|
+
return () => {
|
|
1064
|
+
onActionChange(null)
|
|
1065
|
+
}
|
|
1066
|
+
}, [
|
|
1067
|
+
activities.length,
|
|
1068
|
+
addActionLabel,
|
|
1069
|
+
isLoading,
|
|
1070
|
+
onActionChange,
|
|
1071
|
+
openCreateDialog,
|
|
1072
|
+
pendingAction,
|
|
1073
|
+
resolveEntityForSubmission,
|
|
1074
|
+
])
|
|
1075
|
+
|
|
1076
|
+
const isFormPending =
|
|
1077
|
+
pendingAction?.kind === 'create' ||
|
|
1078
|
+
(pendingAction?.kind === 'update' && pendingAction.id === editingActivityId)
|
|
1079
|
+
const visibleActivities = React.useMemo(
|
|
1080
|
+
() => activities.slice(0, visibleCount),
|
|
1081
|
+
[activities, visibleCount],
|
|
1082
|
+
)
|
|
1083
|
+
const hasMoreActivities = visibleCount < activities.length
|
|
1084
|
+
const loadMoreLabel = t('loadMore', 'Load more activities')
|
|
1085
|
+
|
|
1086
|
+
const handleLoadMore = React.useCallback(() => {
|
|
1087
|
+
setVisibleCount((prev) => {
|
|
1088
|
+
if (prev >= activities.length) return prev
|
|
1089
|
+
return Math.min(prev + 5, activities.length)
|
|
1090
|
+
})
|
|
1091
|
+
}, [activities.length])
|
|
1092
|
+
|
|
1093
|
+
const resolvePresentation = React.useCallback(
|
|
1094
|
+
(activity: ActivitySummary): ActivityTypePresentation => {
|
|
1095
|
+
if (resolveActivityPresentation) return resolveActivityPresentation(activity)
|
|
1096
|
+
return {
|
|
1097
|
+
label: activity.activityType,
|
|
1098
|
+
icon: activity.appearanceIcon ?? null,
|
|
1099
|
+
color: activity.appearanceColor ?? null,
|
|
1100
|
+
}
|
|
1101
|
+
},
|
|
1102
|
+
[resolveActivityPresentation],
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
const resolveDealHref = React.useCallback(
|
|
1106
|
+
(id: string) => (dealLinkHref ? dealLinkHref(id) : `/backend/customers/deals/${encodeURIComponent(id)}`),
|
|
1107
|
+
[dealLinkHref],
|
|
1108
|
+
)
|
|
1109
|
+
|
|
1110
|
+
return (
|
|
1111
|
+
<div className="mt-3 space-y-4">
|
|
1112
|
+
{loadError ? (
|
|
1113
|
+
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
|
1114
|
+
{loadError}
|
|
1115
|
+
</div>
|
|
1116
|
+
) : null}
|
|
1117
|
+
<div className="space-y-4">
|
|
1118
|
+
{isLoading && activities.length === 0 ? (
|
|
1119
|
+
<LoadingMessage
|
|
1120
|
+
label={t('loading', 'Loading activities…')}
|
|
1121
|
+
className="border-0 bg-transparent p-0 py-8 justify-center"
|
|
1122
|
+
/>
|
|
1123
|
+
) : (
|
|
1124
|
+
<>
|
|
1125
|
+
{!isLoading && activities.length === 0 && !dialogOpen ? (
|
|
1126
|
+
<TabEmptyState
|
|
1127
|
+
title={emptyState.title}
|
|
1128
|
+
action={{
|
|
1129
|
+
label: emptyState.actionLabel,
|
|
1130
|
+
onClick: openCreateDialog,
|
|
1131
|
+
disabled: resolveEntityForSubmission(null) === null || pendingAction !== null,
|
|
1132
|
+
}}
|
|
1133
|
+
/>
|
|
1134
|
+
) : null}
|
|
1135
|
+
{visibleActivities.length > 0
|
|
1136
|
+
? visibleActivities.map((activity) => {
|
|
1137
|
+
const presentation = resolvePresentation(activity)
|
|
1138
|
+
const timestampValue = activity.occurredAt ?? activity.createdAt ?? null
|
|
1139
|
+
const occurredLabel =
|
|
1140
|
+
formatDateTime(timestampValue) ?? t('noDate', 'No date provided')
|
|
1141
|
+
const authorLabel = activity.authorName ?? activity.authorEmail ?? null
|
|
1142
|
+
const loggedByText = authorLabel
|
|
1143
|
+
? (() => {
|
|
1144
|
+
const translated = t('loggedBy', `Logged by ${authorLabel}`, { user: authorLabel })
|
|
1145
|
+
if (
|
|
1146
|
+
!translated ||
|
|
1147
|
+
translated.includes('{{') ||
|
|
1148
|
+
translated.includes('{user')
|
|
1149
|
+
) {
|
|
1150
|
+
return `Logged by ${authorLabel}`
|
|
1151
|
+
}
|
|
1152
|
+
return translated
|
|
1153
|
+
})()
|
|
1154
|
+
: null
|
|
1155
|
+
const isUpdatePending = pendingAction?.kind === 'update' && pendingAction.id === activity.id
|
|
1156
|
+
const isDeletePending = pendingAction?.kind === 'delete' && pendingAction.id === activity.id
|
|
1157
|
+
|
|
1158
|
+
return (
|
|
1159
|
+
<div
|
|
1160
|
+
key={activity.id}
|
|
1161
|
+
className="group space-y-3 rounded-lg border bg-card p-4 transition hover:border-border/80 cursor-pointer"
|
|
1162
|
+
role="button"
|
|
1163
|
+
tabIndex={0}
|
|
1164
|
+
onClick={() => openEditDialog(activity)}
|
|
1165
|
+
onKeyDown={(event) => {
|
|
1166
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
1167
|
+
event.preventDefault()
|
|
1168
|
+
openEditDialog(activity)
|
|
1169
|
+
}
|
|
1170
|
+
}}
|
|
1171
|
+
>
|
|
1172
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
1173
|
+
<div className="space-y-1">
|
|
1174
|
+
<TimelineItemHeader
|
|
1175
|
+
title={presentation.label}
|
|
1176
|
+
timestamp={timestampValue}
|
|
1177
|
+
fallbackTimestampLabel={occurredLabel}
|
|
1178
|
+
icon={presentation.icon}
|
|
1179
|
+
color={presentation.color}
|
|
1180
|
+
renderIcon={renderIcon}
|
|
1181
|
+
renderColor={renderColor}
|
|
1182
|
+
/>
|
|
1183
|
+
{activity.dealId ? (
|
|
1184
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
1185
|
+
<ArrowUpRightSquare className="h-3.5 w-3.5" />
|
|
1186
|
+
<Link
|
|
1187
|
+
href={resolveDealHref(activity.dealId)}
|
|
1188
|
+
className="font-medium text-foreground hover:underline"
|
|
1189
|
+
onClick={(event) => event.stopPropagation()}
|
|
1190
|
+
>
|
|
1191
|
+
{activity.dealTitle && activity.dealTitle.length
|
|
1192
|
+
? activity.dealTitle
|
|
1193
|
+
: t('linkedDeal', 'Linked deal')}
|
|
1194
|
+
</Link>
|
|
1195
|
+
</div>
|
|
1196
|
+
) : null}
|
|
1197
|
+
</div>
|
|
1198
|
+
<div className="flex items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
|
1199
|
+
<Button
|
|
1200
|
+
type="button"
|
|
1201
|
+
variant="ghost"
|
|
1202
|
+
size="icon"
|
|
1203
|
+
onClick={(event) => {
|
|
1204
|
+
event.stopPropagation()
|
|
1205
|
+
openEditDialog(activity)
|
|
1206
|
+
}}
|
|
1207
|
+
disabled={pendingAction !== null}
|
|
1208
|
+
>
|
|
1209
|
+
{isUpdatePending ? (
|
|
1210
|
+
<span className="relative flex h-4 w-4 items-center justify-center">
|
|
1211
|
+
<span className="absolute h-4 w-4 animate-spin rounded-full border border-primary border-t-transparent" />
|
|
1212
|
+
</span>
|
|
1213
|
+
) : (
|
|
1214
|
+
<Pencil className="h-4 w-4" />
|
|
1215
|
+
)}
|
|
1216
|
+
</Button>
|
|
1217
|
+
<Button
|
|
1218
|
+
type="button"
|
|
1219
|
+
variant="ghost"
|
|
1220
|
+
size="icon"
|
|
1221
|
+
onClick={(event) => {
|
|
1222
|
+
event.stopPropagation()
|
|
1223
|
+
handleDelete(activity).catch(() => {})
|
|
1224
|
+
}}
|
|
1225
|
+
disabled={pendingAction !== null}
|
|
1226
|
+
>
|
|
1227
|
+
{isDeletePending ? (
|
|
1228
|
+
<span className="relative flex h-4 w-4 items-center justify-center text-destructive">
|
|
1229
|
+
<span className="absolute h-4 w-4 animate-spin rounded-full border border-destructive border-t-transparent" />
|
|
1230
|
+
</span>
|
|
1231
|
+
) : (
|
|
1232
|
+
<Trash2 className="h-4 w-4" />
|
|
1233
|
+
)}
|
|
1234
|
+
</Button>
|
|
1235
|
+
</div>
|
|
1236
|
+
</div>
|
|
1237
|
+
{activity.subject ? <p className="text-sm font-medium">{activity.subject}</p> : null}
|
|
1238
|
+
{activity.body ? (
|
|
1239
|
+
<p className="text-sm whitespace-pre-wrap text-muted-foreground">{activity.body}</p>
|
|
1240
|
+
) : null}
|
|
1241
|
+
{renderCustomFields ? renderCustomFields(activity) : null}
|
|
1242
|
+
{loggedByText ? (
|
|
1243
|
+
<p className="text-xs text-muted-foreground">{loggedByText}</p>
|
|
1244
|
+
) : null}
|
|
1245
|
+
</div>
|
|
1246
|
+
)
|
|
1247
|
+
})
|
|
1248
|
+
: null}
|
|
1249
|
+
{hasMoreActivities ? (
|
|
1250
|
+
<div className="flex justify-center">
|
|
1251
|
+
<Button variant="outline" size="sm" onClick={handleLoadMore} disabled={pendingAction !== null}>
|
|
1252
|
+
{loadMoreLabel}
|
|
1253
|
+
</Button>
|
|
1254
|
+
</div>
|
|
1255
|
+
) : null}
|
|
1256
|
+
</>
|
|
1257
|
+
)}
|
|
1258
|
+
</div>
|
|
1259
|
+
|
|
1260
|
+
<ActivityDialog
|
|
1261
|
+
open={dialogOpen}
|
|
1262
|
+
mode={dialogMode}
|
|
1263
|
+
onOpenChange={handleDialogOpenChange}
|
|
1264
|
+
initialValues={initialValues}
|
|
1265
|
+
onSubmit={async (payload) => {
|
|
1266
|
+
await handleDialogSubmit(payload)
|
|
1267
|
+
}}
|
|
1268
|
+
isSubmitting={Boolean(isFormPending)}
|
|
1269
|
+
activityTypeLabels={activityTypeLabels}
|
|
1270
|
+
loadActivityOptions={loadActivityOptions}
|
|
1271
|
+
createActivityOption={createActivityOption}
|
|
1272
|
+
dealOptions={dealOptions}
|
|
1273
|
+
entityOptions={entityOptions}
|
|
1274
|
+
defaultEntityId={resolvedDefaultEntityId || undefined}
|
|
1275
|
+
manageHref={manageHref}
|
|
1276
|
+
customFieldEntityIds={customFieldEntityIds}
|
|
1277
|
+
labelPrefix={labelPrefix}
|
|
1278
|
+
appearanceLabels={appearanceLabels}
|
|
1279
|
+
/>
|
|
1280
|
+
</div>
|
|
1281
|
+
)
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
export default ActivitiesSection
|