@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,1275 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import dynamic from 'next/dynamic'
|
|
5
|
+
import type { PluggableList } from 'unified'
|
|
6
|
+
import type { AppearanceSelectorLabels } from '@open-mercato/core/modules/dictionaries/components/AppearanceSelector'
|
|
7
|
+
import { AppearanceDialog } from '@open-mercato/core/modules/customers/components/detail/AppearanceDialog'
|
|
8
|
+
import type { IconOption } from '@open-mercato/core/modules/dictionaries/components/dictionaryAppearance'
|
|
9
|
+
import { ArrowUpRightSquare, FileCode, Loader2, Palette, Pencil, Plus, Trash2 } from 'lucide-react'
|
|
10
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
11
|
+
import { flash } from '../FlashMessages'
|
|
12
|
+
import { SwitchableMarkdownInput } from '../inputs/SwitchableMarkdownInput'
|
|
13
|
+
import { ErrorMessage } from './ErrorMessage'
|
|
14
|
+
import { LoadingMessage } from './LoadingMessage'
|
|
15
|
+
import { TabEmptyState } from './TabEmptyState'
|
|
16
|
+
|
|
17
|
+
type Translator = (key: string, fallback?: string, params?: Record<string, string | number>) => string
|
|
18
|
+
|
|
19
|
+
export type SectionAction = {
|
|
20
|
+
label: React.ReactNode
|
|
21
|
+
onClick: () => void
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
icon?: React.ReactNode
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type TabEmptyStateConfig = {
|
|
27
|
+
title: string
|
|
28
|
+
actionLabel: string
|
|
29
|
+
description?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type CommentSummary = {
|
|
33
|
+
id: string
|
|
34
|
+
body: string
|
|
35
|
+
createdAt: string
|
|
36
|
+
authorUserId?: string | null
|
|
37
|
+
authorName?: string | null
|
|
38
|
+
authorEmail?: string | null
|
|
39
|
+
dealId?: string | null
|
|
40
|
+
dealTitle?: string | null
|
|
41
|
+
appearanceIcon?: string | null
|
|
42
|
+
appearanceColor?: string | null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type NotesCreatePayload = {
|
|
46
|
+
entityId: string
|
|
47
|
+
body: string
|
|
48
|
+
appearanceIcon: string | null
|
|
49
|
+
appearanceColor: string | null
|
|
50
|
+
dealId?: string | null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type NotesUpdatePayload = {
|
|
54
|
+
body?: string
|
|
55
|
+
appearanceIcon?: string | null
|
|
56
|
+
appearanceColor?: string | null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type NotesDataAdapter<C = unknown> = {
|
|
60
|
+
list: (params: { entityId: string | null; dealId: string | null; context?: C }) => Promise<CommentSummary[]>
|
|
61
|
+
create: (params: NotesCreatePayload & { context?: C }) => Promise<Partial<CommentSummary> | void>
|
|
62
|
+
update: (params: { id: string; patch: NotesUpdatePayload; context?: C }) => Promise<void>
|
|
63
|
+
delete: (params: { id: string; context?: C }) => Promise<void>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type RenderIconFn = (icon: string, className?: string) => React.ReactNode
|
|
67
|
+
type RenderColorFn = (color: string, className?: string) => React.ReactNode
|
|
68
|
+
|
|
69
|
+
type MarkdownPreviewProps = { children: string; className?: string; remarkPlugins?: PluggableList }
|
|
70
|
+
|
|
71
|
+
const isTestEnv = typeof process !== 'undefined' && process.env.NODE_ENV === 'test'
|
|
72
|
+
|
|
73
|
+
const MarkdownPreviewComponent: React.ComponentType<MarkdownPreviewProps> = isTestEnv
|
|
74
|
+
? ({ children, className }) => <div className={className}>{children}</div>
|
|
75
|
+
: (dynamic(() => import('react-markdown').then((mod) => mod.default as React.ComponentType<MarkdownPreviewProps>), {
|
|
76
|
+
ssr: false,
|
|
77
|
+
loading: () => null,
|
|
78
|
+
}) as unknown as React.ComponentType<MarkdownPreviewProps>)
|
|
79
|
+
|
|
80
|
+
let markdownPluginsPromise: Promise<PluggableList> | null = null
|
|
81
|
+
|
|
82
|
+
async function loadMarkdownPlugins(): Promise<PluggableList> {
|
|
83
|
+
if (isTestEnv) return []
|
|
84
|
+
if (!markdownPluginsPromise) {
|
|
85
|
+
markdownPluginsPromise = import('remark-gfm')
|
|
86
|
+
.then((mod) => [mod.default ?? mod] as PluggableList)
|
|
87
|
+
.catch(() => [])
|
|
88
|
+
}
|
|
89
|
+
return markdownPluginsPromise
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function generateTempId() {
|
|
93
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID()
|
|
94
|
+
return `tmp_${Math.random().toString(36).slice(2)}`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function formatDateTime(value?: string | null): string | null {
|
|
98
|
+
if (!value) return null
|
|
99
|
+
const date = new Date(value)
|
|
100
|
+
if (Number.isNaN(date.getTime())) return null
|
|
101
|
+
return date.toLocaleString()
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatRelativeTime(value?: string | null): string | null {
|
|
105
|
+
if (!value) return null
|
|
106
|
+
const date = new Date(value)
|
|
107
|
+
if (Number.isNaN(date.getTime())) return null
|
|
108
|
+
const now = Date.now()
|
|
109
|
+
const diffSeconds = (date.getTime() - now) / 1000
|
|
110
|
+
const absSeconds = Math.abs(diffSeconds)
|
|
111
|
+
const rtf =
|
|
112
|
+
typeof Intl !== 'undefined' && typeof Intl.RelativeTimeFormat === 'function'
|
|
113
|
+
? new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
|
114
|
+
: null
|
|
115
|
+
const format = (unit: Intl.RelativeTimeFormatUnit, divisor: number) => {
|
|
116
|
+
const valueToFormat = Math.round(diffSeconds / divisor)
|
|
117
|
+
if (rtf) return rtf.format(valueToFormat, unit)
|
|
118
|
+
const suffix = valueToFormat <= 0 ? 'ago' : 'from now'
|
|
119
|
+
const magnitude = Math.abs(valueToFormat)
|
|
120
|
+
return `${magnitude} ${unit}${magnitude === 1 ? '' : 's'} ${suffix}`
|
|
121
|
+
}
|
|
122
|
+
if (absSeconds < 45) return format('second', 1)
|
|
123
|
+
if (absSeconds < 45 * 60) return format('minute', 60)
|
|
124
|
+
if (absSeconds < 24 * 60 * 60) return format('hour', 60 * 60)
|
|
125
|
+
if (absSeconds < 7 * 24 * 60 * 60) return format('day', 24 * 60 * 60)
|
|
126
|
+
if (absSeconds < 30 * 24 * 60 * 60) return format('week', 7 * 24 * 60 * 60)
|
|
127
|
+
if (absSeconds < 365 * 24 * 60 * 60) return format('month', 30 * 24 * 60 * 60)
|
|
128
|
+
return format('year', 365 * 24 * 60 * 60)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
type TimelineItemHeaderProps = {
|
|
132
|
+
title: React.ReactNode
|
|
133
|
+
subtitle?: React.ReactNode
|
|
134
|
+
timestamp?: string | Date | null
|
|
135
|
+
fallbackTimestampLabel?: React.ReactNode
|
|
136
|
+
icon?: string | null
|
|
137
|
+
color?: string | null
|
|
138
|
+
iconSize?: 'sm' | 'md'
|
|
139
|
+
className?: string
|
|
140
|
+
renderIcon?: RenderIconFn
|
|
141
|
+
renderColor?: RenderColorFn
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function TimelineItemHeader({
|
|
145
|
+
title,
|
|
146
|
+
subtitle,
|
|
147
|
+
timestamp,
|
|
148
|
+
fallbackTimestampLabel,
|
|
149
|
+
icon,
|
|
150
|
+
color,
|
|
151
|
+
iconSize = 'md',
|
|
152
|
+
className,
|
|
153
|
+
renderIcon,
|
|
154
|
+
renderColor,
|
|
155
|
+
}: TimelineItemHeaderProps) {
|
|
156
|
+
const wrapperSize = iconSize === 'sm' ? 'h-6 w-6' : 'h-8 w-8'
|
|
157
|
+
const iconSizeClass = iconSize === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'
|
|
158
|
+
const resolvedTimestamp = React.useMemo(() => {
|
|
159
|
+
if (subtitle) return subtitle
|
|
160
|
+
if (!timestamp) return fallbackTimestampLabel ?? null
|
|
161
|
+
const value = typeof timestamp === 'string' ? timestamp : timestamp.toISOString()
|
|
162
|
+
const date = new Date(value)
|
|
163
|
+
if (Number.isNaN(date.getTime())) return fallbackTimestampLabel ?? null
|
|
164
|
+
const now = Date.now()
|
|
165
|
+
const diff = Math.abs(now - date.getTime())
|
|
166
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000
|
|
167
|
+
const relativeLabel = diff <= THIRTY_DAYS_MS ? formatRelativeTime(value) : null
|
|
168
|
+
const absoluteLabel = formatDateTime(value)
|
|
169
|
+
if (relativeLabel) {
|
|
170
|
+
return (
|
|
171
|
+
<span title={absoluteLabel ?? undefined}>
|
|
172
|
+
{relativeLabel}
|
|
173
|
+
</span>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
return absoluteLabel ?? fallbackTimestampLabel ?? null
|
|
177
|
+
}, [fallbackTimestampLabel, subtitle, timestamp])
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div className={['flex items-start gap-3', className].filter(Boolean).join(' ')}>
|
|
181
|
+
{icon && renderIcon ? (
|
|
182
|
+
<span className={['inline-flex items-center justify-center rounded border border-border bg-muted/40', wrapperSize].join(' ')}>
|
|
183
|
+
{renderIcon(icon, iconSizeClass)}
|
|
184
|
+
</span>
|
|
185
|
+
) : null}
|
|
186
|
+
<div className="space-y-1">
|
|
187
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
188
|
+
<span className="text-sm font-semibold text-foreground">{title}</span>
|
|
189
|
+
{color && renderColor ? renderColor(color, 'h-3 w-3 rounded-full border border-border') : null}
|
|
190
|
+
</div>
|
|
191
|
+
{resolvedTimestamp ? <div className="text-xs text-muted-foreground">{resolvedTimestamp}</div> : null}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export type NotesSectionProps<C = unknown> = {
|
|
198
|
+
entityId: string | null
|
|
199
|
+
dealId?: string | null
|
|
200
|
+
emptyLabel: string
|
|
201
|
+
viewerUserId: string | null
|
|
202
|
+
viewerName?: string | null
|
|
203
|
+
viewerEmail?: string | null
|
|
204
|
+
addActionLabel: string
|
|
205
|
+
emptyState: TabEmptyStateConfig
|
|
206
|
+
onActionChange?: (action: SectionAction | null) => void
|
|
207
|
+
translator?: Translator
|
|
208
|
+
labelPrefix?: string
|
|
209
|
+
inlineLabelPrefix?: string
|
|
210
|
+
onLoadingChange?: (isLoading: boolean) => void
|
|
211
|
+
dealOptions?: Array<{ id: string; label: string }>
|
|
212
|
+
entityOptions?: Array<{ id: string; label: string }>
|
|
213
|
+
dataAdapter: NotesDataAdapter<C>
|
|
214
|
+
dataContext?: C
|
|
215
|
+
renderIcon?: RenderIconFn
|
|
216
|
+
renderColor?: RenderColorFn
|
|
217
|
+
iconSuggestions?: IconOption[]
|
|
218
|
+
readMarkdownPreference?: () => boolean | null
|
|
219
|
+
writeMarkdownPreference?: (value: boolean) => void
|
|
220
|
+
disableMarkdown?: boolean
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function sanitizeHexColor(value: string | null): string | null {
|
|
224
|
+
if (!value) return null
|
|
225
|
+
const trimmed = value.trim()
|
|
226
|
+
return /^#([0-9a-f]{6})$/i.test(trimmed) ? trimmed.toLowerCase() : null
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function mapCommentSummary(input: unknown): CommentSummary {
|
|
230
|
+
const data = (typeof input === 'object' && input !== null ? input : {}) as Record<string, unknown>
|
|
231
|
+
const id = typeof data.id === 'string' ? data.id : generateTempId()
|
|
232
|
+
const body = typeof data.body === 'string' ? data.body : ''
|
|
233
|
+
const createdAt =
|
|
234
|
+
typeof data.createdAt === 'string'
|
|
235
|
+
? data.createdAt
|
|
236
|
+
: typeof data.created_at === 'string'
|
|
237
|
+
? data.created_at
|
|
238
|
+
: new Date().toISOString()
|
|
239
|
+
const authorUserId =
|
|
240
|
+
typeof data.authorUserId === 'string'
|
|
241
|
+
? data.authorUserId
|
|
242
|
+
: typeof data.author_user_id === 'string'
|
|
243
|
+
? data.author_user_id
|
|
244
|
+
: null
|
|
245
|
+
const authorName =
|
|
246
|
+
typeof data.authorName === 'string'
|
|
247
|
+
? data.authorName
|
|
248
|
+
: typeof data.author_name === 'string'
|
|
249
|
+
? data.author_name
|
|
250
|
+
: null
|
|
251
|
+
const authorEmail =
|
|
252
|
+
typeof data.authorEmail === 'string'
|
|
253
|
+
? data.authorEmail
|
|
254
|
+
: typeof data.author_email === 'string'
|
|
255
|
+
? data.author_email
|
|
256
|
+
: null
|
|
257
|
+
const dealId =
|
|
258
|
+
typeof data.dealId === 'string'
|
|
259
|
+
? data.dealId
|
|
260
|
+
: typeof data.deal_id === 'string'
|
|
261
|
+
? data.deal_id
|
|
262
|
+
: null
|
|
263
|
+
const dealTitle =
|
|
264
|
+
typeof data.dealTitle === 'string'
|
|
265
|
+
? data.dealTitle
|
|
266
|
+
: typeof data.deal_title === 'string'
|
|
267
|
+
? data.deal_title
|
|
268
|
+
: null
|
|
269
|
+
const appearanceIcon =
|
|
270
|
+
typeof data.appearanceIcon === 'string'
|
|
271
|
+
? data.appearanceIcon
|
|
272
|
+
: typeof data.appearance_icon === 'string'
|
|
273
|
+
? data.appearance_icon
|
|
274
|
+
: null
|
|
275
|
+
const appearanceColor =
|
|
276
|
+
typeof data.appearanceColor === 'string'
|
|
277
|
+
? data.appearanceColor
|
|
278
|
+
: typeof data.appearance_color === 'string'
|
|
279
|
+
? data.appearance_color
|
|
280
|
+
: null
|
|
281
|
+
return {
|
|
282
|
+
id,
|
|
283
|
+
body,
|
|
284
|
+
createdAt,
|
|
285
|
+
authorUserId,
|
|
286
|
+
authorName,
|
|
287
|
+
authorEmail,
|
|
288
|
+
dealId,
|
|
289
|
+
dealTitle,
|
|
290
|
+
appearanceIcon,
|
|
291
|
+
appearanceColor,
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function NotesSection<C = unknown>({
|
|
296
|
+
entityId,
|
|
297
|
+
dealId,
|
|
298
|
+
emptyLabel,
|
|
299
|
+
viewerUserId,
|
|
300
|
+
viewerName,
|
|
301
|
+
viewerEmail,
|
|
302
|
+
addActionLabel,
|
|
303
|
+
emptyState,
|
|
304
|
+
onActionChange,
|
|
305
|
+
translator,
|
|
306
|
+
labelPrefix = 'customers.people.detail.notes',
|
|
307
|
+
inlineLabelPrefix = 'customers.people.detail.inline',
|
|
308
|
+
onLoadingChange,
|
|
309
|
+
dealOptions,
|
|
310
|
+
entityOptions,
|
|
311
|
+
dataAdapter,
|
|
312
|
+
dataContext,
|
|
313
|
+
renderIcon,
|
|
314
|
+
renderColor,
|
|
315
|
+
iconSuggestions,
|
|
316
|
+
readMarkdownPreference,
|
|
317
|
+
writeMarkdownPreference,
|
|
318
|
+
disableMarkdown,
|
|
319
|
+
}: NotesSectionProps<C>) {
|
|
320
|
+
const t = React.useMemo<Translator>(() => translator ?? ((key, fallback) => fallback ?? key), [translator])
|
|
321
|
+
const label = React.useCallback(
|
|
322
|
+
(suffix: string, fallback?: string, params?: Record<string, string | number>) =>
|
|
323
|
+
t(`${labelPrefix}.${suffix}`, fallback, params),
|
|
324
|
+
[labelPrefix, t],
|
|
325
|
+
)
|
|
326
|
+
const inlineLabel = React.useCallback(
|
|
327
|
+
(suffix: string, fallback?: string, params?: Record<string, string | number>) =>
|
|
328
|
+
t(`${inlineLabelPrefix}.${suffix}`, fallback, params),
|
|
329
|
+
[inlineLabelPrefix, t],
|
|
330
|
+
)
|
|
331
|
+
const [markdownPlugins, setMarkdownPlugins] = React.useState<PluggableList>([])
|
|
332
|
+
React.useEffect(() => {
|
|
333
|
+
if (isTestEnv) return
|
|
334
|
+
let mounted = true
|
|
335
|
+
void loadMarkdownPlugins().then((plugins) => {
|
|
336
|
+
if (!mounted) return
|
|
337
|
+
setMarkdownPlugins(plugins)
|
|
338
|
+
})
|
|
339
|
+
return () => {
|
|
340
|
+
mounted = false
|
|
341
|
+
}
|
|
342
|
+
}, [])
|
|
343
|
+
|
|
344
|
+
const normalizedDealOptions = React.useMemo(() => {
|
|
345
|
+
if (!Array.isArray(dealOptions)) return []
|
|
346
|
+
const seen = new Set<string>()
|
|
347
|
+
return dealOptions
|
|
348
|
+
.map((option) => {
|
|
349
|
+
if (!option || typeof option !== 'object') return null
|
|
350
|
+
const id = typeof option.id === 'string' ? option.id.trim() : ''
|
|
351
|
+
if (!id || seen.has(id)) return null
|
|
352
|
+
const label =
|
|
353
|
+
typeof option.label === 'string' && option.label.trim().length
|
|
354
|
+
? option.label.trim()
|
|
355
|
+
: id
|
|
356
|
+
seen.add(id)
|
|
357
|
+
return { id, label }
|
|
358
|
+
})
|
|
359
|
+
.filter((option): option is { id: string; label: string } => !!option)
|
|
360
|
+
}, [dealOptions])
|
|
361
|
+
|
|
362
|
+
const dealLabelMap = React.useMemo(() => {
|
|
363
|
+
const map = new Map<string, string>()
|
|
364
|
+
normalizedDealOptions.forEach((option) => {
|
|
365
|
+
map.set(option.id, option.label)
|
|
366
|
+
})
|
|
367
|
+
return map
|
|
368
|
+
}, [normalizedDealOptions])
|
|
369
|
+
|
|
370
|
+
const normalizedEntityOptions = React.useMemo(() => {
|
|
371
|
+
if (!Array.isArray(entityOptions)) return []
|
|
372
|
+
const seen = new Set<string>()
|
|
373
|
+
return entityOptions
|
|
374
|
+
.map((option) => {
|
|
375
|
+
if (!option || typeof option !== 'object') return null
|
|
376
|
+
const id = typeof option.id === 'string' ? option.id.trim() : ''
|
|
377
|
+
if (!id || seen.has(id)) return null
|
|
378
|
+
const label =
|
|
379
|
+
typeof option.label === 'string' && option.label.trim().length
|
|
380
|
+
? option.label.trim()
|
|
381
|
+
: id
|
|
382
|
+
seen.add(id)
|
|
383
|
+
return { id, label }
|
|
384
|
+
})
|
|
385
|
+
.filter((option): option is { id: string; label: string } => !!option)
|
|
386
|
+
}, [entityOptions])
|
|
387
|
+
|
|
388
|
+
const [selectedDealId, setSelectedDealId] = React.useState<string>(() => {
|
|
389
|
+
const initial = typeof dealId === 'string' ? dealId.trim() : ''
|
|
390
|
+
return initial
|
|
391
|
+
})
|
|
392
|
+
React.useEffect(() => {
|
|
393
|
+
const initial = typeof dealId === 'string' ? dealId.trim() : ''
|
|
394
|
+
if (initial !== selectedDealId) {
|
|
395
|
+
setSelectedDealId(initial)
|
|
396
|
+
}
|
|
397
|
+
}, [dealId, selectedDealId])
|
|
398
|
+
|
|
399
|
+
const [selectedEntityId, setSelectedEntityId] = React.useState<string>(() => {
|
|
400
|
+
if (normalizedEntityOptions.length) return normalizedEntityOptions[0].id
|
|
401
|
+
return typeof entityId === 'string' ? entityId : ''
|
|
402
|
+
})
|
|
403
|
+
React.useEffect(() => {
|
|
404
|
+
if (normalizedEntityOptions.length) {
|
|
405
|
+
if (!normalizedEntityOptions.some((option) => option.id === selectedEntityId)) {
|
|
406
|
+
setSelectedEntityId(normalizedEntityOptions[0].id)
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
const initial = typeof entityId === 'string' ? entityId : ''
|
|
410
|
+
if (initial !== selectedEntityId) {
|
|
411
|
+
setSelectedEntityId(initial)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}, [entityId, normalizedEntityOptions, selectedEntityId])
|
|
415
|
+
|
|
416
|
+
const resolvedEntityId = React.useMemo(() => {
|
|
417
|
+
if (normalizedEntityOptions.length) return selectedEntityId
|
|
418
|
+
return typeof entityId === 'string' ? entityId : ''
|
|
419
|
+
}, [entityId, normalizedEntityOptions, selectedEntityId])
|
|
420
|
+
|
|
421
|
+
const resolvedDealId = React.useMemo(() => {
|
|
422
|
+
const trimmed = typeof selectedDealId === 'string' ? selectedDealId.trim() : ''
|
|
423
|
+
return trimmed
|
|
424
|
+
}, [selectedDealId])
|
|
425
|
+
|
|
426
|
+
const hasEntity = resolvedEntityId.length > 0
|
|
427
|
+
|
|
428
|
+
const [notes, setNotes] = React.useState<CommentSummary[]>([])
|
|
429
|
+
const [isLoading, setIsLoading] = React.useState<boolean>(() => Boolean(entityId || dealId))
|
|
430
|
+
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
|
431
|
+
const [loadError, setLoadError] = React.useState<string | null>(null)
|
|
432
|
+
const pendingCounterRef = React.useRef(0)
|
|
433
|
+
|
|
434
|
+
const pushLoading = React.useCallback(() => {
|
|
435
|
+
pendingCounterRef.current += 1
|
|
436
|
+
if (pendingCounterRef.current === 1) {
|
|
437
|
+
onLoadingChange?.(true)
|
|
438
|
+
}
|
|
439
|
+
}, [onLoadingChange])
|
|
440
|
+
|
|
441
|
+
const popLoading = React.useCallback(() => {
|
|
442
|
+
pendingCounterRef.current = Math.max(0, pendingCounterRef.current - 1)
|
|
443
|
+
if (pendingCounterRef.current === 0) {
|
|
444
|
+
onLoadingChange?.(false)
|
|
445
|
+
}
|
|
446
|
+
}, [onLoadingChange])
|
|
447
|
+
|
|
448
|
+
const [composerOpen, setComposerOpen] = React.useState(false)
|
|
449
|
+
const [draftBody, setDraftBody] = React.useState('')
|
|
450
|
+
const [draftIcon, setDraftIcon] = React.useState<string | null>(null)
|
|
451
|
+
const [draftColor, setDraftColor] = React.useState<string | null>(null)
|
|
452
|
+
const [isMarkdownEnabled, setIsMarkdownEnabled] = React.useState(false)
|
|
453
|
+
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null)
|
|
454
|
+
const formRef = React.useRef<HTMLFormElement | null>(null)
|
|
455
|
+
const focusComposer = React.useCallback(() => {
|
|
456
|
+
if (!hasEntity) return
|
|
457
|
+
setComposerOpen(true)
|
|
458
|
+
window.requestAnimationFrame(() => {
|
|
459
|
+
if (isMarkdownEnabled) {
|
|
460
|
+
const markdownTextarea = formRef.current?.querySelector('textarea')
|
|
461
|
+
if (markdownTextarea instanceof HTMLTextAreaElement) {
|
|
462
|
+
markdownTextarea.focus()
|
|
463
|
+
markdownTextarea.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
464
|
+
return
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
const element = textareaRef.current
|
|
468
|
+
if (!element) return
|
|
469
|
+
element.focus()
|
|
470
|
+
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
471
|
+
})
|
|
472
|
+
}, [formRef, hasEntity, isMarkdownEnabled])
|
|
473
|
+
const [appearanceDialogState, setAppearanceDialogState] = React.useState<
|
|
474
|
+
| { mode: 'create'; icon: string | null; color: string | null }
|
|
475
|
+
| { mode: 'edit'; noteId: string; icon: string | null; color: string | null }
|
|
476
|
+
| null
|
|
477
|
+
>(null)
|
|
478
|
+
const [appearanceDialogSaving, setAppearanceDialogSaving] = React.useState(false)
|
|
479
|
+
const [appearanceDialogError, setAppearanceDialogError] = React.useState<string | null>(null)
|
|
480
|
+
const [contentEditor, setContentEditor] = React.useState<{ id: string; value: string }>({ id: '', value: '' })
|
|
481
|
+
const [contentSavingId, setContentSavingId] = React.useState<string | null>(null)
|
|
482
|
+
const [contentError, setContentError] = React.useState<string | null>(null)
|
|
483
|
+
const contentTextareaRef = React.useRef<HTMLTextAreaElement | null>(null)
|
|
484
|
+
const [visibleCount, setVisibleCount] = React.useState(0)
|
|
485
|
+
const [deletingNoteId, setDeletingNoteId] = React.useState<string | null>(null)
|
|
486
|
+
|
|
487
|
+
React.useEffect(() => {
|
|
488
|
+
const queryEntityId = typeof entityId === 'string' ? entityId : ''
|
|
489
|
+
const queryDealId = typeof dealId === 'string' ? dealId : ''
|
|
490
|
+
if (!queryEntityId && !queryDealId) {
|
|
491
|
+
setNotes([])
|
|
492
|
+
setLoadError(null)
|
|
493
|
+
setIsLoading(false)
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
let cancelled = false
|
|
497
|
+
setIsLoading(true)
|
|
498
|
+
setLoadError(null)
|
|
499
|
+
pushLoading()
|
|
500
|
+
async function loadNotes() {
|
|
501
|
+
try {
|
|
502
|
+
const mapped = await dataAdapter.list({
|
|
503
|
+
entityId: queryEntityId || null,
|
|
504
|
+
dealId: queryDealId || null,
|
|
505
|
+
context: dataContext,
|
|
506
|
+
})
|
|
507
|
+
if (cancelled) return
|
|
508
|
+
setNotes(mapped)
|
|
509
|
+
} catch (err) {
|
|
510
|
+
if (cancelled) return
|
|
511
|
+
const message =
|
|
512
|
+
err instanceof Error ? err.message : label('loadError', 'Failed to load notes.')
|
|
513
|
+
setNotes([])
|
|
514
|
+
setLoadError(message)
|
|
515
|
+
flash(message, 'error')
|
|
516
|
+
} finally {
|
|
517
|
+
if (!cancelled) setIsLoading(false)
|
|
518
|
+
popLoading()
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
loadNotes().catch(() => {})
|
|
522
|
+
return () => {
|
|
523
|
+
cancelled = true
|
|
524
|
+
}
|
|
525
|
+
}, [dataAdapter, dataContext, dealId, entityId, popLoading, pushLoading, t])
|
|
526
|
+
|
|
527
|
+
const youLabel = label('you', 'You')
|
|
528
|
+
const viewerLabel = React.useMemo(() => viewerName ?? viewerEmail ?? null, [viewerEmail, viewerName])
|
|
529
|
+
|
|
530
|
+
const handleMarkdownToggle = React.useCallback(() => {
|
|
531
|
+
setIsMarkdownEnabled((prev) => {
|
|
532
|
+
const next = !prev
|
|
533
|
+
if (writeMarkdownPreference) {
|
|
534
|
+
writeMarkdownPreference(next)
|
|
535
|
+
}
|
|
536
|
+
return next
|
|
537
|
+
})
|
|
538
|
+
}, [writeMarkdownPreference])
|
|
539
|
+
|
|
540
|
+
React.useEffect(() => {
|
|
541
|
+
if (!onActionChange) return
|
|
542
|
+
if (!notes.length) {
|
|
543
|
+
onActionChange(null)
|
|
544
|
+
return
|
|
545
|
+
}
|
|
546
|
+
onActionChange({
|
|
547
|
+
label: addActionLabel,
|
|
548
|
+
onClick: focusComposer,
|
|
549
|
+
disabled: isSubmitting || isLoading || !hasEntity,
|
|
550
|
+
icon: <Plus className="mr-2 h-4 w-4" />,
|
|
551
|
+
})
|
|
552
|
+
return () => onActionChange(null)
|
|
553
|
+
}, [onActionChange, addActionLabel, focusComposer, hasEntity, isLoading, isSubmitting, notes.length])
|
|
554
|
+
|
|
555
|
+
const adjustTextareaSize = React.useCallback((element: HTMLTextAreaElement | null) => {
|
|
556
|
+
if (!element) return
|
|
557
|
+
element.style.height = 'auto'
|
|
558
|
+
element.style.height = `${element.scrollHeight}px`
|
|
559
|
+
}, [])
|
|
560
|
+
|
|
561
|
+
React.useEffect(() => {
|
|
562
|
+
adjustTextareaSize(textareaRef.current)
|
|
563
|
+
}, [adjustTextareaSize, draftBody, isMarkdownEnabled, composerOpen])
|
|
564
|
+
|
|
565
|
+
React.useEffect(() => {
|
|
566
|
+
const preference = readMarkdownPreference ? readMarkdownPreference() : null
|
|
567
|
+
if (preference !== null) {
|
|
568
|
+
setIsMarkdownEnabled(preference)
|
|
569
|
+
}
|
|
570
|
+
}, [readMarkdownPreference])
|
|
571
|
+
|
|
572
|
+
React.useEffect(() => {
|
|
573
|
+
if (!notes.length) {
|
|
574
|
+
setVisibleCount(0)
|
|
575
|
+
return
|
|
576
|
+
}
|
|
577
|
+
const baseline = Math.min(5, notes.length)
|
|
578
|
+
setVisibleCount((prev) => {
|
|
579
|
+
if (prev >= notes.length) return prev
|
|
580
|
+
return Math.min(Math.max(prev, baseline), notes.length)
|
|
581
|
+
})
|
|
582
|
+
}, [notes.length])
|
|
583
|
+
|
|
584
|
+
React.useEffect(() => {
|
|
585
|
+
if (hasEntity) return
|
|
586
|
+
setComposerOpen(false)
|
|
587
|
+
setDraftBody('')
|
|
588
|
+
setDraftIcon(null)
|
|
589
|
+
setDraftColor(null)
|
|
590
|
+
}, [hasEntity])
|
|
591
|
+
|
|
592
|
+
const visibleNotes = React.useMemo(() => notes.slice(0, visibleCount), [notes, visibleCount])
|
|
593
|
+
const hasVisibleNotes = React.useMemo(() => visibleCount > 0, [visibleCount])
|
|
594
|
+
|
|
595
|
+
const loadMoreLabel = label('loadMore')
|
|
596
|
+
|
|
597
|
+
const handleCreateNote = React.useCallback(
|
|
598
|
+
async (input: { body: string; appearanceIcon: string | null; appearanceColor: string | null }) => {
|
|
599
|
+
if (!hasEntity || !resolvedEntityId) {
|
|
600
|
+
flash(label('entityMissing', 'Unable to determine current person.'), 'error')
|
|
601
|
+
return false
|
|
602
|
+
}
|
|
603
|
+
const body = input.body.trim()
|
|
604
|
+
if (!body) {
|
|
605
|
+
focusComposer()
|
|
606
|
+
return false
|
|
607
|
+
}
|
|
608
|
+
const icon = input.appearanceIcon && input.appearanceIcon.trim().length ? input.appearanceIcon.trim() : null
|
|
609
|
+
const color = sanitizeHexColor(input.appearanceColor)
|
|
610
|
+
const targetDealId = resolvedDealId.length ? resolvedDealId : null
|
|
611
|
+
const dealLabel = targetDealId ? dealLabelMap.get(targetDealId) ?? null : null
|
|
612
|
+
setIsSubmitting(true)
|
|
613
|
+
pushLoading()
|
|
614
|
+
try {
|
|
615
|
+
const responseBody =
|
|
616
|
+
(await dataAdapter.create({
|
|
617
|
+
entityId: resolvedEntityId,
|
|
618
|
+
body,
|
|
619
|
+
appearanceIcon: icon,
|
|
620
|
+
appearanceColor: color,
|
|
621
|
+
dealId: targetDealId,
|
|
622
|
+
context: dataContext,
|
|
623
|
+
})) ?? {}
|
|
624
|
+
setNotes((prev) => {
|
|
625
|
+
const viewerId = viewerUserId ?? null
|
|
626
|
+
const resolvedAuthorId =
|
|
627
|
+
typeof responseBody?.authorUserId === 'string' ? responseBody.authorUserId : viewerId ?? null
|
|
628
|
+
const resolvedAuthorName = (() => {
|
|
629
|
+
if (resolvedAuthorId && viewerId && resolvedAuthorId === viewerId) {
|
|
630
|
+
return youLabel
|
|
631
|
+
}
|
|
632
|
+
return typeof responseBody?.authorName === 'string' ? responseBody.authorName : viewerLabel
|
|
633
|
+
})()
|
|
634
|
+
const resolvedAuthorEmail = (() => {
|
|
635
|
+
if (resolvedAuthorId && viewerId && resolvedAuthorId === viewerId) {
|
|
636
|
+
return viewerEmail ?? null
|
|
637
|
+
}
|
|
638
|
+
return typeof responseBody?.authorEmail === 'string' ? responseBody.authorEmail : null
|
|
639
|
+
})()
|
|
640
|
+
const newNote: CommentSummary = {
|
|
641
|
+
id: typeof responseBody?.id === 'string' ? responseBody.id : generateTempId(),
|
|
642
|
+
body,
|
|
643
|
+
createdAt: new Date().toISOString(),
|
|
644
|
+
authorUserId: resolvedAuthorId,
|
|
645
|
+
authorName: resolvedAuthorName,
|
|
646
|
+
authorEmail: resolvedAuthorEmail,
|
|
647
|
+
dealId: targetDealId,
|
|
648
|
+
dealTitle: dealLabel,
|
|
649
|
+
appearanceIcon: icon,
|
|
650
|
+
appearanceColor: color,
|
|
651
|
+
}
|
|
652
|
+
return [newNote, ...prev]
|
|
653
|
+
})
|
|
654
|
+
flash(label('success'), 'success')
|
|
655
|
+
return true
|
|
656
|
+
} catch (err) {
|
|
657
|
+
const message = err instanceof Error ? err.message : label('error')
|
|
658
|
+
flash(message, 'error')
|
|
659
|
+
return false
|
|
660
|
+
} finally {
|
|
661
|
+
setIsSubmitting(false)
|
|
662
|
+
popLoading()
|
|
663
|
+
}
|
|
664
|
+
},
|
|
665
|
+
[dataAdapter, dataContext, dealLabelMap, focusComposer, hasEntity, popLoading, pushLoading, resolvedDealId, resolvedEntityId, t, viewerEmail, viewerLabel, viewerUserId, youLabel],
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
const handleUpdateNote = React.useCallback(
|
|
669
|
+
async (noteId: string, patch: { body?: string; appearanceIcon?: string | null; appearanceColor?: string | null }) => {
|
|
670
|
+
const sanitizedBody = patch.body
|
|
671
|
+
const sanitizedIcon =
|
|
672
|
+
patch.appearanceIcon !== undefined && patch.appearanceIcon !== null && patch.appearanceIcon.trim().length
|
|
673
|
+
? patch.appearanceIcon.trim()
|
|
674
|
+
: patch.appearanceIcon === null
|
|
675
|
+
? null
|
|
676
|
+
: undefined
|
|
677
|
+
const sanitizedColor =
|
|
678
|
+
patch.appearanceColor !== undefined ? sanitizeHexColor(patch.appearanceColor ?? null) : undefined
|
|
679
|
+
try {
|
|
680
|
+
await dataAdapter.update({
|
|
681
|
+
id: noteId,
|
|
682
|
+
patch: {
|
|
683
|
+
body: sanitizedBody,
|
|
684
|
+
appearanceIcon: sanitizedIcon,
|
|
685
|
+
appearanceColor: sanitizedColor,
|
|
686
|
+
},
|
|
687
|
+
context: dataContext,
|
|
688
|
+
})
|
|
689
|
+
setNotes((prev) => {
|
|
690
|
+
const nextComments = prev.map((comment) => {
|
|
691
|
+
if (comment.id !== noteId) return comment
|
|
692
|
+
const next = { ...comment }
|
|
693
|
+
if (sanitizedBody !== undefined) next.body = sanitizedBody
|
|
694
|
+
if (sanitizedIcon !== undefined) next.appearanceIcon = sanitizedIcon ?? null
|
|
695
|
+
if (sanitizedColor !== undefined) next.appearanceColor = sanitizedColor ?? null
|
|
696
|
+
return next
|
|
697
|
+
})
|
|
698
|
+
return nextComments
|
|
699
|
+
})
|
|
700
|
+
flash(label('updateSuccess'), 'success')
|
|
701
|
+
} catch (error) {
|
|
702
|
+
const message = error instanceof Error ? error.message : label('updateError')
|
|
703
|
+
flash(message, 'error')
|
|
704
|
+
throw error instanceof Error ? error : new Error(message)
|
|
705
|
+
}
|
|
706
|
+
},
|
|
707
|
+
[dataAdapter, dataContext, t],
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
const handleDeleteNote = React.useCallback(
|
|
711
|
+
async (note: CommentSummary) => {
|
|
712
|
+
const confirmed =
|
|
713
|
+
typeof window === 'undefined'
|
|
714
|
+
? true
|
|
715
|
+
: window.confirm(label('deleteConfirm', 'Delete this note? This action cannot be undone.'))
|
|
716
|
+
if (!confirmed) return
|
|
717
|
+
setDeletingNoteId(note.id)
|
|
718
|
+
pushLoading()
|
|
719
|
+
try {
|
|
720
|
+
await dataAdapter.delete({ id: note.id, context: dataContext })
|
|
721
|
+
setNotes((prev) => prev.filter((existing) => existing.id !== note.id))
|
|
722
|
+
flash(label('deleteSuccess', 'Note deleted'), 'success')
|
|
723
|
+
} catch (err) {
|
|
724
|
+
const message = err instanceof Error ? err.message : label('deleteError', 'Failed to delete note')
|
|
725
|
+
flash(message, 'error')
|
|
726
|
+
} finally {
|
|
727
|
+
setDeletingNoteId(null)
|
|
728
|
+
popLoading()
|
|
729
|
+
}
|
|
730
|
+
},
|
|
731
|
+
[dataAdapter, dataContext, popLoading, pushLoading, t],
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
const handleSubmit = React.useCallback(
|
|
735
|
+
async (event: React.FormEvent<HTMLFormElement>) => {
|
|
736
|
+
event.preventDefault()
|
|
737
|
+
const created = await handleCreateNote({
|
|
738
|
+
body: draftBody,
|
|
739
|
+
appearanceIcon: draftIcon,
|
|
740
|
+
appearanceColor: draftColor,
|
|
741
|
+
})
|
|
742
|
+
if (created) {
|
|
743
|
+
setDraftBody('')
|
|
744
|
+
setDraftIcon(null)
|
|
745
|
+
setDraftColor(null)
|
|
746
|
+
}
|
|
747
|
+
},
|
|
748
|
+
[draftBody, draftColor, draftIcon, handleCreateNote],
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
const handleLoadMore = React.useCallback(() => {
|
|
752
|
+
setVisibleCount((prev) => {
|
|
753
|
+
if (prev >= notes.length) return prev
|
|
754
|
+
return Math.min(prev + 5, notes.length)
|
|
755
|
+
})
|
|
756
|
+
}, [notes.length])
|
|
757
|
+
|
|
758
|
+
const handleAppearanceDialogSubmit = React.useCallback(async () => {
|
|
759
|
+
if (!appearanceDialogState) return
|
|
760
|
+
setAppearanceDialogError(null)
|
|
761
|
+
const sanitizedIcon =
|
|
762
|
+
appearanceDialogState.icon && appearanceDialogState.icon.trim().length
|
|
763
|
+
? appearanceDialogState.icon.trim()
|
|
764
|
+
: null
|
|
765
|
+
const sanitizedColor = sanitizeHexColor(appearanceDialogState.color ?? null)
|
|
766
|
+
if (appearanceDialogState.mode === 'create') {
|
|
767
|
+
setDraftIcon(sanitizedIcon)
|
|
768
|
+
setDraftColor(sanitizedColor)
|
|
769
|
+
setAppearanceDialogState(null)
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
setAppearanceDialogSaving(true)
|
|
773
|
+
try {
|
|
774
|
+
await handleUpdateNote(appearanceDialogState.noteId, {
|
|
775
|
+
appearanceIcon: sanitizedIcon,
|
|
776
|
+
appearanceColor: sanitizedColor,
|
|
777
|
+
})
|
|
778
|
+
setAppearanceDialogState(null)
|
|
779
|
+
} catch (err) {
|
|
780
|
+
const message =
|
|
781
|
+
err instanceof Error
|
|
782
|
+
? err.message
|
|
783
|
+
: label('appearance.error', 'Failed to update appearance.')
|
|
784
|
+
setAppearanceDialogError(message)
|
|
785
|
+
} finally {
|
|
786
|
+
setAppearanceDialogSaving(false)
|
|
787
|
+
}
|
|
788
|
+
}, [appearanceDialogState, handleUpdateNote, t])
|
|
789
|
+
|
|
790
|
+
const handleAppearanceDialogClose = React.useCallback(() => {
|
|
791
|
+
if (appearanceDialogSaving) return
|
|
792
|
+
setAppearanceDialogState(null)
|
|
793
|
+
setAppearanceDialogError(null)
|
|
794
|
+
}, [appearanceDialogSaving])
|
|
795
|
+
|
|
796
|
+
const handleContentSave = React.useCallback(async () => {
|
|
797
|
+
if (!contentEditor.id) return
|
|
798
|
+
const trimmed = contentEditor.value.trim()
|
|
799
|
+
if (!trimmed) {
|
|
800
|
+
setContentError(label('updateError', 'Failed to update note'))
|
|
801
|
+
return
|
|
802
|
+
}
|
|
803
|
+
setContentSavingId(contentEditor.id)
|
|
804
|
+
setContentError(null)
|
|
805
|
+
try {
|
|
806
|
+
await handleUpdateNote(contentEditor.id, { body: trimmed })
|
|
807
|
+
setContentEditor({ id: '', value: '' })
|
|
808
|
+
} catch (err) {
|
|
809
|
+
const message =
|
|
810
|
+
err instanceof Error ? err.message : label('updateError', 'Failed to update note')
|
|
811
|
+
setContentError(message)
|
|
812
|
+
} finally {
|
|
813
|
+
setContentSavingId(null)
|
|
814
|
+
}
|
|
815
|
+
}, [contentEditor, handleUpdateNote, t])
|
|
816
|
+
|
|
817
|
+
const handleContentEditorKeyDown = React.useCallback(
|
|
818
|
+
(event: React.KeyboardEvent) => {
|
|
819
|
+
if (!contentEditor.id) return
|
|
820
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
821
|
+
event.preventDefault()
|
|
822
|
+
if (!contentSavingId) void handleContentSave()
|
|
823
|
+
return
|
|
824
|
+
}
|
|
825
|
+
if (event.key === 'Escape') {
|
|
826
|
+
event.preventDefault()
|
|
827
|
+
setContentEditor({ id: '', value: '' })
|
|
828
|
+
setContentError(null)
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
[contentEditor.id, contentSavingId, handleContentSave],
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
const handleComposerKeyDown = React.useCallback(
|
|
835
|
+
(event: React.KeyboardEvent<HTMLFormElement>) => {
|
|
836
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
837
|
+
event.preventDefault()
|
|
838
|
+
formRef.current?.requestSubmit()
|
|
839
|
+
}
|
|
840
|
+
},
|
|
841
|
+
[],
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
const handleContentKeyDown = React.useCallback(
|
|
845
|
+
(event: React.KeyboardEvent<HTMLDivElement>, note: CommentSummary) => {
|
|
846
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
847
|
+
event.preventDefault()
|
|
848
|
+
setContentEditor({ id: note.id, value: note.body })
|
|
849
|
+
}
|
|
850
|
+
},
|
|
851
|
+
[],
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
const noteAuthorLabel = React.useCallback(
|
|
855
|
+
(note: CommentSummary) => {
|
|
856
|
+
if (note.authorUserId && viewerUserId && note.authorUserId === viewerUserId) {
|
|
857
|
+
return youLabel
|
|
858
|
+
}
|
|
859
|
+
return note.authorName ?? note.authorEmail ?? youLabel
|
|
860
|
+
},
|
|
861
|
+
[viewerUserId, youLabel],
|
|
862
|
+
)
|
|
863
|
+
|
|
864
|
+
const noteAppearanceLabels = React.useMemo<AppearanceSelectorLabels>(
|
|
865
|
+
() => ({
|
|
866
|
+
colorLabel: label('appearance.colorLabel'),
|
|
867
|
+
colorHelp: label('appearance.colorHelp'),
|
|
868
|
+
colorClearLabel: label('appearance.clearColor'),
|
|
869
|
+
iconLabel: label('appearance.iconLabel'),
|
|
870
|
+
iconPlaceholder: label('appearance.iconPlaceholder'),
|
|
871
|
+
iconPickerTriggerLabel: label('appearance.iconPicker'),
|
|
872
|
+
iconSearchPlaceholder: label('appearance.iconSearchPlaceholder'),
|
|
873
|
+
iconSearchEmptyLabel: label('appearance.iconSearchEmpty'),
|
|
874
|
+
iconSuggestionsLabel: label('appearance.iconSuggestions'),
|
|
875
|
+
iconClearLabel: label('appearance.iconClear'),
|
|
876
|
+
previewEmptyLabel: label('appearance.previewEmpty'),
|
|
877
|
+
}),
|
|
878
|
+
[label],
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
const composerAuthor = React.useMemo(
|
|
882
|
+
() => youLabel,
|
|
883
|
+
[youLabel],
|
|
884
|
+
)
|
|
885
|
+
const composerHasAppearance = Boolean(draftIcon) || Boolean(draftColor)
|
|
886
|
+
const appearanceDialogOpen = appearanceDialogState !== null
|
|
887
|
+
const editingAppearanceNoteId =
|
|
888
|
+
appearanceDialogState?.mode === 'edit' ? appearanceDialogState.noteId : null
|
|
889
|
+
const addNoteShortcutLabel = label('addShortcut', 'Add note ⌘⏎ / Ctrl+Enter')
|
|
890
|
+
const saveAppearanceShortcutLabel = label('appearance.saveShortcut', 'Save appearance ⌘⏎ / Ctrl+Enter')
|
|
891
|
+
const composerSubmitLabel = addNoteShortcutLabel
|
|
892
|
+
const appearanceDialogPrimaryLabel = saveAppearanceShortcutLabel
|
|
893
|
+
const appearanceDialogSavingLabel =
|
|
894
|
+
appearanceDialogState?.mode === 'edit'
|
|
895
|
+
? label('appearance.saving')
|
|
896
|
+
: label('saving', 'Saving note…')
|
|
897
|
+
|
|
898
|
+
return (
|
|
899
|
+
<div className="mt-0 space-y-2">
|
|
900
|
+
<div
|
|
901
|
+
className={[
|
|
902
|
+
'overflow-hidden rounded-xl transition-all duration-300 ease-out',
|
|
903
|
+
composerOpen ? 'max-h-[1200px] bg-muted/10 p-4 opacity-100' : 'pointer-events-none max-h-0 p-0 opacity-0',
|
|
904
|
+
].join(' ')}
|
|
905
|
+
aria-hidden={!composerOpen}
|
|
906
|
+
>
|
|
907
|
+
{composerOpen ? (
|
|
908
|
+
<form
|
|
909
|
+
ref={formRef}
|
|
910
|
+
onSubmit={handleSubmit}
|
|
911
|
+
onKeyDown={handleComposerKeyDown}
|
|
912
|
+
className="space-y-3"
|
|
913
|
+
>
|
|
914
|
+
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
915
|
+
<h3 className="text-sm font-medium">{label('addLabel')}</h3>
|
|
916
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
917
|
+
<Button
|
|
918
|
+
type="button"
|
|
919
|
+
variant="ghost"
|
|
920
|
+
size="icon"
|
|
921
|
+
onClick={() => {
|
|
922
|
+
setAppearanceDialogError(null)
|
|
923
|
+
setAppearanceDialogState({ mode: 'create', icon: draftIcon, color: draftColor })
|
|
924
|
+
}}
|
|
925
|
+
disabled={isSubmitting || isLoading || !hasEntity}
|
|
926
|
+
>
|
|
927
|
+
<span className="sr-only">{label('appearance.toggleOpen', 'Customize appearance')}</span>
|
|
928
|
+
<Palette className="h-4 w-4" />
|
|
929
|
+
</Button>
|
|
930
|
+
{disableMarkdown ? null : (
|
|
931
|
+
<Button
|
|
932
|
+
type="button"
|
|
933
|
+
variant={isMarkdownEnabled ? 'secondary' : 'ghost'}
|
|
934
|
+
size="icon"
|
|
935
|
+
onClick={handleMarkdownToggle}
|
|
936
|
+
aria-pressed={isMarkdownEnabled}
|
|
937
|
+
disabled={isSubmitting || isLoading}
|
|
938
|
+
>
|
|
939
|
+
<FileCode className="h-4 w-4" />
|
|
940
|
+
</Button>
|
|
941
|
+
)}
|
|
942
|
+
<Button
|
|
943
|
+
type="button"
|
|
944
|
+
size="sm"
|
|
945
|
+
variant="ghost"
|
|
946
|
+
onClick={() => {
|
|
947
|
+
setComposerOpen(false)
|
|
948
|
+
setDraftBody('')
|
|
949
|
+
setDraftIcon(null)
|
|
950
|
+
setDraftColor(null)
|
|
951
|
+
}}
|
|
952
|
+
disabled={isSubmitting || isLoading}
|
|
953
|
+
>
|
|
954
|
+
{inlineLabel('cancel')}
|
|
955
|
+
</Button>
|
|
956
|
+
</div>
|
|
957
|
+
</div>
|
|
958
|
+
{(normalizedEntityOptions.length || normalizedDealOptions.length) ? (
|
|
959
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
960
|
+
{normalizedEntityOptions.length ? (
|
|
961
|
+
<div className="flex flex-col gap-1">
|
|
962
|
+
<label
|
|
963
|
+
htmlFor="note-entity-select"
|
|
964
|
+
className="text-xs font-medium text-muted-foreground"
|
|
965
|
+
>
|
|
966
|
+
{label('fields.entity', 'Assign to customer')}
|
|
967
|
+
</label>
|
|
968
|
+
<select
|
|
969
|
+
id="note-entity-select"
|
|
970
|
+
className="h-9 rounded border border-muted-foreground/40 bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
971
|
+
value={selectedEntityId}
|
|
972
|
+
onChange={(event) => setSelectedEntityId(event.target.value)}
|
|
973
|
+
disabled={isSubmitting || isLoading || !normalizedEntityOptions.length}
|
|
974
|
+
>
|
|
975
|
+
{normalizedEntityOptions.map((option) => (
|
|
976
|
+
<option key={option.id} value={option.id}>
|
|
977
|
+
{option.label}
|
|
978
|
+
</option>
|
|
979
|
+
))}
|
|
980
|
+
</select>
|
|
981
|
+
</div>
|
|
982
|
+
) : null}
|
|
983
|
+
{normalizedDealOptions.length ? (
|
|
984
|
+
<div className="flex flex-col gap-1">
|
|
985
|
+
<label
|
|
986
|
+
htmlFor="note-deal-select"
|
|
987
|
+
className="text-xs font-medium text-muted-foreground"
|
|
988
|
+
>
|
|
989
|
+
{label('fields.deal', 'Link to deal (optional)')}
|
|
990
|
+
</label>
|
|
991
|
+
<select
|
|
992
|
+
id="note-deal-select"
|
|
993
|
+
className="h-9 rounded border border-muted-foreground/40 bg-background px-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
994
|
+
value={selectedDealId}
|
|
995
|
+
onChange={(event) => setSelectedDealId(event.target.value)}
|
|
996
|
+
disabled={isSubmitting || isLoading}
|
|
997
|
+
>
|
|
998
|
+
<option value="">
|
|
999
|
+
{label('fields.dealPlaceholder', 'No linked deal')}
|
|
1000
|
+
</option>
|
|
1001
|
+
{normalizedDealOptions.map((option) => (
|
|
1002
|
+
<option key={option.id} value={option.id}>
|
|
1003
|
+
{option.label}
|
|
1004
|
+
</option>
|
|
1005
|
+
))}
|
|
1006
|
+
</select>
|
|
1007
|
+
</div>
|
|
1008
|
+
) : null}
|
|
1009
|
+
</div>
|
|
1010
|
+
) : null}
|
|
1011
|
+
<SwitchableMarkdownInput
|
|
1012
|
+
value={draftBody}
|
|
1013
|
+
onChange={setDraftBody}
|
|
1014
|
+
isMarkdownEnabled={isMarkdownEnabled}
|
|
1015
|
+
disableMarkdown={disableMarkdown}
|
|
1016
|
+
rows={1}
|
|
1017
|
+
placeholder={label('placeholder')}
|
|
1018
|
+
textareaRef={textareaRef}
|
|
1019
|
+
onTextareaInput={(event) => adjustTextareaSize(event.currentTarget)}
|
|
1020
|
+
disabled={isSubmitting || isLoading || !hasEntity}
|
|
1021
|
+
remarkPlugins={markdownPlugins}
|
|
1022
|
+
/>
|
|
1023
|
+
{composerHasAppearance ? (
|
|
1024
|
+
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-dashed border-muted-foreground/40 px-3 py-2">
|
|
1025
|
+
<div className="flex flex-wrap items-center gap-3 text-sm">
|
|
1026
|
+
{draftIcon && renderIcon ? (
|
|
1027
|
+
<span className="inline-flex h-7 w-7 items-center justify-center rounded border border-border bg-muted/40">
|
|
1028
|
+
{renderIcon(draftIcon, 'h-4 w-4')}
|
|
1029
|
+
</span>
|
|
1030
|
+
) : null}
|
|
1031
|
+
<span className="font-semibold text-foreground">{composerAuthor}</span>
|
|
1032
|
+
{draftColor && renderColor ? (
|
|
1033
|
+
<span className="flex items-center gap-2">
|
|
1034
|
+
{renderColor(draftColor, 'h-3.5 w-3.5 rounded-full border border-border')}
|
|
1035
|
+
<span className="text-xs font-medium uppercase text-muted-foreground">{draftColor}</span>
|
|
1036
|
+
</span>
|
|
1037
|
+
) : null}
|
|
1038
|
+
</div>
|
|
1039
|
+
<Button
|
|
1040
|
+
type="button"
|
|
1041
|
+
size="sm"
|
|
1042
|
+
variant="ghost"
|
|
1043
|
+
onClick={() => {
|
|
1044
|
+
setDraftIcon(null)
|
|
1045
|
+
setDraftColor(null)
|
|
1046
|
+
}}
|
|
1047
|
+
disabled={isSubmitting}
|
|
1048
|
+
>
|
|
1049
|
+
{label('appearance.clearAll', 'Clear')}
|
|
1050
|
+
</Button>
|
|
1051
|
+
</div>
|
|
1052
|
+
) : null}
|
|
1053
|
+
<div className="flex justify-end">
|
|
1054
|
+
<Button
|
|
1055
|
+
type="submit"
|
|
1056
|
+
size="sm"
|
|
1057
|
+
disabled={isSubmitting || isLoading || !hasEntity}
|
|
1058
|
+
>
|
|
1059
|
+
{isSubmitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
|
|
1060
|
+
{composerSubmitLabel}
|
|
1061
|
+
</Button>
|
|
1062
|
+
</div>
|
|
1063
|
+
</form>
|
|
1064
|
+
) : null}
|
|
1065
|
+
</div>
|
|
1066
|
+
|
|
1067
|
+
{loadError ? <ErrorMessage label={loadError} className="mt-3" /> : null}
|
|
1068
|
+
|
|
1069
|
+
<div className="space-y-3">
|
|
1070
|
+
{isLoading ? (
|
|
1071
|
+
<LoadingMessage
|
|
1072
|
+
label={label('loading', 'Loading notes…')}
|
|
1073
|
+
className="border-0 bg-transparent p-0 py-8 justify-center"
|
|
1074
|
+
/>
|
|
1075
|
+
) : hasVisibleNotes ? (
|
|
1076
|
+
visibleNotes.map((note) => {
|
|
1077
|
+
const author = noteAuthorLabel(note)
|
|
1078
|
+
const isAppearanceSaving = appearanceDialogSaving && editingAppearanceNoteId === note.id
|
|
1079
|
+
const isEditingContent = contentEditor.id === note.id
|
|
1080
|
+
const displayIcon = note.appearanceIcon ?? null
|
|
1081
|
+
const displayColor = note.appearanceColor ?? null
|
|
1082
|
+
const timestampValue = note.createdAt
|
|
1083
|
+
const fallbackTimestampLabel = formatDateTime(note.createdAt) ?? emptyLabel
|
|
1084
|
+
return (
|
|
1085
|
+
<div key={note.id} className="group space-y-2 rounded-lg border bg-card p-4">
|
|
1086
|
+
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
1087
|
+
<div className="space-y-1">
|
|
1088
|
+
<TimelineItemHeader
|
|
1089
|
+
title={author}
|
|
1090
|
+
timestamp={timestampValue}
|
|
1091
|
+
fallbackTimestampLabel={fallbackTimestampLabel}
|
|
1092
|
+
icon={displayIcon}
|
|
1093
|
+
color={displayColor}
|
|
1094
|
+
renderIcon={renderIcon}
|
|
1095
|
+
renderColor={renderColor}
|
|
1096
|
+
/>
|
|
1097
|
+
{note.dealId ? (
|
|
1098
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
1099
|
+
<ArrowUpRightSquare className="h-3.5 w-3.5" />
|
|
1100
|
+
<a
|
|
1101
|
+
href={`/backend/customers/deals/${encodeURIComponent(note.dealId)}`}
|
|
1102
|
+
className="font-medium text-foreground hover:underline"
|
|
1103
|
+
>
|
|
1104
|
+
{note.dealTitle && note.dealTitle.length
|
|
1105
|
+
? note.dealTitle
|
|
1106
|
+
: label('linkedDeal', 'Linked deal')}
|
|
1107
|
+
</a>
|
|
1108
|
+
</div>
|
|
1109
|
+
) : null}
|
|
1110
|
+
</div>
|
|
1111
|
+
<div
|
|
1112
|
+
className={`flex items-center gap-2 transition-opacity ${
|
|
1113
|
+
isEditingContent ? 'opacity-100' : 'opacity-0 group-hover:opacity-100 focus-within:opacity-100'
|
|
1114
|
+
}`}
|
|
1115
|
+
>
|
|
1116
|
+
<Button
|
|
1117
|
+
type="button"
|
|
1118
|
+
variant="ghost"
|
|
1119
|
+
size="icon"
|
|
1120
|
+
onClick={() => setContentEditor({ id: note.id, value: note.body })}
|
|
1121
|
+
>
|
|
1122
|
+
<Pencil className="h-4 w-4" />
|
|
1123
|
+
</Button>
|
|
1124
|
+
<Button
|
|
1125
|
+
type="button"
|
|
1126
|
+
variant="ghost"
|
|
1127
|
+
size="icon"
|
|
1128
|
+
onClick={(event) => {
|
|
1129
|
+
event.stopPropagation()
|
|
1130
|
+
setAppearanceDialogError(null)
|
|
1131
|
+
setAppearanceDialogState({
|
|
1132
|
+
mode: 'edit',
|
|
1133
|
+
noteId: note.id,
|
|
1134
|
+
icon: note.appearanceIcon ?? null,
|
|
1135
|
+
color: note.appearanceColor ?? null,
|
|
1136
|
+
})
|
|
1137
|
+
}}
|
|
1138
|
+
disabled={appearanceDialogSaving && editingAppearanceNoteId === note.id}
|
|
1139
|
+
>
|
|
1140
|
+
{isAppearanceSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Palette className="h-4 w-4" />}
|
|
1141
|
+
</Button>
|
|
1142
|
+
<Button
|
|
1143
|
+
type="button"
|
|
1144
|
+
variant="ghost"
|
|
1145
|
+
size="icon"
|
|
1146
|
+
onClick={(event) => {
|
|
1147
|
+
event.stopPropagation()
|
|
1148
|
+
void handleDeleteNote(note)
|
|
1149
|
+
}}
|
|
1150
|
+
disabled={deletingNoteId === note.id}
|
|
1151
|
+
>
|
|
1152
|
+
{deletingNoteId === note.id ? (
|
|
1153
|
+
<span className="relative flex h-4 w-4 items-center justify-center text-destructive">
|
|
1154
|
+
<span className="absolute h-4 w-4 animate-spin rounded-full border border-destructive border-t-transparent" />
|
|
1155
|
+
</span>
|
|
1156
|
+
) : (
|
|
1157
|
+
<Trash2 className="h-4 w-4" />
|
|
1158
|
+
)}
|
|
1159
|
+
</Button>
|
|
1160
|
+
</div>
|
|
1161
|
+
</div>
|
|
1162
|
+
{isEditingContent ? (
|
|
1163
|
+
<div className="space-y-2" onKeyDown={handleContentEditorKeyDown}>
|
|
1164
|
+
<SwitchableMarkdownInput
|
|
1165
|
+
value={contentEditor.value}
|
|
1166
|
+
onChange={(nextValue) => setContentEditor((prev) => ({ ...prev, value: nextValue }))}
|
|
1167
|
+
isMarkdownEnabled={isMarkdownEnabled}
|
|
1168
|
+
disableMarkdown={disableMarkdown}
|
|
1169
|
+
rows={3}
|
|
1170
|
+
textareaRef={contentTextareaRef}
|
|
1171
|
+
onTextareaInput={(event) => adjustTextareaSize(event.currentTarget)}
|
|
1172
|
+
textareaClassName="w-full resize-none overflow-hidden rounded-md border border-border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
|
1173
|
+
editorWrapperClassName="w-full rounded-md border border-muted-foreground/20 bg-background p-2"
|
|
1174
|
+
remarkPlugins={markdownPlugins}
|
|
1175
|
+
/>
|
|
1176
|
+
{contentError ? <p className="text-xs text-red-600">{contentError}</p> : null}
|
|
1177
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1178
|
+
<Button type="button" size="sm" onClick={handleContentSave} disabled={contentSavingId === note.id}>
|
|
1179
|
+
{contentSavingId === note.id ? (
|
|
1180
|
+
<>
|
|
1181
|
+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
1182
|
+
{label('saving')}
|
|
1183
|
+
</>
|
|
1184
|
+
) : (
|
|
1185
|
+
inlineLabel('saveShortcut')
|
|
1186
|
+
)}
|
|
1187
|
+
</Button>
|
|
1188
|
+
{disableMarkdown ? null : (
|
|
1189
|
+
<Button
|
|
1190
|
+
type="button"
|
|
1191
|
+
variant="ghost"
|
|
1192
|
+
size="icon"
|
|
1193
|
+
onClick={handleMarkdownToggle}
|
|
1194
|
+
aria-pressed={isMarkdownEnabled}
|
|
1195
|
+
className={isMarkdownEnabled ? 'text-primary' : undefined}
|
|
1196
|
+
disabled={contentSavingId === note.id}
|
|
1197
|
+
>
|
|
1198
|
+
<FileCode className="h-4 w-4" />
|
|
1199
|
+
</Button>
|
|
1200
|
+
)}
|
|
1201
|
+
<Button
|
|
1202
|
+
type="button"
|
|
1203
|
+
size="sm"
|
|
1204
|
+
variant="ghost"
|
|
1205
|
+
onClick={() => setContentEditor({ id: '', value: '' })}
|
|
1206
|
+
disabled={contentSavingId === note.id}
|
|
1207
|
+
>
|
|
1208
|
+
{inlineLabel('cancel')}
|
|
1209
|
+
</Button>
|
|
1210
|
+
</div>
|
|
1211
|
+
</div>
|
|
1212
|
+
) : (
|
|
1213
|
+
<div
|
|
1214
|
+
role="button"
|
|
1215
|
+
tabIndex={0}
|
|
1216
|
+
className="cursor-pointer text-sm"
|
|
1217
|
+
onClick={() => setContentEditor({ id: note.id, value: note.body })}
|
|
1218
|
+
onKeyDown={(event) => handleContentKeyDown(event, note)}
|
|
1219
|
+
>
|
|
1220
|
+
<MarkdownPreviewComponent
|
|
1221
|
+
remarkPlugins={markdownPlugins}
|
|
1222
|
+
className="break-words text-foreground [&>*]:mb-2 [&>*:last-child]:mb-0 [&_ul]:ml-4 [&_ul]:list-disc [&_ol]:ml-4 [&_ol]:list-decimal [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:text-xs"
|
|
1223
|
+
>
|
|
1224
|
+
{note.body}
|
|
1225
|
+
</MarkdownPreviewComponent>
|
|
1226
|
+
</div>
|
|
1227
|
+
)}
|
|
1228
|
+
</div>
|
|
1229
|
+
)
|
|
1230
|
+
})
|
|
1231
|
+
) : composerOpen ? null : (
|
|
1232
|
+
<TabEmptyState
|
|
1233
|
+
title={emptyState.title}
|
|
1234
|
+
description={emptyState.description}
|
|
1235
|
+
action={{
|
|
1236
|
+
label: emptyState.actionLabel,
|
|
1237
|
+
onClick: focusComposer,
|
|
1238
|
+
disabled: isSubmitting || !hasEntity,
|
|
1239
|
+
}}
|
|
1240
|
+
/>
|
|
1241
|
+
)}
|
|
1242
|
+
{isLoading || visibleCount >= notes.length ? null : (
|
|
1243
|
+
<div className="flex justify-center">
|
|
1244
|
+
<Button variant="outline" size="sm" onClick={handleLoadMore}>
|
|
1245
|
+
{loadMoreLabel}
|
|
1246
|
+
</Button>
|
|
1247
|
+
</div>
|
|
1248
|
+
)}
|
|
1249
|
+
</div>
|
|
1250
|
+
<AppearanceDialog
|
|
1251
|
+
open={appearanceDialogOpen}
|
|
1252
|
+
title={
|
|
1253
|
+
appearanceDialogState?.mode === 'edit'
|
|
1254
|
+
? label('appearance.edit')
|
|
1255
|
+
: label('appearance.toggleOpen', 'Customize appearance')
|
|
1256
|
+
}
|
|
1257
|
+
icon={appearanceDialogState?.icon ?? null}
|
|
1258
|
+
color={appearanceDialogState?.color ?? null}
|
|
1259
|
+
labels={noteAppearanceLabels}
|
|
1260
|
+
iconSuggestions={iconSuggestions}
|
|
1261
|
+
onIconChange={(value) => setAppearanceDialogState((prev) => (prev ? { ...prev, icon: value ?? null } : prev))}
|
|
1262
|
+
onColorChange={(value) => setAppearanceDialogState((prev) => (prev ? { ...prev, color: value ?? null } : prev))}
|
|
1263
|
+
onSubmit={() => {
|
|
1264
|
+
void handleAppearanceDialogSubmit()
|
|
1265
|
+
}}
|
|
1266
|
+
onClose={handleAppearanceDialogClose}
|
|
1267
|
+
isSaving={appearanceDialogSaving}
|
|
1268
|
+
errorMessage={appearanceDialogError}
|
|
1269
|
+
primaryLabel={appearanceDialogPrimaryLabel}
|
|
1270
|
+
savingLabel={appearanceDialogSavingLabel}
|
|
1271
|
+
cancelLabel={label('appearance.cancel')}
|
|
1272
|
+
/>
|
|
1273
|
+
</div>
|
|
1274
|
+
)
|
|
1275
|
+
}
|