@open-mercato/ui 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3045.b4b3320cc2
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +2 -1
- package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +204 -0
- package/dist/ai/AiAssistantLauncher.js +596 -0
- package/dist/ai/AiAssistantLauncher.js.map +7 -0
- package/dist/ai/AiChat.js +1092 -0
- package/dist/ai/AiChat.js.map +7 -0
- package/dist/ai/AiChatSessions.js +297 -0
- package/dist/ai/AiChatSessions.js.map +7 -0
- package/dist/ai/AiDock.js +347 -0
- package/dist/ai/AiDock.js.map +7 -0
- package/dist/ai/AiMessageContent.js +369 -0
- package/dist/ai/AiMessageContent.js.map +7 -0
- package/dist/ai/ChatPaneTabs.js +251 -0
- package/dist/ai/ChatPaneTabs.js.map +7 -0
- package/dist/ai/index.js +115 -0
- package/dist/ai/index.js.map +7 -0
- package/dist/ai/parts/ConfirmationCard.js +211 -0
- package/dist/ai/parts/ConfirmationCard.js.map +7 -0
- package/dist/ai/parts/FieldDiffCard.js +119 -0
- package/dist/ai/parts/FieldDiffCard.js.map +7 -0
- package/dist/ai/parts/MutationPreviewCard.js +224 -0
- package/dist/ai/parts/MutationPreviewCard.js.map +7 -0
- package/dist/ai/parts/MutationResultCard.js +240 -0
- package/dist/ai/parts/MutationResultCard.js.map +7 -0
- package/dist/ai/parts/approval-cards-map.js +15 -0
- package/dist/ai/parts/approval-cards-map.js.map +7 -0
- package/dist/ai/parts/index.js +24 -0
- package/dist/ai/parts/index.js.map +7 -0
- package/dist/ai/parts/pending-action-api.js +60 -0
- package/dist/ai/parts/pending-action-api.js.map +7 -0
- package/dist/ai/parts/types.js +1 -0
- package/dist/ai/parts/types.js.map +7 -0
- package/dist/ai/parts/useAiPendingActionPolling.js +126 -0
- package/dist/ai/parts/useAiPendingActionPolling.js.map +7 -0
- package/dist/ai/records/ActivityCard.js +83 -0
- package/dist/ai/records/ActivityCard.js.map +7 -0
- package/dist/ai/records/CompanyCard.js +81 -0
- package/dist/ai/records/CompanyCard.js.map +7 -0
- package/dist/ai/records/DealCard.js +76 -0
- package/dist/ai/records/DealCard.js.map +7 -0
- package/dist/ai/records/PersonCard.js +68 -0
- package/dist/ai/records/PersonCard.js.map +7 -0
- package/dist/ai/records/ProductCard.js +68 -0
- package/dist/ai/records/ProductCard.js.map +7 -0
- package/dist/ai/records/RecordCard.js +29 -0
- package/dist/ai/records/RecordCard.js.map +7 -0
- package/dist/ai/records/RecordCardShell.js +103 -0
- package/dist/ai/records/RecordCardShell.js.map +7 -0
- package/dist/ai/records/index.js +31 -0
- package/dist/ai/records/index.js.map +7 -0
- package/dist/ai/records/registry.js +51 -0
- package/dist/ai/records/registry.js.map +7 -0
- package/dist/ai/records/types.js +1 -0
- package/dist/ai/records/types.js.map +7 -0
- package/dist/ai/ui-part-registry.js +112 -0
- package/dist/ai/ui-part-registry.js.map +7 -0
- package/dist/ai/ui-part-slots.js +14 -0
- package/dist/ai/ui-part-slots.js.map +7 -0
- package/dist/ai/ui-parts/pending-phase3-placeholder.js +35 -0
- package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +7 -0
- package/dist/ai/upload-adapter.js +256 -0
- package/dist/ai/upload-adapter.js.map +7 -0
- package/dist/ai/useAiChat.js +549 -0
- package/dist/ai/useAiChat.js.map +7 -0
- package/dist/ai/useAiChatUpload.js +127 -0
- package/dist/ai/useAiChatUpload.js.map +7 -0
- package/dist/ai/useAiShortcuts.js +43 -0
- package/dist/ai/useAiShortcuts.js.map +7 -0
- package/dist/backend/AppShell.js +8 -4
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/backend/BackendChromeProvider.js +2 -0
- package/dist/backend/BackendChromeProvider.js.map +2 -2
- package/dist/backend/DataTable.js +19 -2
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/FilterBar.js +19 -15
- package/dist/backend/FilterBar.js.map +2 -2
- package/dist/backend/dashboard/DashboardScreen.js +31 -3
- package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
- package/dist/backend/injection/spotIds.js +6 -0
- package/dist/backend/injection/spotIds.js.map +2 -2
- package/dist/backend/notifications/useNotificationEffect.js +38 -2
- package/dist/backend/notifications/useNotificationEffect.js.map +2 -2
- package/dist/index.js +1 -0
- package/dist/index.js.map +2 -2
- package/jest.config.cjs +7 -1
- package/jest.markdown-mock.tsx +7 -0
- package/package.json +10 -4
- package/src/ai/AiAssistantLauncher.tsx +805 -0
- package/src/ai/AiChat.tsx +1483 -0
- package/src/ai/AiChatSessions.tsx +429 -0
- package/src/ai/AiDock.tsx +505 -0
- package/src/ai/AiMessageContent.tsx +515 -0
- package/src/ai/ChatPaneTabs.tsx +310 -0
- package/src/ai/__tests__/AiChat.conversation.test.tsx +160 -0
- package/src/ai/__tests__/AiChat.debug.test.tsx +152 -0
- package/src/ai/__tests__/AiChat.registry.test.tsx +213 -0
- package/src/ai/__tests__/AiChat.test.tsx +257 -0
- package/src/ai/__tests__/AiDock.test.tsx +124 -0
- package/src/ai/__tests__/AiMessageContent.test.ts +111 -0
- package/src/ai/__tests__/ui-part-registry.test.ts +199 -0
- package/src/ai/__tests__/ui-part-slots.test.ts +43 -0
- package/src/ai/__tests__/upload-adapter.test.ts +213 -0
- package/src/ai/__tests__/useAiChatUpload.test.tsx +163 -0
- package/src/ai/__tests__/useAiShortcuts.test.tsx +100 -0
- package/src/ai/index.ts +125 -0
- package/src/ai/parts/ConfirmationCard.tsx +310 -0
- package/src/ai/parts/FieldDiffCard.tsx +173 -0
- package/src/ai/parts/MutationPreviewCard.tsx +302 -0
- package/src/ai/parts/MutationResultCard.tsx +360 -0
- package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +169 -0
- package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +74 -0
- package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +177 -0
- package/src/ai/parts/__tests__/MutationResultCard.test.tsx +127 -0
- package/src/ai/parts/__tests__/useAiPendingActionPolling.test.tsx +151 -0
- package/src/ai/parts/approval-cards-map.ts +24 -0
- package/src/ai/parts/index.ts +27 -0
- package/src/ai/parts/pending-action-api.ts +123 -0
- package/src/ai/parts/types.ts +84 -0
- package/src/ai/parts/useAiPendingActionPolling.ts +210 -0
- package/src/ai/records/ActivityCard.tsx +102 -0
- package/src/ai/records/CompanyCard.tsx +89 -0
- package/src/ai/records/DealCard.tsx +85 -0
- package/src/ai/records/PersonCard.tsx +77 -0
- package/src/ai/records/ProductCard.tsx +83 -0
- package/src/ai/records/RecordCard.tsx +37 -0
- package/src/ai/records/RecordCardShell.tsx +169 -0
- package/src/ai/records/index.ts +30 -0
- package/src/ai/records/registry.tsx +80 -0
- package/src/ai/records/types.ts +90 -0
- package/src/ai/ui-part-registry.ts +233 -0
- package/src/ai/ui-part-slots.ts +32 -0
- package/src/ai/ui-parts/pending-phase3-placeholder.tsx +50 -0
- package/src/ai/upload-adapter.ts +421 -0
- package/src/ai/useAiChat.ts +865 -0
- package/src/ai/useAiChatUpload.ts +180 -0
- package/src/ai/useAiShortcuts.ts +79 -0
- package/src/backend/AppShell.tsx +12 -5
- package/src/backend/BackendChromeProvider.tsx +2 -0
- package/src/backend/DataTable.tsx +20 -1
- package/src/backend/FilterBar.tsx +26 -13
- package/src/backend/__tests__/BackendChromeProvider.test.tsx +45 -0
- package/src/backend/dashboard/DashboardScreen.tsx +38 -3
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +24 -1
- package/src/backend/injection/spotIds.ts +6 -0
- package/src/backend/notifications/__tests__/useNotificationEffect.test.tsx +77 -0
- package/src/backend/notifications/useNotificationEffect.ts +47 -2
- package/src/index.ts +1 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared props + payload shapes for the Phase 3 mutation-approval cards
|
|
3
|
+
* (Step 5.10).
|
|
4
|
+
*
|
|
5
|
+
* The cards render the server-emitted `AiPendingAction` row via the
|
|
6
|
+
* `@open-mercato/ai-assistant` serializer. Re-declared here by structure
|
|
7
|
+
* rather than imported to keep `packages/ui` free of server-only imports.
|
|
8
|
+
* The shape mirrors `SerializedPendingAction` from
|
|
9
|
+
* `@open-mercato/ai-assistant/modules/ai_assistant/lib/pending-action-client`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type AiPendingActionCardStatus =
|
|
13
|
+
| 'pending'
|
|
14
|
+
| 'confirmed'
|
|
15
|
+
| 'cancelled'
|
|
16
|
+
| 'expired'
|
|
17
|
+
| 'executing'
|
|
18
|
+
| 'failed'
|
|
19
|
+
|
|
20
|
+
export type AiPendingActionCardFieldDiff = {
|
|
21
|
+
field: string
|
|
22
|
+
before: unknown
|
|
23
|
+
after: unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type AiPendingActionCardRecordDiff = {
|
|
27
|
+
recordId: string
|
|
28
|
+
entityType: string
|
|
29
|
+
label: string
|
|
30
|
+
fieldDiff: AiPendingActionCardFieldDiff[]
|
|
31
|
+
recordVersion?: string | null
|
|
32
|
+
attachmentIds?: string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type AiPendingActionCardFailedRecord = {
|
|
36
|
+
recordId: string
|
|
37
|
+
error: { code: string; message: string }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type AiPendingActionCardExecutionErrorDetails = {
|
|
41
|
+
issues?: Array<{
|
|
42
|
+
path?: (string | number)[]
|
|
43
|
+
message?: string
|
|
44
|
+
code?: string
|
|
45
|
+
expected?: string
|
|
46
|
+
received?: string
|
|
47
|
+
}>
|
|
48
|
+
fieldErrors?: Record<string, string[]>
|
|
49
|
+
cause?: unknown
|
|
50
|
+
[key: string]: unknown
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type AiPendingActionCardExecutionResult = {
|
|
54
|
+
recordId?: string
|
|
55
|
+
commandName?: string
|
|
56
|
+
error?: {
|
|
57
|
+
code: string
|
|
58
|
+
message: string
|
|
59
|
+
name?: string
|
|
60
|
+
details?: AiPendingActionCardExecutionErrorDetails
|
|
61
|
+
input?: unknown
|
|
62
|
+
stack?: string
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface AiPendingActionCardAction {
|
|
67
|
+
id: string
|
|
68
|
+
agentId: string
|
|
69
|
+
toolName: string
|
|
70
|
+
status: AiPendingActionCardStatus
|
|
71
|
+
fieldDiff: AiPendingActionCardFieldDiff[]
|
|
72
|
+
records: AiPendingActionCardRecordDiff[] | null
|
|
73
|
+
failedRecords: AiPendingActionCardFailedRecord[] | null
|
|
74
|
+
sideEffectsSummary: string | null
|
|
75
|
+
attachmentIds: string[]
|
|
76
|
+
targetEntityType: string | null
|
|
77
|
+
targetRecordId: string | null
|
|
78
|
+
recordVersion: string | null
|
|
79
|
+
executionResult: AiPendingActionCardExecutionResult | null
|
|
80
|
+
createdAt: string
|
|
81
|
+
expiresAt: string
|
|
82
|
+
resolvedAt: string | null
|
|
83
|
+
resolvedByUserId: string | null
|
|
84
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { apiCallOrThrow } from '../../backend/utils/apiCall'
|
|
5
|
+
import type { AiPendingActionCardAction, AiPendingActionCardStatus } from './types'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Shared polling hook for the Phase 3 mutation-approval cards (Step 5.10).
|
|
9
|
+
*
|
|
10
|
+
* Responsibilities:
|
|
11
|
+
* - Fetch `GET /api/ai_assistant/ai/actions/:id` on mount, even when the
|
|
12
|
+
* server previously streamed a preview card. This is the "reconnect" path:
|
|
13
|
+
* after a page reload or navigation away+back, the card recovers the
|
|
14
|
+
* current pending-action state instead of staying blank.
|
|
15
|
+
* - Poll every 3 seconds while the status is non-terminal
|
|
16
|
+
* (`pending` / `executing`). Terminal states (`confirmed`, `cancelled`,
|
|
17
|
+
* `failed`, `expired`) stop polling.
|
|
18
|
+
* - Expose a `refresh()` force-fetch helper the confirmation card uses after
|
|
19
|
+
* a confirm POST races with the polling loop.
|
|
20
|
+
*
|
|
21
|
+
* The hook owns a single `setInterval`/`setTimeout` — unmounting clears
|
|
22
|
+
* every outstanding timer. This is required for the Jest fake-timers
|
|
23
|
+
* "mount, unmount mid-poll" test contract.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const TERMINAL_STATUSES: ReadonlyArray<AiPendingActionCardStatus> = [
|
|
27
|
+
'confirmed',
|
|
28
|
+
'cancelled',
|
|
29
|
+
'failed',
|
|
30
|
+
'expired',
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
function isTerminal(status: AiPendingActionCardStatus | null): boolean {
|
|
34
|
+
if (!status) return false
|
|
35
|
+
return TERMINAL_STATUSES.includes(status)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface UseAiPendingActionPollingOptions {
|
|
39
|
+
pendingActionId: string
|
|
40
|
+
/**
|
|
41
|
+
* Poll interval in ms while the status is non-terminal. Defaults to 3000.
|
|
42
|
+
*/
|
|
43
|
+
intervalMs?: number
|
|
44
|
+
/**
|
|
45
|
+
* Endpoint base. Override to point at a mock during tests.
|
|
46
|
+
*/
|
|
47
|
+
endpoint?: string
|
|
48
|
+
/**
|
|
49
|
+
* When true, the hook does NOT schedule any network activity. Used by the
|
|
50
|
+
* result card which already holds a terminal state and only needs to read
|
|
51
|
+
* what the preview card fetched.
|
|
52
|
+
*/
|
|
53
|
+
disabled?: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface AiPendingActionFetchResult {
|
|
57
|
+
pendingAction: AiPendingActionCardAction | null
|
|
58
|
+
error?: { code?: string; message: string } | null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface UseAiPendingActionPollingResult {
|
|
62
|
+
action: AiPendingActionCardAction | null
|
|
63
|
+
status: AiPendingActionCardStatus | null
|
|
64
|
+
isPolling: boolean
|
|
65
|
+
error: { code?: string; message: string } | null
|
|
66
|
+
refresh: () => Promise<AiPendingActionCardAction | null>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function fetchPendingAction(
|
|
70
|
+
pendingActionId: string,
|
|
71
|
+
endpoint: string,
|
|
72
|
+
): Promise<AiPendingActionFetchResult> {
|
|
73
|
+
const url = `${endpoint}/${encodeURIComponent(pendingActionId)}`
|
|
74
|
+
const call = await apiCallOrThrow<unknown>(url, { method: 'GET' })
|
|
75
|
+
const body = call.result as
|
|
76
|
+
| (Partial<AiPendingActionCardAction> & {
|
|
77
|
+
pendingAction?: AiPendingActionCardAction
|
|
78
|
+
error?: string
|
|
79
|
+
code?: string
|
|
80
|
+
})
|
|
81
|
+
| null
|
|
82
|
+
| undefined
|
|
83
|
+
|
|
84
|
+
// The GET route returns the bare action object (`serializePendingActionForClient(row)`),
|
|
85
|
+
// but earlier client code expected it under a `pendingAction` envelope.
|
|
86
|
+
// Accept BOTH shapes so the cards work whether the dispatcher wraps or
|
|
87
|
+
// not — bare-object payloads were the actual cause of empty
|
|
88
|
+
// `fieldDiff` and missing `executionResult.error` displays in
|
|
89
|
+
// MutationPreviewCard / ConfirmationCard / MutationResultCard.
|
|
90
|
+
if (body && typeof body === 'object') {
|
|
91
|
+
if (body.pendingAction) {
|
|
92
|
+
return { pendingAction: body.pendingAction, error: null }
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
typeof (body as Partial<AiPendingActionCardAction>).id === 'string' &&
|
|
96
|
+
typeof (body as Partial<AiPendingActionCardAction>).status === 'string'
|
|
97
|
+
) {
|
|
98
|
+
return { pendingAction: body as AiPendingActionCardAction, error: null }
|
|
99
|
+
}
|
|
100
|
+
if (body.error) {
|
|
101
|
+
return {
|
|
102
|
+
pendingAction: null,
|
|
103
|
+
error: { message: body.error, code: body.code },
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { pendingAction: null, error: null }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function useAiPendingActionPolling(
|
|
111
|
+
options: UseAiPendingActionPollingOptions,
|
|
112
|
+
): UseAiPendingActionPollingResult {
|
|
113
|
+
const {
|
|
114
|
+
pendingActionId,
|
|
115
|
+
intervalMs = 3000,
|
|
116
|
+
endpoint = '/api/ai_assistant/ai/actions',
|
|
117
|
+
disabled = false,
|
|
118
|
+
} = options
|
|
119
|
+
|
|
120
|
+
const [action, setAction] = React.useState<AiPendingActionCardAction | null>(null)
|
|
121
|
+
const [error, setError] = React.useState<{ code?: string; message: string } | null>(null)
|
|
122
|
+
const [isPolling, setIsPolling] = React.useState<boolean>(!disabled)
|
|
123
|
+
|
|
124
|
+
const mountedRef = React.useRef(true)
|
|
125
|
+
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
126
|
+
const statusRef = React.useRef<AiPendingActionCardStatus | null>(null)
|
|
127
|
+
|
|
128
|
+
const clearTimer = React.useCallback(() => {
|
|
129
|
+
if (timerRef.current) {
|
|
130
|
+
clearTimeout(timerRef.current)
|
|
131
|
+
timerRef.current = null
|
|
132
|
+
}
|
|
133
|
+
}, [])
|
|
134
|
+
|
|
135
|
+
const refresh = React.useCallback(async (): Promise<AiPendingActionCardAction | null> => {
|
|
136
|
+
if (!pendingActionId) return null
|
|
137
|
+
try {
|
|
138
|
+
const result = await fetchPendingAction(pendingActionId, endpoint)
|
|
139
|
+
if (!mountedRef.current) return result.pendingAction ?? null
|
|
140
|
+
if (result.error) {
|
|
141
|
+
setError(result.error)
|
|
142
|
+
} else {
|
|
143
|
+
setError(null)
|
|
144
|
+
}
|
|
145
|
+
if (result.pendingAction) {
|
|
146
|
+
setAction(result.pendingAction)
|
|
147
|
+
statusRef.current = result.pendingAction.status
|
|
148
|
+
}
|
|
149
|
+
return result.pendingAction
|
|
150
|
+
} catch (err) {
|
|
151
|
+
if (!mountedRef.current) return null
|
|
152
|
+
const message = err instanceof Error ? err.message : 'Failed to load pending action.'
|
|
153
|
+
setError({ message })
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
}, [endpoint, pendingActionId])
|
|
157
|
+
|
|
158
|
+
const scheduleNext = React.useCallback(() => {
|
|
159
|
+
clearTimer()
|
|
160
|
+
if (!mountedRef.current) return
|
|
161
|
+
if (disabled) {
|
|
162
|
+
setIsPolling(false)
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
if (isTerminal(statusRef.current)) {
|
|
166
|
+
setIsPolling(false)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
setIsPolling(true)
|
|
170
|
+
timerRef.current = setTimeout(async () => {
|
|
171
|
+
await refresh()
|
|
172
|
+
scheduleNext()
|
|
173
|
+
}, intervalMs)
|
|
174
|
+
}, [clearTimer, disabled, intervalMs, refresh])
|
|
175
|
+
|
|
176
|
+
React.useEffect(() => {
|
|
177
|
+
mountedRef.current = true
|
|
178
|
+
statusRef.current = null
|
|
179
|
+
if (disabled) {
|
|
180
|
+
setIsPolling(false)
|
|
181
|
+
return () => {
|
|
182
|
+
mountedRef.current = false
|
|
183
|
+
clearTimer()
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
setIsPolling(true)
|
|
187
|
+
// Always fetch on mount — the "reconnect behavior" guarantee.
|
|
188
|
+
void refresh().then(() => {
|
|
189
|
+
if (!mountedRef.current) return
|
|
190
|
+
scheduleNext()
|
|
191
|
+
})
|
|
192
|
+
return () => {
|
|
193
|
+
mountedRef.current = false
|
|
194
|
+
clearTimer()
|
|
195
|
+
}
|
|
196
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
197
|
+
}, [pendingActionId, endpoint, intervalMs, disabled])
|
|
198
|
+
|
|
199
|
+
const status = action?.status ?? null
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
action,
|
|
203
|
+
status,
|
|
204
|
+
isPolling: isPolling && !isTerminal(status),
|
|
205
|
+
error,
|
|
206
|
+
refresh,
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export default useAiPendingActionPolling
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import {
|
|
5
|
+
Activity as ActivityIcon,
|
|
6
|
+
CalendarClock,
|
|
7
|
+
CheckCircle2,
|
|
8
|
+
ListChecks,
|
|
9
|
+
Mail,
|
|
10
|
+
MessageSquare,
|
|
11
|
+
Phone,
|
|
12
|
+
StickyNote,
|
|
13
|
+
} from 'lucide-react'
|
|
14
|
+
import type { LucideIcon } from 'lucide-react'
|
|
15
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
16
|
+
import { KeyValueList, RecordCardShell, TagRow, statusToTagVariant } from './RecordCardShell'
|
|
17
|
+
import type { ActivityRecordPayload } from './types'
|
|
18
|
+
|
|
19
|
+
function formatDate(value: string | null | undefined): string | null {
|
|
20
|
+
if (!value) return null
|
|
21
|
+
const d = new Date(value)
|
|
22
|
+
if (Number.isNaN(d.getTime())) return value
|
|
23
|
+
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function pickActivityIcon(type: string | null | undefined): LucideIcon {
|
|
27
|
+
if (!type) return ActivityIcon
|
|
28
|
+
const t = type.toLowerCase()
|
|
29
|
+
if (t.includes('call')) return Phone
|
|
30
|
+
if (t.includes('mail') || t.includes('email')) return Mail
|
|
31
|
+
if (t.includes('meeting') || t.includes('chat')) return MessageSquare
|
|
32
|
+
if (t.includes('task') || t.includes('todo')) return ListChecks
|
|
33
|
+
if (t.includes('note')) return StickyNote
|
|
34
|
+
if (t.includes('done') || t.includes('complete')) return CheckCircle2
|
|
35
|
+
return ActivityIcon
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ActivityCardProps extends ActivityRecordPayload {}
|
|
39
|
+
|
|
40
|
+
export function ActivityCard(props: ActivityCardProps) {
|
|
41
|
+
const t = useT()
|
|
42
|
+
const Icon = pickActivityIcon(props.type)
|
|
43
|
+
const status = props.status
|
|
44
|
+
? { label: props.status, variant: statusToTagVariant(props.status) }
|
|
45
|
+
: null
|
|
46
|
+
const dueDate = formatDate(props.dueDate)
|
|
47
|
+
const completedAt = formatDate(props.completedAt)
|
|
48
|
+
|
|
49
|
+
const items = [
|
|
50
|
+
props.type ? { label: t('ai_assistant.chat.records.fields.type', 'Type'), value: props.type } : null,
|
|
51
|
+
dueDate
|
|
52
|
+
? {
|
|
53
|
+
label: t('ai_assistant.chat.records.fields.due', 'Due'),
|
|
54
|
+
value: (
|
|
55
|
+
<span className="inline-flex items-center gap-1">
|
|
56
|
+
<CalendarClock className="size-3 text-muted-foreground" aria-hidden />
|
|
57
|
+
{dueDate}
|
|
58
|
+
</span>
|
|
59
|
+
),
|
|
60
|
+
}
|
|
61
|
+
: null,
|
|
62
|
+
completedAt
|
|
63
|
+
? {
|
|
64
|
+
label: t('ai_assistant.chat.records.fields.completed', 'Completed'),
|
|
65
|
+
value: (
|
|
66
|
+
<span className="inline-flex items-center gap-1">
|
|
67
|
+
<CheckCircle2 className="size-3 text-status-success-icon" aria-hidden />
|
|
68
|
+
{completedAt}
|
|
69
|
+
</span>
|
|
70
|
+
),
|
|
71
|
+
}
|
|
72
|
+
: null,
|
|
73
|
+
props.relatedTo ? { label: t('ai_assistant.chat.records.fields.related', 'Related'), value: props.relatedTo } : null,
|
|
74
|
+
props.ownerName ? { label: t('ai_assistant.chat.records.fields.owner', 'Owner'), value: props.ownerName } : null,
|
|
75
|
+
].filter(Boolean) as { label: string; value: React.ReactNode }[]
|
|
76
|
+
|
|
77
|
+
const subtitle = [props.type, props.relatedTo].filter(Boolean).join(' • ')
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<RecordCardShell
|
|
81
|
+
kindLabel={t('ai_assistant.chat.records.kinds.activity', 'Activity')}
|
|
82
|
+
kindIcon={<Icon className="size-4" aria-hidden />}
|
|
83
|
+
title={props.title}
|
|
84
|
+
subtitle={subtitle || undefined}
|
|
85
|
+
status={status}
|
|
86
|
+
href={props.href}
|
|
87
|
+
id={props.id}
|
|
88
|
+
className={props.className}
|
|
89
|
+
dataKind="activity"
|
|
90
|
+
>
|
|
91
|
+
<div className="space-y-2">
|
|
92
|
+
<KeyValueList items={items} />
|
|
93
|
+
{props.description ? (
|
|
94
|
+
<p className="line-clamp-3 text-muted-foreground">{props.description}</p>
|
|
95
|
+
) : null}
|
|
96
|
+
{props.tags && props.tags.length > 0 ? <TagRow tags={props.tags} /> : null}
|
|
97
|
+
</div>
|
|
98
|
+
</RecordCardShell>
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export default ActivityCard
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Building2, Globe, Mail, MapPin, Phone } from 'lucide-react'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { Avatar } from '../../primitives/avatar'
|
|
7
|
+
import { KeyValueList, RecordCardShell, TagRow, statusToTagVariant } from './RecordCardShell'
|
|
8
|
+
import type { CompanyRecordPayload } from './types'
|
|
9
|
+
|
|
10
|
+
export interface CompanyCardProps extends CompanyRecordPayload {}
|
|
11
|
+
|
|
12
|
+
function normalizeWebsite(value: string): { href: string; label: string } {
|
|
13
|
+
const trimmed = value.trim()
|
|
14
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
15
|
+
return { href: trimmed, label: trimmed.replace(/^https?:\/\//i, '') }
|
|
16
|
+
}
|
|
17
|
+
return { href: `https://${trimmed}`, label: trimmed }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function CompanyCard(props: CompanyCardProps) {
|
|
21
|
+
const t = useT()
|
|
22
|
+
const status = props.status
|
|
23
|
+
? { label: props.status, variant: statusToTagVariant(props.status) }
|
|
24
|
+
: null
|
|
25
|
+
|
|
26
|
+
const websiteEntry = props.website ? normalizeWebsite(props.website) : null
|
|
27
|
+
const location = [props.city, props.country].filter(Boolean).join(', ')
|
|
28
|
+
|
|
29
|
+
const items = [
|
|
30
|
+
props.industry ? { label: t('ai_assistant.chat.records.fields.industry', 'Industry'), value: props.industry } : null,
|
|
31
|
+
websiteEntry
|
|
32
|
+
? {
|
|
33
|
+
label: t('ai_assistant.chat.records.fields.website', 'Website'),
|
|
34
|
+
value: (
|
|
35
|
+
<a
|
|
36
|
+
href={websiteEntry.href}
|
|
37
|
+
target="_blank"
|
|
38
|
+
rel="noopener noreferrer"
|
|
39
|
+
className="text-primary hover:underline"
|
|
40
|
+
onClick={(event) => event.stopPropagation()}
|
|
41
|
+
>
|
|
42
|
+
{websiteEntry.label}
|
|
43
|
+
</a>
|
|
44
|
+
),
|
|
45
|
+
}
|
|
46
|
+
: null,
|
|
47
|
+
props.email
|
|
48
|
+
? {
|
|
49
|
+
label: t('ai_assistant.chat.records.fields.email', 'Email'),
|
|
50
|
+
value: (
|
|
51
|
+
<a
|
|
52
|
+
href={`mailto:${props.email}`}
|
|
53
|
+
className="text-primary hover:underline"
|
|
54
|
+
onClick={(event) => event.stopPropagation()}
|
|
55
|
+
>
|
|
56
|
+
{props.email}
|
|
57
|
+
</a>
|
|
58
|
+
),
|
|
59
|
+
}
|
|
60
|
+
: null,
|
|
61
|
+
props.phone ? { label: t('ai_assistant.chat.records.fields.phone', 'Phone'), value: props.phone } : null,
|
|
62
|
+
location ? { label: t('ai_assistant.chat.records.fields.location', 'Location'), value: location } : null,
|
|
63
|
+
props.ownerName ? { label: t('ai_assistant.chat.records.fields.owner', 'Owner'), value: props.ownerName } : null,
|
|
64
|
+
].filter(Boolean) as { label: string; value: React.ReactNode }[]
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<RecordCardShell
|
|
68
|
+
kindLabel={t('ai_assistant.chat.records.kinds.company', 'Company')}
|
|
69
|
+
kindIcon={<Building2 className="size-4" aria-hidden />}
|
|
70
|
+
leading={<Avatar label={props.name} src={props.logoUrl ?? undefined} size="md" variant="monochrome" />}
|
|
71
|
+
title={props.name}
|
|
72
|
+
subtitle={[props.industry, location].filter(Boolean).join(' • ') || undefined}
|
|
73
|
+
status={status}
|
|
74
|
+
href={props.href}
|
|
75
|
+
id={props.id}
|
|
76
|
+
className={props.className}
|
|
77
|
+
dataKind="company"
|
|
78
|
+
>
|
|
79
|
+
<div className="space-y-2">
|
|
80
|
+
<KeyValueList items={items} />
|
|
81
|
+
{props.tags && props.tags.length > 0 ? <TagRow tags={props.tags} /> : null}
|
|
82
|
+
</div>
|
|
83
|
+
</RecordCardShell>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export default CompanyCard
|
|
88
|
+
|
|
89
|
+
export { Building2, Globe, Mail, MapPin, Phone }
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Briefcase, Building2, CalendarDays, CircleDollarSign, User } from 'lucide-react'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { KeyValueList, RecordCardShell, TagRow, statusToTagVariant } from './RecordCardShell'
|
|
7
|
+
import type { DealRecordPayload } from './types'
|
|
8
|
+
|
|
9
|
+
function formatAmount(amount: string | number | null | undefined, currency?: string | null): string | null {
|
|
10
|
+
if (amount === null || amount === undefined || amount === '') return null
|
|
11
|
+
const value = typeof amount === 'number' ? amount : Number(amount)
|
|
12
|
+
if (!Number.isFinite(value)) {
|
|
13
|
+
return typeof amount === 'string' ? amount : null
|
|
14
|
+
}
|
|
15
|
+
const code = currency && currency.length === 3 ? currency.toUpperCase() : undefined
|
|
16
|
+
try {
|
|
17
|
+
if (code) {
|
|
18
|
+
return new Intl.NumberFormat(undefined, { style: 'currency', currency: code }).format(value)
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
// fall through to fallback
|
|
22
|
+
}
|
|
23
|
+
const formatted = new Intl.NumberFormat().format(value)
|
|
24
|
+
return code ? `${formatted} ${code}` : formatted
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function formatDate(value: string | null | undefined): string | null {
|
|
28
|
+
if (!value) return null
|
|
29
|
+
const d = new Date(value)
|
|
30
|
+
if (Number.isNaN(d.getTime())) return value
|
|
31
|
+
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface DealCardProps extends DealRecordPayload {}
|
|
35
|
+
|
|
36
|
+
export function DealCard(props: DealCardProps) {
|
|
37
|
+
const t = useT()
|
|
38
|
+
const status = props.status
|
|
39
|
+
? { label: props.status, variant: statusToTagVariant(props.status) }
|
|
40
|
+
: null
|
|
41
|
+
const stage = props.stage && props.stage !== props.status ? props.stage : null
|
|
42
|
+
const amount = formatAmount(props.amount, props.currency)
|
|
43
|
+
const closeDate = formatDate(props.closeDate)
|
|
44
|
+
|
|
45
|
+
const items = [
|
|
46
|
+
stage ? { label: t('ai_assistant.chat.records.fields.stage', 'Stage'), value: stage } : null,
|
|
47
|
+
amount ? { label: t('ai_assistant.chat.records.fields.amount', 'Amount'), value: <span className="font-medium">{amount}</span> } : null,
|
|
48
|
+
closeDate ? { label: t('ai_assistant.chat.records.fields.close', 'Close'), value: closeDate } : null,
|
|
49
|
+
props.companyName ? { label: t('ai_assistant.chat.records.fields.company', 'Company'), value: props.companyName } : null,
|
|
50
|
+
props.personName ? { label: t('ai_assistant.chat.records.fields.contact', 'Contact'), value: props.personName } : null,
|
|
51
|
+
props.ownerName ? { label: t('ai_assistant.chat.records.fields.owner', 'Owner'), value: props.ownerName } : null,
|
|
52
|
+
].filter(Boolean) as { label: string; value: React.ReactNode }[]
|
|
53
|
+
|
|
54
|
+
const subtitleParts: string[] = []
|
|
55
|
+
if (props.companyName) subtitleParts.push(props.companyName)
|
|
56
|
+
if (props.personName && !props.companyName) subtitleParts.push(props.personName)
|
|
57
|
+
if (amount) subtitleParts.push(amount)
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<RecordCardShell
|
|
61
|
+
kindLabel={t('ai_assistant.chat.records.kinds.deal', 'Deal')}
|
|
62
|
+
kindIcon={<Briefcase className="size-4" aria-hidden />}
|
|
63
|
+
title={props.title}
|
|
64
|
+
subtitle={subtitleParts.join(' • ')}
|
|
65
|
+
status={status}
|
|
66
|
+
href={props.href}
|
|
67
|
+
id={props.id}
|
|
68
|
+
className={props.className}
|
|
69
|
+
dataKind="deal"
|
|
70
|
+
>
|
|
71
|
+
<div className="space-y-2">
|
|
72
|
+
<KeyValueList items={items} />
|
|
73
|
+
{props.description ? (
|
|
74
|
+
<p className="line-clamp-2 text-muted-foreground">{props.description}</p>
|
|
75
|
+
) : null}
|
|
76
|
+
{props.tags && props.tags.length > 0 ? <TagRow tags={props.tags} /> : null}
|
|
77
|
+
</div>
|
|
78
|
+
</RecordCardShell>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export default DealCard
|
|
83
|
+
|
|
84
|
+
// Re-export icons consumers may want when extending the layout
|
|
85
|
+
export { Briefcase, Building2, CalendarDays, CircleDollarSign, User }
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Mail, Phone, User as UserIcon } from 'lucide-react'
|
|
5
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { Avatar } from '../../primitives/avatar'
|
|
7
|
+
import { KeyValueList, RecordCardShell, TagRow, statusToTagVariant } from './RecordCardShell'
|
|
8
|
+
import type { PersonRecordPayload } from './types'
|
|
9
|
+
|
|
10
|
+
export interface PersonCardProps extends PersonRecordPayload {}
|
|
11
|
+
|
|
12
|
+
export function PersonCard(props: PersonCardProps) {
|
|
13
|
+
const t = useT()
|
|
14
|
+
const status = props.status
|
|
15
|
+
? { label: props.status, variant: statusToTagVariant(props.status) }
|
|
16
|
+
: null
|
|
17
|
+
|
|
18
|
+
const items = [
|
|
19
|
+
props.title ? { label: t('ai_assistant.chat.records.fields.title', 'Title'), value: props.title } : null,
|
|
20
|
+
props.companyName ? { label: t('ai_assistant.chat.records.fields.company', 'Company'), value: props.companyName } : null,
|
|
21
|
+
props.email
|
|
22
|
+
? {
|
|
23
|
+
label: t('ai_assistant.chat.records.fields.email', 'Email'),
|
|
24
|
+
value: (
|
|
25
|
+
<a
|
|
26
|
+
href={`mailto:${props.email}`}
|
|
27
|
+
className="text-primary hover:underline"
|
|
28
|
+
onClick={(event) => event.stopPropagation()}
|
|
29
|
+
>
|
|
30
|
+
{props.email}
|
|
31
|
+
</a>
|
|
32
|
+
),
|
|
33
|
+
}
|
|
34
|
+
: null,
|
|
35
|
+
props.phone
|
|
36
|
+
? {
|
|
37
|
+
label: t('ai_assistant.chat.records.fields.phone', 'Phone'),
|
|
38
|
+
value: (
|
|
39
|
+
<a
|
|
40
|
+
href={`tel:${props.phone.replace(/\s+/g, '')}`}
|
|
41
|
+
className="text-primary hover:underline"
|
|
42
|
+
onClick={(event) => event.stopPropagation()}
|
|
43
|
+
>
|
|
44
|
+
{props.phone}
|
|
45
|
+
</a>
|
|
46
|
+
),
|
|
47
|
+
}
|
|
48
|
+
: null,
|
|
49
|
+
props.ownerName ? { label: t('ai_assistant.chat.records.fields.owner', 'Owner'), value: props.ownerName } : null,
|
|
50
|
+
].filter(Boolean) as { label: string; value: React.ReactNode }[]
|
|
51
|
+
|
|
52
|
+
const subtitle = [props.title, props.companyName].filter(Boolean).join(' • ')
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<RecordCardShell
|
|
56
|
+
kindLabel={t('ai_assistant.chat.records.kinds.person', 'Person')}
|
|
57
|
+
kindIcon={<UserIcon className="size-4" aria-hidden />}
|
|
58
|
+
leading={<Avatar label={props.name} src={props.avatarUrl ?? undefined} size="md" />}
|
|
59
|
+
title={props.name}
|
|
60
|
+
subtitle={subtitle || undefined}
|
|
61
|
+
status={status}
|
|
62
|
+
href={props.href}
|
|
63
|
+
id={props.id}
|
|
64
|
+
className={props.className}
|
|
65
|
+
dataKind="person"
|
|
66
|
+
>
|
|
67
|
+
<div className="space-y-2">
|
|
68
|
+
<KeyValueList items={items} />
|
|
69
|
+
{props.tags && props.tags.length > 0 ? <TagRow tags={props.tags} /> : null}
|
|
70
|
+
</div>
|
|
71
|
+
</RecordCardShell>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default PersonCard
|
|
76
|
+
|
|
77
|
+
export { Mail, Phone, UserIcon }
|