@adatechnology/conversations-ui 0.1.0-rc.24 → 0.1.0-rc.26

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.
@@ -1,5 +1,5 @@
1
- import { G as MessagePayload, o as ConversationSummary, m as ConversationEventSource, q as ConversationsApi, L as ListConversationsParams, n as ConversationPage, S as SSEProvider, k as ConversationDocument, N as ResolveMediaUrl } from '../types-C6A_9edv.js';
2
- export { A as AudioRecorderButton, a as AudioRecorderButtonLabels, b as AudioRecorderButtonProps, D as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '../types-C6A_9edv.js';
1
+ import { G as MessagePayload, o as ConversationSummary, m as ConversationEventSource, q as ConversationsApi, L as ListConversationsParams, n as ConversationPage, S as SSEProvider, k as ConversationDocument, N as ResolveMediaUrl } from '../types-BfINicc-.js';
2
+ export { A as AudioRecorderButton, a as AudioRecorderButtonLabels, b as AudioRecorderButtonProps, D as DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '../types-BfINicc-.js';
3
3
  import * as react from 'react';
4
4
  import { ReactNode } from 'react';
5
5
  import { InteractiveReplyOption, InboundMediaType } from '@adatechnology/meta-whatsapp-contracts/testing';
package/dist/styles.css CHANGED
@@ -831,3 +831,85 @@
831
831
  .dark .cv-workspace-attachments li {
832
832
  border-color: rgb(51 65 85);
833
833
  }
834
+ .cv-filter-count {
835
+ display: inline-flex;
836
+ align-items: center;
837
+ justify-content: center;
838
+ min-width: 1rem;
839
+ height: 1rem;
840
+ padding: 0 0.25rem;
841
+ border-radius: 9999px;
842
+ background: rgb(37 99 235);
843
+ color: white;
844
+ font-size: 0.625rem;
845
+ font-weight: 600;
846
+ }
847
+ .cv-filter-menu {
848
+ position: absolute;
849
+ top: 100%;
850
+ left: 0;
851
+ z-index: 20;
852
+ margin-top: 0.25rem;
853
+ min-width: 12rem;
854
+ padding: 0.5rem;
855
+ border: 1px solid rgb(226 232 240);
856
+ border-radius: 0.5rem;
857
+ background: white;
858
+ box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
859
+ }
860
+ .dark .cv-filter-menu {
861
+ background: rgb(30 41 59);
862
+ border-color: rgb(71 85 105);
863
+ }
864
+ .cv-filter-option {
865
+ display: flex;
866
+ align-items: center;
867
+ gap: 0.5rem;
868
+ padding: 0.375rem 0.5rem;
869
+ border-radius: 0.375rem;
870
+ font-size: 0.75rem;
871
+ cursor: pointer;
872
+ }
873
+ .cv-filter-option:hover {
874
+ background: rgb(241 245 249);
875
+ }
876
+ .dark .cv-filter-option:hover {
877
+ background: rgb(51 65 85);
878
+ }
879
+ .cv-bulk-bar {
880
+ display: flex;
881
+ flex-wrap: wrap;
882
+ align-items: center;
883
+ gap: 0.5rem;
884
+ padding: 0.5rem 0.75rem;
885
+ border: 1px solid rgb(191 219 254);
886
+ border-radius: 0.5rem;
887
+ background: rgb(239 246 255);
888
+ }
889
+ .dark .cv-bulk-bar {
890
+ background: rgb(30 58 138 / 0.25);
891
+ border-color: rgb(30 64 175);
892
+ }
893
+ .cv-listing-pagination {
894
+ display: flex;
895
+ flex-wrap: wrap;
896
+ align-items: center;
897
+ justify-content: space-between;
898
+ gap: 0.5rem;
899
+ padding-top: 0.5rem;
900
+ border-top: 1px solid rgb(241 245 249);
901
+ }
902
+ .dark .cv-listing-pagination {
903
+ border-color: rgb(30 41 59);
904
+ }
905
+ .cv-listing-perpage {
906
+ height: 2rem;
907
+ padding: 0 0.5rem;
908
+ border: 1px solid rgb(226 232 240);
909
+ border-radius: 0.375rem;
910
+ background: transparent;
911
+ font-size: 0.75rem;
912
+ }
913
+ .dark .cv-listing-perpage {
914
+ border-color: rgb(71 85 105);
915
+ }
@@ -284,13 +284,37 @@ interface ListDocumentsParams {
284
284
  page?: number;
285
285
  /** Tamanho da página. Sem ele, `page` sozinho não define fatia nenhuma. */
286
286
  limit?: number;
287
- /** Origem do arquivo (`customer`, `agent`, `bot`…). O vocabulário é do host. */
287
+ /**
288
+ * Origem do arquivo (`customer`, `agent`, `bot`…). O vocabulário é do host.
289
+ *
290
+ * Seleção múltipla viaja como lista separada por vírgula em vez de virar `string[]`: mudar o
291
+ * tipo quebraria em compile-time toda implementação de host que já repassa este campo adiante,
292
+ * e o ganho seria nenhum — quem recebe faz `split(',')`.
293
+ */
288
294
  source?: string;
295
+ /** Categoria do arquivo (`document`, `image`, `audio`, `video`…), mesma convenção de lista. */
296
+ fileCategory?: string;
297
+ /** Recorte por data de recebimento, em `YYYY-MM-DD`. */
298
+ startDate?: string;
299
+ endDate?: string;
289
300
  sortDirection?: 'asc' | 'desc';
301
+ /** Coluna ordenada. Ausente, o host ordena pela data — é o padrão de toda listagem de arquivo. */
302
+ sortField?: string;
303
+ /**
304
+ * Filtros que só existem no produto (`clientId`, `unidade`…). O pacote não os interpreta: passa
305
+ * adiante o que o host injetou pelo slot de filtros. É a porta que evita um fork da tela por
306
+ * causa de um `<select>`.
307
+ */
308
+ extra?: Readonly<Record<string, string | number>>;
290
309
  }
291
310
  /** Arquivo na biblioteca da empresa: o mesmo da conversa, mais de qual conversa veio. */
292
311
  interface CompanyDocument extends ConversationDocument {
293
312
  conversationId: string;
313
+ /**
314
+ * Nome de quem enviou, quando o host o conhece. Opcional porque a biblioteca sempre tem o
315
+ * telefone e nem todo produto tem cadastro por trás dele — ausente, a coluna cai para o número.
316
+ */
317
+ contactName?: string | null;
294
318
  }
295
319
  interface CompanyDocumentPage {
296
320
  documents: CompanyDocument[];
@@ -361,6 +385,17 @@ interface ConversationsApi {
361
385
  * componente de biblioteca simplesmente não é usável — melhor que uma tela que sempre erra.
362
386
  */
363
387
  getAllDocuments?(params?: ListDocumentsParams): Promise<CompanyDocumentPage>;
388
+ /**
389
+ * Remove um arquivo da biblioteca. **Opcional por capacidade:** apagar anexo trocado com o
390
+ * cliente é decisão de retenção do produto — instalação que precisa guardar tudo por obrigação
391
+ * legal não implementa, e a tela simplesmente não desenha a lixeira.
392
+ */
393
+ deleteDocument?(uploadId: string): Promise<void>;
394
+ /**
395
+ * Zip de arquivos avulsos da biblioteca, sem conversa de origem única — irmão do
396
+ * `downloadDocumentsArchive`, que é por conversa. Ausente, a seleção em lote não oferece o botão.
397
+ */
398
+ downloadDocumentsArchiveByIds?(uploadIds: readonly string[]): Promise<Blob>;
364
399
  getMediaProxyUrl(mediaId: string): Promise<{
365
400
  mimeType: string;
366
401
  data: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adatechnology/conversations-ui",
3
- "version": "0.1.0-rc.24",
3
+ "version": "0.1.0-rc.26",
4
4
  "description": "WhatsApp conversation UI components — parametrizável por endpoint, tema e feature flags",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -31,7 +31,7 @@
31
31
  "clsx": "^2.1.1",
32
32
  "lucide-react": "^1.21.0",
33
33
  "tailwind-merge": "^3.6.0",
34
- "@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.9"
34
+ "@adatechnology/meta-whatsapp-contracts": "0.2.0-rc.10"
35
35
  },
36
36
  "peerDependencies": {
37
37
  "react": "^18 || ^19",
@@ -0,0 +1,489 @@
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, useState, type ReactNode } from 'react'
13
+ import { Download, Eye, MessageSquare, Trash2, X } from 'lucide-react'
14
+
15
+ import { useConversations } from '../providers/ConversationsProvider'
16
+ import { FileIcon } from '../FileIcon'
17
+ import { cn } from '../lib/cn'
18
+ import { formatDateTime, formatFileSize } from '../lib/format'
19
+ import { formatPhone } from '../lib/phone'
20
+ import {
21
+ BulkActionBar,
22
+ ListingPagination,
23
+ MultiSelectFilter,
24
+ SortableHead,
25
+ type FilterOption,
26
+ type SortDirection,
27
+ } from '../listing'
28
+ import { useDebouncedValue, useUrlArrayState, useUrlNumberState, useUrlStringState } from '../hooks/useUrlFilterState'
29
+ import type { CompanyDocument } from '../providers/types'
30
+ import { DEFAULT_DOCUMENTS_WORKSPACE_LABELS, type DocumentsWorkspaceLabels } from './labels'
31
+
32
+ const DEFAULT_PER_PAGE = 20
33
+ const DEFAULT_PER_PAGE_OPTIONS = [10, 20, 50, 100] as const
34
+ const DEFAULT_SORT_FIELD = 'linkedAt'
35
+
36
+ export interface DocumentsWorkspaceClassNames {
37
+ root: string
38
+ header: string
39
+ filters: string
40
+ table: string
41
+ row: string
42
+ status: string
43
+ }
44
+
45
+ export interface DocumentsWorkspaceProps {
46
+ readonly perPage?: number
47
+ readonly perPageOptions?: readonly number[]
48
+ /** Abrir a conversa de origem. Ausente, o número aparece como texto e não como link. */
49
+ readonly onOpenConversation?: (conversationId: string) => void
50
+ /** Origens filtráveis. Ausente, usa o vocabulário padrão (`customer`, `agent`, `bot`). */
51
+ readonly sources?: readonly FilterOption[]
52
+ /** Categorias de arquivo. Lista vazia esconde o filtro — nem todo host classifica anexo. */
53
+ readonly categories?: readonly FilterOption[]
54
+ /** Recorte por data. Desligado quando o backend não sabe filtrar por período. */
55
+ readonly dateFilter?: boolean
56
+ /**
57
+ * Filtros do produto (cliente, unidade, campanha). Recebe os parâmetros extras atuais e devolve
58
+ * os controles; o que o produto guardar aqui viaja em `extra` para o `getAllDocuments`.
59
+ */
60
+ readonly renderFilters?: (context: DocumentsFiltersContext) => ReactNode
61
+ readonly labels?: Partial<DocumentsWorkspaceLabels>
62
+ readonly className?: string
63
+ readonly classNames?: Partial<DocumentsWorkspaceClassNames>
64
+ /** Espelhar filtros e paginação na URL. Desligado em preview, onde não há rota de verdade. */
65
+ readonly syncUrl?: boolean
66
+ }
67
+
68
+ export interface DocumentsFiltersContext {
69
+ readonly extra: Readonly<Record<string, string | number>>
70
+ readonly setExtra: (next: Readonly<Record<string, string | number>>) => void
71
+ }
72
+
73
+ export function DocumentsWorkspace({
74
+ perPage: initialPerPage = DEFAULT_PER_PAGE,
75
+ perPageOptions = DEFAULT_PER_PAGE_OPTIONS,
76
+ onOpenConversation,
77
+ sources,
78
+ categories,
79
+ dateFilter = true,
80
+ renderFilters,
81
+ labels: labelsOverride,
82
+ className,
83
+ classNames,
84
+ syncUrl = true,
85
+ }: DocumentsWorkspaceProps) {
86
+ const labels = { ...DEFAULT_DOCUMENTS_WORKSPACE_LABELS, ...labelsOverride }
87
+ const context = useConversations()
88
+ const urlOptions = { enabled: syncUrl }
89
+
90
+ const [search, setSearch] = useUrlStringState('search', '', urlOptions)
91
+ const [source, setSource] = useUrlArrayState('source', urlOptions)
92
+ const [category, setCategory] = useUrlArrayState('fileCategory', urlOptions)
93
+ const [startDate, setStartDate] = useUrlStringState('startDate', '', urlOptions)
94
+ const [endDate, setEndDate] = useUrlStringState('endDate', '', urlOptions)
95
+ const [sortField, setSortField] = useUrlStringState('sortField', DEFAULT_SORT_FIELD, urlOptions)
96
+ const [sortDirection, setSortDirection] = useUrlStringState('sortDirection', 'desc', urlOptions)
97
+ const [page, setPage] = useUrlNumberState('page', 1, urlOptions)
98
+ const [perPage, setPerPage] = useUrlNumberState('limit', initialPerPage, urlOptions)
99
+ const [extra, setExtra] = useState<Readonly<Record<string, string | number>>>({})
100
+
101
+ const [documents, setDocuments] = useState<readonly CompanyDocument[]>([])
102
+ const [total, setTotal] = useState(0)
103
+ const [loading, setLoading] = useState(false)
104
+ const [failed, setFailed] = useState(false)
105
+ const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(new Set())
106
+ const [reloadToken, setReloadToken] = useState(0)
107
+ const [busy, setBusy] = useState(false)
108
+
109
+ const debouncedSearch = useDebouncedValue(search)
110
+ const fetchAll = context?.api.getAllDocuments
111
+ const removeDocument = context?.api.deleteDocument
112
+ const downloadArchive = context?.api.downloadDocumentsArchiveByIds
113
+ const extraKey = JSON.stringify(extra)
114
+
115
+ const sourceOptions = useMemo<readonly FilterOption[]>(
116
+ () => sources ?? Object.entries(labels.sourceLabels).map(([value, label]) => ({ value, label })),
117
+ [sources, labels.sourceLabels],
118
+ )
119
+ const categoryOptions = useMemo<readonly FilterOption[]>(
120
+ () => categories ?? Object.entries(labels.categoryLabels).map(([value, label]) => ({ value, label })),
121
+ [categories, labels.categoryLabels],
122
+ )
123
+
124
+ const hasFilters =
125
+ debouncedSearch !== '' ||
126
+ source.length > 0 ||
127
+ category.length > 0 ||
128
+ startDate !== '' ||
129
+ endDate !== '' ||
130
+ Object.keys(extra).length > 0
131
+
132
+ useEffect(() => {
133
+ if (!fetchAll) return
134
+ let active = true
135
+ setLoading(true)
136
+ setFailed(false)
137
+
138
+ void fetchAll({
139
+ search: debouncedSearch || undefined,
140
+ page,
141
+ limit: perPage,
142
+ sortField,
143
+ sortDirection: sortDirection === 'asc' ? 'asc' : 'desc',
144
+ ...(source.length > 0 ? { source: source.join(',') } : {}),
145
+ ...(category.length > 0 ? { fileCategory: category.join(',') } : {}),
146
+ ...(startDate ? { startDate } : {}),
147
+ ...(endDate ? { endDate } : {}),
148
+ ...(Object.keys(extra).length > 0 ? { extra } : {}),
149
+ })
150
+ .then((result) => {
151
+ // `active` evita que uma resposta antiga sobrescreva a nova: digitar rápido na busca dispara
152
+ // várias chamadas e a ordem de retorno não é garantida.
153
+ if (!active) return
154
+ setDocuments(result.documents)
155
+ setTotal(result.total)
156
+ })
157
+ .catch(() => {
158
+ if (active) setFailed(true)
159
+ })
160
+ .finally(() => {
161
+ if (active) setLoading(false)
162
+ })
163
+
164
+ return () => {
165
+ active = false
166
+ }
167
+ // `extraKey` no lugar de `extra`: o objeto é remontado a cada render do host e a identidade
168
+ // sozinha dispararia a busca em loop.
169
+
170
+ }, [fetchAll, debouncedSearch, source, category, startDate, endDate, sortField, sortDirection, page, perPage, extraKey, reloadToken])
171
+
172
+ // Trocar de filtro mantendo a página 7 mostra "nenhum resultado" com dados existindo na página 1.
173
+ useEffect(() => {
174
+ setPage(1)
175
+ setSelectedIds(new Set())
176
+
177
+ }, [debouncedSearch, source, category, startDate, endDate, perPage, extraKey])
178
+
179
+ function toggleSort(field: string): void {
180
+ if (sortField === field) setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
181
+ else {
182
+ setSortField(field)
183
+ setSortDirection('asc')
184
+ }
185
+ }
186
+
187
+ function toggleRow(id: string): void {
188
+ const next = new Set(selectedIds)
189
+ if (next.has(id)) next.delete(id)
190
+ else next.add(id)
191
+ setSelectedIds(next)
192
+ }
193
+
194
+ const isAllSelected = documents.length > 0 && documents.every((document) => selectedIds.has(document.id))
195
+
196
+ function toggleAll(): void {
197
+ setSelectedIds(isAllSelected ? new Set() : new Set(documents.map((document) => document.id)))
198
+ }
199
+
200
+ function clearFilters(): void {
201
+ setSearch('')
202
+ setSource([])
203
+ setCategory([])
204
+ setStartDate('')
205
+ setEndDate('')
206
+ setExtra({})
207
+ }
208
+
209
+ async function handleOpen(uploadId: string, disposition: 'inline' | 'attachment'): Promise<void> {
210
+ const url = await context?.api.getDocumentUrl(uploadId, disposition)
211
+ if (url) window.open(url, '_blank', 'noopener,noreferrer')
212
+ }
213
+
214
+ async function handleRemove(ids: readonly string[]): Promise<void> {
215
+ if (!removeDocument) return
216
+ setBusy(true)
217
+ try {
218
+ await Promise.all(ids.map((id) => removeDocument(id)))
219
+ setSelectedIds(new Set())
220
+ setReloadToken((token) => token + 1)
221
+ } finally {
222
+ setBusy(false)
223
+ }
224
+ }
225
+
226
+ async function handleDownloadArchive(): Promise<void> {
227
+ if (!downloadArchive) return
228
+ setBusy(true)
229
+ try {
230
+ const blob = await downloadArchive(Array.from(selectedIds))
231
+ const url = URL.createObjectURL(blob)
232
+ const anchor = document.createElement('a')
233
+ anchor.href = url
234
+ anchor.download = 'documentos.zip'
235
+ anchor.click()
236
+ URL.revokeObjectURL(url)
237
+ setSelectedIds(new Set())
238
+ } finally {
239
+ setBusy(false)
240
+ }
241
+ }
242
+
243
+ // Host sem `getAllDocuments` não tem o que mostrar aqui — some, em vez de renderizar vazio para
244
+ // sempre e fazer parecer que a empresa não tem arquivo nenhum.
245
+ if (!fetchAll) return null
246
+
247
+ const direction: SortDirection = sortDirection === 'asc' ? 'asc' : 'desc'
248
+ const canSelect = Boolean(removeDocument ?? downloadArchive)
249
+
250
+ return (
251
+ <div className={cn('space-y-4', classNames?.root, className)}>
252
+ <header className={cn('space-y-0.5', classNames?.header)}>
253
+ <h2 className="text-lg font-semibold">{labels.title}</h2>
254
+ <p className="text-sm text-gray-500 dark:text-gray-400">{labels.subtitle(total)}</p>
255
+ </header>
256
+
257
+ <div className={cn('flex flex-wrap items-center gap-2', classNames?.filters)}>
258
+ <input
259
+ type="search"
260
+ value={search}
261
+ onChange={(event) => setSearch(event.target.value)}
262
+ placeholder={labels.searchPlaceholder}
263
+ aria-label={labels.searchPlaceholder}
264
+ className="w-full rounded-md border px-3 py-2 text-sm dark:border-gray-700 dark:bg-gray-900 sm:w-64"
265
+ />
266
+
267
+ <MultiSelectFilter label={labels.sourceFilter} options={sourceOptions} selected={source} onChange={setSource} />
268
+
269
+ {categoryOptions.length > 0 ? (
270
+ <MultiSelectFilter
271
+ label={labels.categoryFilter}
272
+ options={categoryOptions}
273
+ selected={category}
274
+ onChange={setCategory}
275
+ />
276
+ ) : null}
277
+
278
+ {dateFilter ? (
279
+ <>
280
+ <input
281
+ type="date"
282
+ value={startDate}
283
+ onChange={(event) => setStartDate(event.target.value)}
284
+ aria-label={labels.startDate}
285
+ className="rounded-md border px-2 py-2 text-xs dark:border-gray-700 dark:bg-gray-900"
286
+ />
287
+ <input
288
+ type="date"
289
+ value={endDate}
290
+ onChange={(event) => setEndDate(event.target.value)}
291
+ aria-label={labels.endDate}
292
+ className="rounded-md border px-2 py-2 text-xs dark:border-gray-700 dark:bg-gray-900"
293
+ />
294
+ </>
295
+ ) : null}
296
+
297
+ {renderFilters?.({ extra, setExtra })}
298
+
299
+ {hasFilters ? (
300
+ <button type="button" onClick={clearFilters} className="cv-header-action inline-flex items-center gap-1">
301
+ <X size={12} aria-hidden="true" />
302
+ {labels.clearFilters}
303
+ </button>
304
+ ) : null}
305
+ </div>
306
+
307
+ {canSelect ? (
308
+ <BulkActionBar
309
+ selectedCount={selectedIds.size}
310
+ selectedLabel={labels.bulkSelected}
311
+ clearLabel={labels.bulkClear}
312
+ onClear={() => setSelectedIds(new Set())}
313
+ >
314
+ {downloadArchive ? (
315
+ <button
316
+ type="button"
317
+ onClick={() => void handleDownloadArchive()}
318
+ disabled={busy}
319
+ className="cv-header-action inline-flex items-center gap-1 disabled:opacity-40"
320
+ >
321
+ <Download size={12} aria-hidden="true" />
322
+ {labels.bulkDownloadZip}
323
+ </button>
324
+ ) : null}
325
+ {removeDocument ? (
326
+ <button
327
+ type="button"
328
+ onClick={() => {
329
+ if (window.confirm(labels.bulkRemoveConfirm(selectedIds.size))) void handleRemove(Array.from(selectedIds))
330
+ }}
331
+ disabled={busy}
332
+ className="cv-header-action cv-header-action--danger inline-flex items-center gap-1 disabled:opacity-40"
333
+ >
334
+ <Trash2 size={12} aria-hidden="true" />
335
+ {labels.bulkRemove(selectedIds.size)}
336
+ </button>
337
+ ) : null}
338
+ </BulkActionBar>
339
+ ) : null}
340
+
341
+ {loading ? <p className={cn('text-sm text-gray-500', classNames?.status)}>{labels.loading}</p> : null}
342
+ {failed ? (
343
+ <p role="alert" className={cn('text-sm text-red-600 dark:text-red-400', classNames?.status)}>
344
+ {labels.failure}
345
+ </p>
346
+ ) : null}
347
+
348
+ <div className={cn('overflow-x-auto rounded-xl border dark:border-gray-700', classNames?.table)}>
349
+ <table className="w-full text-sm">
350
+ <thead className="border-b text-gray-500 dark:border-gray-700 dark:text-gray-400">
351
+ <tr>
352
+ {canSelect ? (
353
+ <th scope="col" className="w-10 px-3 py-2">
354
+ <input
355
+ type="checkbox"
356
+ checked={isAllSelected}
357
+ onChange={toggleAll}
358
+ aria-label={labels.selectAllPage}
359
+ className="cursor-pointer rounded border-gray-300 text-blue-600 dark:border-gray-600"
360
+ />
361
+ </th>
362
+ ) : null}
363
+ <SortableHead label={labels.columnFilename} field="filename" activeField={sortField} direction={direction} onSort={toggleSort} />
364
+ <SortableHead label={labels.columnContact} field="conversationId" activeField={sortField} direction={direction} onSort={toggleSort} className="hidden sm:table-cell" />
365
+ <SortableHead label={labels.columnType} field="mimeType" activeField={sortField} direction={direction} onSort={toggleSort} className="hidden md:table-cell" />
366
+ <SortableHead label={labels.columnSize} field="sizeBytes" activeField={sortField} direction={direction} onSort={toggleSort} className="hidden lg:table-cell" />
367
+ <SortableHead label={labels.columnSource} field="source" activeField={sortField} direction={direction} onSort={toggleSort} className="hidden lg:table-cell" />
368
+ <SortableHead label={labels.columnDate} field="linkedAt" activeField={sortField} direction={direction} onSort={toggleSort} className="hidden xl:table-cell" />
369
+ <th scope="col" className="px-3 py-2 text-left text-xs font-medium">
370
+ {labels.columnActions}
371
+ </th>
372
+ </tr>
373
+ </thead>
374
+ <tbody>
375
+ {documents.map((document) => (
376
+ <tr key={`${document.conversationId}:${document.id}`} className={cn('border-b last:border-0 dark:border-gray-800', classNames?.row)}>
377
+ {canSelect ? (
378
+ <td className="w-10 px-3 py-2">
379
+ <input
380
+ type="checkbox"
381
+ checked={selectedIds.has(document.id)}
382
+ onChange={() => toggleRow(document.id)}
383
+ aria-label={`${labels.selectRow}: ${document.filename}`}
384
+ className="cursor-pointer rounded border-gray-300 text-blue-600 dark:border-gray-600"
385
+ />
386
+ </td>
387
+ ) : null}
388
+
389
+ <td className="max-w-xs px-3 py-2">
390
+ <div className="flex min-w-0 items-center gap-2">
391
+ <FileIcon filename={document.filename} mimeType={document.mimeType} />
392
+ <span className="truncate font-medium" title={document.filename}>
393
+ {document.filename}
394
+ </span>
395
+ </div>
396
+ </td>
397
+
398
+ {/* A conversa de origem é o dado que só esta tela tem. Vira botão quando o host sabe
399
+ navegar; sem handler, fica texto — link que não leva a lugar nenhum é pior. */}
400
+ <td className="hidden px-3 py-2 sm:table-cell">
401
+ {onOpenConversation ? (
402
+ <button
403
+ type="button"
404
+ onClick={() => onOpenConversation(document.conversationId)}
405
+ title={labels.openConversation}
406
+ className="cv-header-action inline-flex items-center gap-1"
407
+ >
408
+ <MessageSquare size={12} aria-hidden="true" />
409
+ {document.contactName ?? formatPhone(document.conversationId)}
410
+ </button>
411
+ ) : (
412
+ <span className="text-xs text-gray-500">{document.contactName ?? formatPhone(document.conversationId)}</span>
413
+ )}
414
+ </td>
415
+
416
+ <td className="hidden px-3 py-2 font-mono text-xs md:table-cell">{document.mimeType}</td>
417
+ <td className="hidden px-3 py-2 text-xs lg:table-cell">{formatFileSize(document.sizeBytes)}</td>
418
+ <td className="hidden px-3 py-2 text-xs lg:table-cell">
419
+ {labels.sourceLabels[document.source] ?? document.source}
420
+ </td>
421
+ <td className="hidden px-3 py-2 text-xs xl:table-cell">{formatDateTime(document.linkedAt)}</td>
422
+
423
+ <td className="px-3 py-2">
424
+ <div className="flex gap-1">
425
+ <button
426
+ type="button"
427
+ onClick={() => void handleOpen(document.id, 'inline')}
428
+ title={labels.view}
429
+ aria-label={`${labels.view}: ${document.filename}`}
430
+ className="cv-header-icon"
431
+ >
432
+ <Eye size={14} />
433
+ </button>
434
+ <button
435
+ type="button"
436
+ onClick={() => void handleOpen(document.id, 'attachment')}
437
+ title={labels.download}
438
+ aria-label={`${labels.download}: ${document.filename}`}
439
+ className="cv-header-icon"
440
+ >
441
+ <Download size={14} />
442
+ </button>
443
+ {removeDocument ? (
444
+ <button
445
+ type="button"
446
+ onClick={() => {
447
+ if (window.confirm(labels.removeConfirm(document.filename))) void handleRemove([document.id])
448
+ }}
449
+ disabled={busy}
450
+ title={labels.remove}
451
+ aria-label={`${labels.remove}: ${document.filename}`}
452
+ className="cv-header-icon disabled:opacity-40"
453
+ >
454
+ <Trash2 size={14} className="text-red-500" />
455
+ </button>
456
+ ) : null}
457
+ </div>
458
+ </td>
459
+ </tr>
460
+ ))}
461
+ </tbody>
462
+ </table>
463
+
464
+ {!loading && documents.length === 0 ? (
465
+ <p className={cn('py-8 text-center text-sm text-gray-400', classNames?.status)}>
466
+ {hasFilters ? labels.noResults : labels.empty}
467
+ </p>
468
+ ) : null}
469
+ </div>
470
+
471
+ <ListingPagination
472
+ page={page}
473
+ total={total}
474
+ perPage={perPage}
475
+ perPageOptions={perPageOptions}
476
+ onPageChange={setPage}
477
+ onPerPageChange={setPerPage}
478
+ labels={{
479
+ show: labels.show,
480
+ perPage: labels.perPage,
481
+ total: labels.total,
482
+ page: labels.page,
483
+ previous: labels.previousPage,
484
+ next: labels.nextPage,
485
+ }}
486
+ />
487
+ </div>
488
+ )
489
+ }
@@ -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'