@adatechnology/conversations-ui 0.1.0-rc.9 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
  2. package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
  3. package/dist/chunk-DKPXKQGC.js +110 -0
  4. package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
  5. package/dist/flows/index.d.ts +422 -5
  6. package/dist/flows/index.js +2502 -676
  7. package/dist/index.d.ts +919 -17
  8. package/dist/index.js +3676 -675
  9. package/dist/preview/index.d.ts +62 -105
  10. package/dist/preview/index.js +162 -284
  11. package/dist/styles.css +893 -0
  12. package/package.json +9 -8
  13. package/src/AudioPlayer.tsx +8 -0
  14. package/src/AudioRecorderButton.test.tsx +30 -0
  15. package/src/AudioRecorderButton.tsx +248 -0
  16. package/src/AudioTranscription.test.tsx +115 -0
  17. package/src/AudioTranscription.tsx +252 -0
  18. package/src/Avatar.tsx +1 -1
  19. package/src/ConversationContextPanel.tsx +218 -44
  20. package/src/ConversationDocumentsPanel.tsx +11 -6
  21. package/src/ConversationHeader.test.tsx +66 -0
  22. package/src/ConversationHeader.tsx +147 -47
  23. package/src/ConversationListItem.tsx +8 -6
  24. package/src/ConversationLocalesProvider.tsx +28 -0
  25. package/src/ConversationRow.tsx +53 -7
  26. package/src/DarkModeToggle.test.tsx +76 -0
  27. package/src/DarkModeToggle.tsx +92 -0
  28. package/src/DocumentsLibrary.tsx +67 -7
  29. package/src/EmojiPicker.tsx +2 -1
  30. package/src/InteractiveMessage.tsx +3 -0
  31. package/src/Lightbox.tsx +1 -1
  32. package/src/MediaRenderer.tsx +88 -15
  33. package/src/MessageBubble.test.tsx +41 -0
  34. package/src/MessageBubble.tsx +47 -5
  35. package/src/MessageComposer.test.tsx +35 -0
  36. package/src/MessageComposer.tsx +122 -17
  37. package/src/MessageText.tsx +2 -1
  38. package/src/MessageTimestamp.tsx +2 -1
  39. package/src/RichMessageComposer.test.tsx +113 -0
  40. package/src/RichMessageComposer.tsx +551 -0
  41. package/src/SimpleEmojiPicker.tsx +5 -3
  42. package/src/StatusTicks.tsx +1 -1
  43. package/src/Toast.tsx +4 -0
  44. package/src/Tooltip.test.ts +42 -0
  45. package/src/Tooltip.tsx +167 -0
  46. package/src/Wallpaper.tsx +27 -13
  47. package/src/WhatsAppMessageEditor.tsx +10 -7
  48. package/src/WindowExpiredNotice.tsx +12 -4
  49. package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
  50. package/src/buildOutput.test.ts +79 -0
  51. package/src/composer.constant.ts +33 -0
  52. package/src/conversationTranscript.test.ts +57 -0
  53. package/src/conversationTranscript.ts +29 -4
  54. package/src/conversationWindow.ts +7 -5
  55. package/src/documentTypeLabel.test.ts +57 -0
  56. package/src/documents/DocumentsWorkspace.tsx +550 -0
  57. package/src/documents/index.ts +8 -0
  58. package/src/documents/labels.ts +92 -0
  59. package/src/flows/FlowConnectionEdge.tsx +104 -0
  60. package/src/flows/FlowGroupHeader.tsx +12 -2
  61. package/src/flows/FlowLegend.tsx +125 -0
  62. package/src/flows/FlowMapCanvas.tsx +15 -12
  63. package/src/flows/FlowMapNode.tsx +4 -1
  64. package/src/flows/FlowNodeCard.tsx +219 -34
  65. package/src/flows/FlowNodePanel.tsx +153 -39
  66. package/src/flows/FlowPalette.tsx +156 -70
  67. package/src/flows/FlowPortalNode.tsx +1 -1
  68. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  69. package/src/flows/FlowsWorkspace.tsx +1255 -0
  70. package/src/flows/flowCanvasModel.test.ts +456 -0
  71. package/src/flows/flowCanvasModel.ts +378 -0
  72. package/src/flows/flowEditorOps.test.ts +276 -0
  73. package/src/flows/flowEditorOps.ts +202 -0
  74. package/src/flows/flowGraph.ts +78 -53
  75. package/src/flows/flowMenuPlacement.test.ts +130 -0
  76. package/src/flows/flowMenuPlacement.ts +86 -0
  77. package/src/flows/index.ts +51 -2
  78. package/src/flows/labels.ts +180 -0
  79. package/src/flows/workspaceContract.test.ts +126 -0
  80. package/src/hooks/useContainerWidth.ts +35 -0
  81. package/src/hooks/useConversationRealtime.ts +10 -8
  82. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  83. package/src/hooks/useUrlFilterState.ts +107 -0
  84. package/src/icon.constant.ts +12 -0
  85. package/src/index.ts +100 -0
  86. package/src/lib/composer-formatting.test.ts +78 -0
  87. package/src/lib/composer-formatting.ts +145 -0
  88. package/src/lib/whatsapp-formatting.test.tsx +37 -0
  89. package/src/lib/whatsapp-formatting.tsx +28 -3
  90. package/src/listing/index.tsx +202 -0
  91. package/src/pagination.constant.ts +10 -0
  92. package/src/preview/ConversationPreview.tsx +84 -45
  93. package/src/preview/ConversationSimulatorClient.ts +143 -0
  94. package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
  95. package/src/preview/ConversationSimulatorPanel.tsx +131 -0
  96. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  97. package/src/preview/createPreviewBridgeClient.ts +124 -0
  98. package/src/preview/createPreviewMediaUploader.ts +82 -0
  99. package/src/preview/createPreviewWebhookClient.test.ts +96 -0
  100. package/src/preview/createPreviewWebhookClient.ts +99 -3
  101. package/src/preview/index.ts +36 -2
  102. package/src/preview/previewMediaUploader.test.ts +61 -0
  103. package/src/providers/ConversationsProvider.tsx +8 -6
  104. package/src/providers/types.ts +59 -2
  105. package/src/quickReply.test.ts +58 -0
  106. package/src/replyLatency.test.ts +71 -0
  107. package/src/replyLatency.ts +57 -0
  108. package/src/settings/MessagesWorkspace.tsx +571 -0
  109. package/src/settings/TopicsForm.tsx +2 -0
  110. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  111. package/src/settings/TranscriptionSettingsForm.tsx +190 -0
  112. package/src/settings/WelcomeFarewellForm.tsx +1 -0
  113. package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
  114. package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
  115. package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
  116. package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
  117. package/src/styles.css +858 -0
  118. package/src/theme.ts +13 -0
  119. package/src/types.ts +26 -0
  120. package/src/workspace/BulkTemplateModal.tsx +132 -0
  121. package/src/workspace/ConversationPane.tsx +432 -0
  122. package/src/workspace/ConversationsInboxList.tsx +194 -0
  123. package/src/workspace/ConversationsWorkspace.tsx +423 -0
  124. package/src/workspace/index.ts +17 -0
  125. package/src/workspace/labels.test.ts +17 -0
  126. package/src/workspace/labels.ts +85 -0
  127. package/src/workspace/useConversationsInbox.ts +332 -0
  128. package/dist/types-B5C1DLu1.d.ts +0 -365
  129. package/src/preview/AudioRecorderButton.tsx +0 -117
@@ -0,0 +1,550 @@
1
+ /**
2
+ * Tela completa de Documentos: a biblioteca de arquivos de TODAS as conversas, fora do atendimento.
3
+ *
4
+ * Distinta do `ConversationDocumentsPanel`, que parte de uma conversa aberta e vive dentro dela.
5
+ * Esta varre a empresa, e por isso mostra de qual conversa cada arquivo veio.
6
+ *
7
+ * É a tela inteira, não um punhado de peças: ordenação por coluna, filtros de seleção múltipla,
8
+ * recorte por data, seleção em lote, paginação e espelho na URL já vêm montados. Produto que
9
+ * remontasse isso à mão voltaria a divergir dos outros — foi o que aconteceu antes desta tela existir.
10
+ */
11
+
12
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
13
+ import { Download, Eye, MessageSquare, Trash2, Upload, X } from 'lucide-react'
14
+
15
+ import { useConversations } from '../providers/ConversationsProvider'
16
+ import { FileIcon } from '../FileIcon'
17
+ import { documentTypeLabel } from '../MediaRenderer'
18
+ import { cn } from '../lib/cn'
19
+ import { formatDateTime, formatFileSize } from '../lib/format'
20
+ import { formatPhone } from '../lib/phone'
21
+ import {
22
+ BulkActionBar,
23
+ ListingPagination,
24
+ MultiSelectFilter,
25
+ SortableHead,
26
+ type FilterOption,
27
+ type SortDirection,
28
+ } from '../listing'
29
+ import { useDebouncedValue, useUrlArrayState, useUrlNumberState, useUrlStringState } from '../hooks/useUrlFilterState'
30
+ import type { CompanyDocument } from '../providers/types'
31
+ import { DEFAULT_DOCUMENTS_WORKSPACE_LABELS, type DocumentsWorkspaceLabels } from './labels'
32
+ import { TooltipLayer } from '../Tooltip'
33
+
34
+ const DEFAULT_PER_PAGE = 20
35
+ const DEFAULT_PER_PAGE_OPTIONS = [10, 20, 50, 100] as const
36
+ const DEFAULT_SORT_FIELD = 'linkedAt'
37
+
38
+ export interface DocumentsWorkspaceClassNames {
39
+ root: string
40
+ header: string
41
+ filters: string
42
+ table: string
43
+ row: string
44
+ status: string
45
+ }
46
+
47
+ export interface DocumentsWorkspaceProps {
48
+ readonly perPage?: number
49
+ readonly perPageOptions?: readonly number[]
50
+ /** Abrir a conversa de origem. Ausente, o número aparece como texto e não como link. */
51
+ readonly onOpenConversation?: (conversationId: string) => void
52
+ /** Origens filtráveis. Ausente, usa o vocabulário padrão (`customer`, `agent`, `bot`). */
53
+ readonly sources?: readonly FilterOption[]
54
+ /** Categorias de arquivo. Lista vazia esconde o filtro — nem todo host classifica anexo. */
55
+ readonly categories?: readonly FilterOption[]
56
+ /** Recorte por data. Desligado quando o backend não sabe filtrar por período. */
57
+ readonly dateFilter?: boolean
58
+ /**
59
+ * Filtros do produto (cliente, unidade, campanha). Recebe os parâmetros extras atuais e devolve
60
+ * os controles; o que o produto guardar aqui viaja em `extra` para o `getAllDocuments`.
61
+ */
62
+ readonly renderFilters?: (context: DocumentsFiltersContext) => ReactNode
63
+ readonly labels?: Partial<DocumentsWorkspaceLabels>
64
+ readonly className?: string
65
+ readonly classNames?: Partial<DocumentsWorkspaceClassNames>
66
+ /** Espelhar filtros e paginação na URL. Desligado em preview, onde não há rota de verdade. */
67
+ readonly syncUrl?: boolean
68
+ }
69
+
70
+ export interface DocumentsFiltersContext {
71
+ readonly extra: Readonly<Record<string, string | number>>
72
+ readonly setExtra: (next: Readonly<Record<string, string | number>>) => void
73
+ }
74
+
75
+ export function DocumentsWorkspace({
76
+ perPage: initialPerPage = DEFAULT_PER_PAGE,
77
+ perPageOptions = DEFAULT_PER_PAGE_OPTIONS,
78
+ onOpenConversation,
79
+ sources,
80
+ categories,
81
+ dateFilter = true,
82
+ renderFilters,
83
+ labels: labelsOverride,
84
+ className,
85
+ classNames,
86
+ syncUrl = true,
87
+ }: DocumentsWorkspaceProps) {
88
+ const labels = { ...DEFAULT_DOCUMENTS_WORKSPACE_LABELS, ...labelsOverride }
89
+ const context = useConversations()
90
+ const urlOptions = { enabled: syncUrl }
91
+
92
+ const [search, setSearch] = useUrlStringState('search', '', urlOptions)
93
+ const [source, setSource] = useUrlArrayState('source', urlOptions)
94
+ const [category, setCategory] = useUrlArrayState('fileCategory', urlOptions)
95
+ const [startDate, setStartDate] = useUrlStringState('startDate', '', urlOptions)
96
+ const [endDate, setEndDate] = useUrlStringState('endDate', '', urlOptions)
97
+ const [sortField, setSortField] = useUrlStringState('sortField', DEFAULT_SORT_FIELD, urlOptions)
98
+ const [sortDirection, setSortDirection] = useUrlStringState('sortDirection', 'desc', urlOptions)
99
+ const [page, setPage] = useUrlNumberState('page', 1, urlOptions)
100
+ const [perPage, setPerPage] = useUrlNumberState('limit', initialPerPage, urlOptions)
101
+ const [extra, setExtra] = useState<Readonly<Record<string, string | number>>>({})
102
+
103
+ const [documents, setDocuments] = useState<readonly CompanyDocument[]>([])
104
+ const [total, setTotal] = useState(0)
105
+ const [loading, setLoading] = useState(false)
106
+ const [failed, setFailed] = useState(false)
107
+ const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set())
108
+ const [reloadToken, setReloadToken] = useState(0)
109
+ const [busy, setBusy] = useState(false)
110
+ const [uploading, setUploading] = useState(false)
111
+ const [uploadFailed, setUploadFailed] = useState(false)
112
+ const fileInputRef = useRef<HTMLInputElement>(null)
113
+
114
+ const debouncedSearch = useDebouncedValue(search)
115
+ const fetchAll = context?.api.getAllDocuments
116
+ const removeDocument = context?.api.deleteDocument
117
+ const downloadArchive = context?.api.downloadDocumentsArchiveByIds
118
+ const uploadDocument = context?.api.uploadDocument
119
+ const extraKey = JSON.stringify(extra)
120
+
121
+ const sourceOptions = useMemo<readonly FilterOption[]>(
122
+ () => sources ?? Object.entries(labels.sourceLabels).map(([value, label]) => ({ value, label })),
123
+ [sources, labels.sourceLabels],
124
+ )
125
+ const categoryOptions = useMemo<readonly FilterOption[]>(
126
+ () => categories ?? Object.entries(labels.categoryLabels).map(([value, label]) => ({ value, label })),
127
+ [categories, labels.categoryLabels],
128
+ )
129
+
130
+ const hasFilters =
131
+ debouncedSearch !== '' ||
132
+ source.length > 0 ||
133
+ category.length > 0 ||
134
+ startDate !== '' ||
135
+ endDate !== '' ||
136
+ Object.keys(extra).length > 0
137
+
138
+ useEffect(() => {
139
+ if (!fetchAll) return
140
+ let active = true
141
+ setLoading(true)
142
+ setFailed(false)
143
+
144
+ void fetchAll({
145
+ search: debouncedSearch || undefined,
146
+ page,
147
+ limit: perPage,
148
+ sortField,
149
+ sortDirection: sortDirection === 'asc' ? 'asc' : 'desc',
150
+ ...(source.length > 0 ? { source: source.join(',') } : {}),
151
+ ...(category.length > 0 ? { fileCategory: category.join(',') } : {}),
152
+ ...(startDate ? { startDate } : {}),
153
+ ...(endDate ? { endDate } : {}),
154
+ ...(Object.keys(extra).length > 0 ? { extra } : {}),
155
+ })
156
+ .then((result) => {
157
+ // `active` evita que uma resposta antiga sobrescreva a nova: digitar rápido na busca dispara
158
+ // várias chamadas e a ordem de retorno não é garantida.
159
+ if (!active) return
160
+ setDocuments(result.documents)
161
+ setTotal(result.total)
162
+ })
163
+ .catch(() => {
164
+ if (active) setFailed(true)
165
+ })
166
+ .finally(() => {
167
+ if (active) setLoading(false)
168
+ })
169
+
170
+ return () => {
171
+ active = false
172
+ }
173
+ // `extraKey` no lugar de `extra`: o objeto é remontado a cada render do host e a identidade
174
+ // sozinha dispararia a busca em loop.
175
+
176
+ }, [fetchAll, debouncedSearch, source, category, startDate, endDate, sortField, sortDirection, page, perPage, extraKey, reloadToken])
177
+
178
+ // Trocar de filtro mantendo a página 7 mostra "nenhum resultado" com dados existindo na página 1.
179
+ useEffect(() => {
180
+ setPage(1)
181
+ setSelectedIds(new Set())
182
+
183
+ }, [debouncedSearch, source, category, startDate, endDate, perPage, extraKey])
184
+
185
+ function toggleSort(field: string): void {
186
+ if (sortField === field) setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
187
+ else {
188
+ setSortField(field)
189
+ setSortDirection('asc')
190
+ }
191
+ }
192
+
193
+ function toggleRow(id: string): void {
194
+ const next = new Set(selectedIds)
195
+ if (next.has(id)) next.delete(id)
196
+ else next.add(id)
197
+ setSelectedIds(next)
198
+ }
199
+
200
+ const isAllSelected = documents.length > 0 && documents.every((document) => selectedIds.has(document.id))
201
+
202
+ function toggleAll(): void {
203
+ setSelectedIds(isAllSelected ? new Set() : new Set(documents.map((document) => document.id)))
204
+ }
205
+
206
+ function clearFilters(): void {
207
+ setSearch('')
208
+ setSource([])
209
+ setCategory([])
210
+ setStartDate('')
211
+ setEndDate('')
212
+ setExtra({})
213
+ }
214
+
215
+ async function handleOpen(uploadId: string, disposition: 'inline' | 'attachment'): Promise<void> {
216
+ const url = await context?.api.getDocumentUrl(uploadId, disposition)
217
+ if (url) window.open(url, '_blank', 'noopener,noreferrer')
218
+ }
219
+
220
+ async function handleRemove(ids: readonly string[]): Promise<void> {
221
+ if (!removeDocument) return
222
+ setBusy(true)
223
+ try {
224
+ await Promise.all(ids.map((id) => removeDocument(id)))
225
+ setSelectedIds(new Set())
226
+ setReloadToken((token) => token + 1)
227
+ } finally {
228
+ setBusy(false)
229
+ }
230
+ }
231
+
232
+ async function handleUpload(file: File): Promise<void> {
233
+ if (!uploadDocument) return
234
+ setUploading(true)
235
+ setUploadFailed(false)
236
+ try {
237
+ await uploadDocument(file, Object.keys(extra).length > 0 ? extra : undefined)
238
+ setReloadToken((token) => token + 1)
239
+ } catch {
240
+ setUploadFailed(true)
241
+ } finally {
242
+ setUploading(false)
243
+ }
244
+ }
245
+
246
+ async function handleDownloadArchive(): Promise<void> {
247
+ if (!downloadArchive) return
248
+ setBusy(true)
249
+ try {
250
+ const blob = await downloadArchive(Array.from(selectedIds))
251
+ const url = URL.createObjectURL(blob)
252
+ const anchor = document.createElement('a')
253
+ anchor.href = url
254
+ anchor.download = 'documentos.zip'
255
+ anchor.click()
256
+ URL.revokeObjectURL(url)
257
+ setSelectedIds(new Set())
258
+ } finally {
259
+ setBusy(false)
260
+ }
261
+ }
262
+
263
+ // Host sem `getAllDocuments` não tem o que mostrar aqui — some, em vez de renderizar vazio para
264
+ // sempre e fazer parecer que a empresa não tem arquivo nenhum.
265
+ if (!fetchAll) return null
266
+
267
+ const direction: SortDirection = sortDirection === 'asc' ? 'asc' : 'desc'
268
+ const canSelect = Boolean(removeDocument ?? downloadArchive)
269
+
270
+ return (
271
+ <div className={cn('space-y-4', classNames?.root, className)}>
272
+ <TooltipLayer />
273
+ <header className={cn('space-y-0.5', classNames?.header)}>
274
+ <h2 className="text-lg font-semibold">{labels.title}</h2>
275
+ <p className="text-sm text-gray-500 dark:text-gray-400">{labels.subtitle(total)}</p>
276
+ </header>
277
+
278
+ <div className={cn('flex flex-wrap items-center gap-2', classNames?.filters)}>
279
+ <input
280
+ type="search"
281
+ value={search}
282
+ onChange={(event) => setSearch(event.target.value)}
283
+ placeholder={labels.searchPlaceholder}
284
+ aria-label={labels.searchPlaceholder}
285
+ className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 sm:w-64"
286
+ />
287
+
288
+ <MultiSelectFilter label={labels.sourceFilter} options={sourceOptions} selected={source} onChange={setSource} />
289
+
290
+ {categoryOptions.length > 0 ? (
291
+ <MultiSelectFilter
292
+ label={labels.categoryFilter}
293
+ options={categoryOptions}
294
+ selected={category}
295
+ onChange={setCategory}
296
+ />
297
+ ) : null}
298
+
299
+ {dateFilter ? (
300
+ <>
301
+ <input
302
+ type="date"
303
+ value={startDate}
304
+ onChange={(event) => setStartDate(event.target.value)}
305
+ aria-label={labels.startDate}
306
+ className="rounded-md border border-gray-300 px-2 py-2 text-xs dark:border-gray-700 dark:bg-gray-900"
307
+ />
308
+ <input
309
+ type="date"
310
+ value={endDate}
311
+ onChange={(event) => setEndDate(event.target.value)}
312
+ aria-label={labels.endDate}
313
+ className="rounded-md border border-gray-300 px-2 py-2 text-xs dark:border-gray-700 dark:bg-gray-900"
314
+ />
315
+ </>
316
+ ) : null}
317
+
318
+ {renderFilters?.({ extra, setExtra })}
319
+
320
+ {hasFilters ? (
321
+ <button data-cv-tooltip={labels.clearFilters} aria-label={labels.clearFilters} type="button" onClick={clearFilters} className="cv-header-action inline-flex items-center gap-1">
322
+ <X size={12} aria-hidden="true" />
323
+ {labels.clearFilters}
324
+ </button>
325
+ ) : null}
326
+
327
+ {uploadDocument ? (
328
+ <>
329
+ <input
330
+ ref={fileInputRef}
331
+ type="file"
332
+ hidden
333
+ onChange={(event) => {
334
+ const file = event.target.files?.[0]
335
+ event.target.value = ''
336
+ if (file) void handleUpload(file)
337
+ }}
338
+ />
339
+ <button
340
+ data-cv-tooltip={labels.upload}
341
+ aria-label={labels.upload}
342
+ type="button"
343
+ onClick={() => fileInputRef.current?.click()}
344
+ disabled={uploading}
345
+ className="cv-header-action ml-auto inline-flex items-center gap-1 disabled:opacity-40"
346
+ >
347
+ <Upload size={12} aria-hidden="true" />
348
+ {labels.upload}
349
+ </button>
350
+ </>
351
+ ) : null}
352
+ </div>
353
+
354
+ {uploadFailed ? (
355
+ <p role="alert" className={cn('text-sm text-red-600 dark:text-red-400', classNames?.status)}>
356
+ {labels.uploadError}
357
+ </p>
358
+ ) : null}
359
+
360
+ {canSelect ? (
361
+ <BulkActionBar
362
+ selectedCount={selectedIds.size}
363
+ selectedLabel={labels.bulkSelected}
364
+ clearLabel={labels.bulkClear}
365
+ onClear={() => setSelectedIds(new Set())}
366
+ >
367
+ {downloadArchive ? (
368
+ <button
369
+ data-cv-tooltip={labels.bulkDownloadZip} aria-label={labels.bulkDownloadZip}
370
+ type="button"
371
+ onClick={() => void handleDownloadArchive()}
372
+ disabled={busy}
373
+ className="cv-header-action inline-flex items-center gap-1 disabled:opacity-40"
374
+ >
375
+ <Download size={12} aria-hidden="true" />
376
+ {labels.bulkDownloadZip}
377
+ </button>
378
+ ) : null}
379
+ {removeDocument ? (
380
+ <button
381
+ data-cv-tooltip={labels.bulkRemove(selectedIds.size)} aria-label={labels.bulkRemove(selectedIds.size)}
382
+ type="button"
383
+ onClick={() => {
384
+ if (window.confirm(labels.bulkRemoveConfirm(selectedIds.size))) void handleRemove(Array.from(selectedIds))
385
+ }}
386
+ disabled={busy}
387
+ className="cv-header-action cv-header-action--danger inline-flex items-center gap-1 disabled:opacity-40"
388
+ >
389
+ <Trash2 size={12} aria-hidden="true" />
390
+ {labels.bulkRemove(selectedIds.size)}
391
+ </button>
392
+ ) : null}
393
+ </BulkActionBar>
394
+ ) : null}
395
+
396
+ {loading ? <p className={cn('text-sm text-gray-500', classNames?.status)}>{labels.loading}</p> : null}
397
+ {failed ? (
398
+ <p role="alert" className={cn('text-sm text-red-600 dark:text-red-400', classNames?.status)}>
399
+ {labels.failure}
400
+ </p>
401
+ ) : null}
402
+
403
+ <div className={cn('cv-table-card', classNames?.table)}>
404
+ <table className="cv-table">
405
+ <thead>
406
+ <tr>
407
+ {canSelect ? (
408
+ <th scope="col" className="w-10 px-3 py-2">
409
+ <input
410
+ type="checkbox"
411
+ checked={isAllSelected}
412
+ onChange={toggleAll}
413
+ aria-label={labels.selectAllPage}
414
+ className="cursor-pointer rounded border-gray-300 text-blue-600 dark:border-gray-600"
415
+ />
416
+ </th>
417
+ ) : null}
418
+ <SortableHead label={labels.columnFilename} field="filename" activeField={sortField} direction={direction} onSort={toggleSort} />
419
+ <SortableHead label={labels.columnContact} field="conversationId" activeField={sortField} direction={direction} onSort={toggleSort} className="hidden sm:table-cell" />
420
+ <SortableHead label={labels.columnType} field="mimeType" activeField={sortField} direction={direction} onSort={toggleSort} className="hidden md:table-cell" />
421
+ <SortableHead label={labels.columnSize} field="sizeBytes" activeField={sortField} direction={direction} onSort={toggleSort} className="hidden lg:table-cell" />
422
+ <SortableHead label={labels.columnSource} field="source" activeField={sortField} direction={direction} onSort={toggleSort} className="hidden lg:table-cell" />
423
+ <SortableHead label={labels.columnDate} field="linkedAt" activeField={sortField} direction={direction} onSort={toggleSort} className="hidden xl:table-cell" />
424
+ <th scope="col" className="px-3 py-2 text-left text-xs font-medium">
425
+ {labels.columnActions}
426
+ </th>
427
+ </tr>
428
+ </thead>
429
+ <tbody>
430
+ {documents.map((document) => (
431
+ <tr key={`${document.conversationId}:${document.id}`} className={classNames?.row}>
432
+ {canSelect ? (
433
+ <td className="w-10 px-3 py-2">
434
+ <input
435
+ type="checkbox"
436
+ checked={selectedIds.has(document.id)}
437
+ onChange={() => toggleRow(document.id)}
438
+ aria-label={`${labels.selectRow}: ${document.filename}`}
439
+ className="cursor-pointer rounded border-gray-300 text-blue-600 dark:border-gray-600"
440
+ />
441
+ </td>
442
+ ) : null}
443
+
444
+ <td className="max-w-xs px-3 py-2">
445
+ <div className="flex min-w-0 items-center gap-2">
446
+ <FileIcon filename={document.filename} mimeType={document.mimeType} />
447
+ <span className="truncate font-medium" data-cv-tooltip={document.filename}>
448
+ {document.filename}
449
+ </span>
450
+ </div>
451
+ </td>
452
+
453
+ {/* A conversa de origem é o dado que só esta tela tem. Vira botão quando o host sabe
454
+ navegar; sem handler, fica texto — link que não leva a lugar nenhum é pior. */}
455
+ <td className="hidden px-3 py-2 sm:table-cell">
456
+ {onOpenConversation ? (
457
+ <button
458
+ type="button"
459
+ onClick={() => onOpenConversation(document.conversationId)}
460
+ data-cv-tooltip={labels.openConversation} aria-label={labels.openConversation}
461
+ className="cv-header-action inline-flex items-center gap-1"
462
+ >
463
+ <MessageSquare size={12} aria-hidden="true" />
464
+ {document.contactName ?? formatPhone(document.conversationId)}
465
+ </button>
466
+ ) : (
467
+ <span className="text-xs text-gray-500">{document.contactName ?? formatPhone(document.conversationId)}</span>
468
+ )}
469
+ </td>
470
+
471
+ {/* O tipo curto, não o MIME cru: `.docx` responde
472
+ `application/vnd.openxmlformats-officedocument.wordprocessingml.document`, que numa
473
+ célula sem quebra esticava a coluna a 556px e espremia todas as outras. O MIME
474
+ inteiro continua na dica. */}
475
+ <td className="hidden px-3 py-2 md:table-cell" data-cv-tooltip={document.mimeType}>
476
+ <span className="cv-type-tag">{documentTypeLabel(document.filename, document.mimeType)}</span>
477
+ </td>
478
+ <td className="hidden whitespace-nowrap px-3 py-2 text-xs lg:table-cell">{formatFileSize(document.sizeBytes)}</td>
479
+ <td className="hidden whitespace-nowrap px-3 py-2 text-xs lg:table-cell">
480
+ {labels.sourceLabels[document.source] ?? document.source}
481
+ </td>
482
+ <td className="hidden whitespace-nowrap px-3 py-2 text-xs xl:table-cell">{formatDateTime(document.linkedAt)}</td>
483
+
484
+ <td className="px-3 py-2">
485
+ <div className="flex gap-1">
486
+ <button
487
+ type="button"
488
+ onClick={() => void handleOpen(document.id, 'inline')}
489
+ data-cv-tooltip={labels.view}
490
+ aria-label={`${labels.view}: ${document.filename}`}
491
+ className="cv-header-icon"
492
+ >
493
+ <Eye size={14} />
494
+ </button>
495
+ <button
496
+ type="button"
497
+ onClick={() => void handleOpen(document.id, 'attachment')}
498
+ data-cv-tooltip={labels.download}
499
+ aria-label={`${labels.download}: ${document.filename}`}
500
+ className="cv-header-icon"
501
+ >
502
+ <Download size={14} />
503
+ </button>
504
+ {removeDocument ? (
505
+ <button
506
+ type="button"
507
+ onClick={() => {
508
+ if (window.confirm(labels.removeConfirm(document.filename))) void handleRemove([document.id])
509
+ }}
510
+ disabled={busy}
511
+ data-cv-tooltip={labels.remove}
512
+ aria-label={`${labels.remove}: ${document.filename}`}
513
+ className="cv-header-icon disabled:opacity-40"
514
+ >
515
+ <Trash2 size={14} className="text-red-500" />
516
+ </button>
517
+ ) : null}
518
+ </div>
519
+ </td>
520
+ </tr>
521
+ ))}
522
+ </tbody>
523
+ </table>
524
+
525
+ {!loading && documents.length === 0 ? (
526
+ <p className={cn('py-8 text-center text-sm text-gray-400', classNames?.status)}>
527
+ {hasFilters ? labels.noResults : labels.empty}
528
+ </p>
529
+ ) : null}
530
+ </div>
531
+
532
+ <ListingPagination
533
+ page={page}
534
+ total={total}
535
+ perPage={perPage}
536
+ perPageOptions={perPageOptions}
537
+ onPageChange={setPage}
538
+ onPerPageChange={setPerPage}
539
+ labels={{
540
+ show: labels.show,
541
+ perPage: labels.perPage,
542
+ total: labels.total,
543
+ page: labels.page,
544
+ previous: labels.previousPage,
545
+ next: labels.nextPage,
546
+ }}
547
+ />
548
+ </div>
549
+ )
550
+ }
@@ -0,0 +1,8 @@
1
+ export { DocumentsWorkspace } from './DocumentsWorkspace'
2
+ export type {
3
+ DocumentsWorkspaceProps,
4
+ DocumentsWorkspaceClassNames,
5
+ DocumentsFiltersContext,
6
+ } from './DocumentsWorkspace'
7
+ export { DEFAULT_DOCUMENTS_WORKSPACE_LABELS } from './labels'
8
+ export type { DocumentsWorkspaceLabels } from './labels'
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Vocabulário da biblioteca de arquivos. Sobrescrevível campo a campo: o produto troca "cliente"
3
+ * por "lead" ou o idioma inteiro sem manter uma cópia da tela para isso.
4
+ */
5
+
6
+ export interface DocumentsWorkspaceLabels {
7
+ readonly title: string
8
+ readonly subtitle: (total: number) => string
9
+ readonly searchPlaceholder: string
10
+ readonly empty: string
11
+ readonly noResults: string
12
+ readonly loading: string
13
+ readonly failure: string
14
+ readonly view: string
15
+ readonly download: string
16
+ readonly remove: string
17
+ readonly removeConfirm: (filename: string) => string
18
+ readonly openConversation: string
19
+ readonly sourceFilter: string
20
+ readonly categoryFilter: string
21
+ readonly startDate: string
22
+ readonly endDate: string
23
+ readonly clearFilters: string
24
+ readonly selectAllPage: string
25
+ readonly selectRow: string
26
+ readonly bulkSelected: (count: number) => string
27
+ readonly bulkClear: string
28
+ readonly bulkDownloadZip: string
29
+ readonly bulkRemove: (count: number) => string
30
+ readonly bulkRemoveConfirm: (count: number) => string
31
+ readonly upload: string
32
+ readonly uploadError: string
33
+ readonly columnFilename: string
34
+ readonly columnContact: string
35
+ readonly columnType: string
36
+ readonly columnSize: string
37
+ readonly columnSource: string
38
+ readonly columnDate: string
39
+ readonly columnActions: string
40
+ readonly sourceLabels: Readonly<Record<string, string>>
41
+ readonly categoryLabels: Readonly<Record<string, string>>
42
+ readonly show: string
43
+ readonly perPage: string
44
+ readonly total: (count: number) => string
45
+ readonly page: (current: number, last: number) => string
46
+ readonly previousPage: string
47
+ readonly nextPage: string
48
+ }
49
+
50
+ export const DEFAULT_DOCUMENTS_WORKSPACE_LABELS: DocumentsWorkspaceLabels = {
51
+ title: 'Documentos',
52
+ subtitle: (total) => `${total} arquivo${total === 1 ? '' : 's'} trocado${total === 1 ? '' : 's'}`,
53
+ searchPlaceholder: 'Buscar por nome do arquivo ou telefone',
54
+ empty: 'Nenhum arquivo trocado ainda.',
55
+ noResults: 'Nenhum arquivo encontrado para os filtros aplicados.',
56
+ loading: 'Carregando arquivos…',
57
+ failure: 'Não foi possível carregar os arquivos.',
58
+ view: 'Visualizar',
59
+ download: 'Baixar',
60
+ remove: 'Excluir',
61
+ removeConfirm: (filename) => `Excluir "${filename}"?`,
62
+ openConversation: 'Abrir conversa',
63
+ sourceFilter: 'Origem',
64
+ categoryFilter: 'Tipo',
65
+ startDate: 'De',
66
+ endDate: 'Até',
67
+ clearFilters: 'Limpar filtros',
68
+ selectAllPage: 'Selecionar todos desta página',
69
+ selectRow: 'Selecionar arquivo',
70
+ bulkSelected: (count) => `${count} selecionado${count === 1 ? '' : 's'}`,
71
+ bulkClear: 'Limpar seleção',
72
+ bulkDownloadZip: 'Baixar em zip',
73
+ bulkRemove: (count) => `Excluir ${count}`,
74
+ bulkRemoveConfirm: (count) => `Excluir ${count} arquivo${count === 1 ? '' : 's'}?`,
75
+ upload: 'Enviar documento',
76
+ uploadError: 'Não foi possível enviar o arquivo.',
77
+ columnFilename: 'Arquivo',
78
+ columnContact: 'Contato',
79
+ columnType: 'Tipo',
80
+ columnSize: 'Tamanho',
81
+ columnSource: 'Origem',
82
+ columnDate: 'Recebido em',
83
+ columnActions: 'Ações',
84
+ sourceLabels: { customer: 'Cliente', agent: 'Atendente', bot: 'Bot' },
85
+ categoryLabels: { document: 'Documento', image: 'Imagem', audio: 'Áudio', video: 'Vídeo' },
86
+ show: 'Mostrar',
87
+ perPage: 'por página',
88
+ total: (count) => `${count} no total`,
89
+ page: (current, last) => `${current} / ${last}`,
90
+ previousPage: 'Página anterior',
91
+ nextPage: 'Próxima página',
92
+ }