@adatechnology/conversations-ui 0.1.0-rc.4 → 0.1.0-rc.5

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.
@@ -8,49 +8,120 @@
8
8
  */
9
9
 
10
10
  import { useState } from 'react'
11
+ import { ArrowUpDown, Bot, Download, Eye, Users } from 'lucide-react'
11
12
  import { useConversationDocuments } from './hooks/useConversationDocuments'
12
13
  import { useConversations } from './providers/ConversationsProvider'
13
14
  import { FileIcon } from './FileIcon'
14
15
  import { cn } from './lib/cn'
15
- import { formatFileSize, formatTimestamp } from './lib/format'
16
+ import { formatDateTime, formatFileSize } from './lib/format'
17
+
18
+ /** Origens que o filtro oferece. `all` não é origem — é a ausência de filtro. */
19
+ export const DOCUMENT_SOURCE_FILTER = {
20
+ ALL: 'all',
21
+ CUSTOMER: 'customer',
22
+ TEAM: 'team',
23
+ } as const
24
+ export type DocumentSourceFilter = (typeof DOCUMENT_SOURCE_FILTER)[keyof typeof DOCUMENT_SOURCE_FILTER]
25
+
26
+ /**
27
+ * `bot` e `agent` caem em "Equipe": para quem procura um comprovante, o que importa é se veio do
28
+ * cliente ou saiu da loja — não se foi o robô ou a pessoa que apertou enviar.
29
+ */
30
+ const TEAM_SOURCES = new Set(['agent', 'bot'])
16
31
 
17
32
  export interface ConversationDocumentsPanelLabels {
18
33
  toggle: string
19
34
  title: string
20
35
  searchPlaceholder: string
21
36
  empty: string
37
+ /** Distinto de `empty`: sem resultado POR CAUSA do filtro, e não conversa sem anexo nenhum. */
38
+ noResults: string
22
39
  loading: string
23
40
  failure: string
24
41
  download: string
42
+ view: string
43
+ sourceFilterAll: string
44
+ sourceFilterCustomer: string
45
+ sourceFilterTeam: string
46
+ sortMostRecent: string
47
+ sortOldest: string
48
+ clearFilters: string
49
+ selectAll: string
50
+ downloadSelected: (count: number) => string
51
+ archiveFailed: string
52
+ total: (count: number) => string
53
+ page: (current: number, last: number) => string
25
54
  }
26
55
 
27
56
  export const DEFAULT_CONVERSATION_DOCUMENTS_LABELS: ConversationDocumentsPanelLabels = {
28
57
  toggle: '📎 Arquivos',
29
58
  title: 'Arquivos da conversa',
30
- searchPlaceholder: 'Buscar arquivo...',
59
+ noResults: 'Nenhum arquivo encontrado para os filtros aplicados',
60
+ view: 'Visualizar',
61
+ sourceFilterAll: 'Todas as origens',
62
+ sourceFilterCustomer: 'Cliente',
63
+ sourceFilterTeam: 'Equipe',
64
+ sortMostRecent: 'Mais recentes',
65
+ sortOldest: 'Mais antigos',
66
+ clearFilters: 'Limpar filtros',
67
+ selectAll: 'Selecionar todos desta página',
68
+ downloadSelected: (count: number) => `Baixar ${count} selecionado${count === 1 ? '' : 's'} (.zip)`,
69
+ archiveFailed: 'Não foi possível montar o arquivo compactado.',
70
+ total: (count: number) => `${count} arquivo${count === 1 ? '' : 's'}`,
71
+ page: (current: number, last: number) => `${current} / ${last}`,
72
+ searchPlaceholder: 'Buscar por nome do arquivo',
31
73
  empty: 'Nenhum arquivo nesta conversa.',
32
74
  loading: 'Carregando arquivos…',
33
75
  failure: 'Não foi possível carregar os arquivos.',
34
76
  download: 'Baixar',
35
77
  }
36
78
 
79
+ /**
80
+ * Partes estilizáveis do painel, no mesmo contrato do `ConversationHeader`: `cn` funde por cima da
81
+ * base e conflito de utilitário (padding, tamanho de fonte, borda) fica com o valor do produto.
82
+ *
83
+ * `status` cobre carregando/erro/vazio de uma vez — são a mesma linha de texto auxiliar, e slots
84
+ * separados só multiplicariam chave para quem quer mudar a cor de aviso.
85
+ */
37
86
  export interface ConversationDocumentsPanelClassNames {
38
87
  root: string
39
88
  body: string
89
+ title: string
90
+ filters: string
91
+ search: string
92
+ sourceSelect: string
93
+ sortButton: string
94
+ clearButton: string
95
+ status: string
96
+ list: string
97
+ item: string
98
+ sourceBadge: string
99
+ filename: string
100
+ meta: string
101
+ viewButton: string
102
+ downloadButton: string
103
+ pagination: string
104
+ selectionBar: string
105
+ checkbox: string
40
106
  }
41
107
 
42
108
  export interface ConversationDocumentsPanelProps {
43
109
  conversationId: string
44
110
  /** Controlado de fora porque o gatilho vive no cabeçalho, junto das outras ações da conversa. */
45
111
  open: boolean
112
+ /** Itens por página. O total vem do servidor; sem paginação no host, a barra não aparece. */
113
+ perPage?: number
46
114
  labels?: Partial<ConversationDocumentsPanelLabels>
47
115
  className?: string
48
116
  classNames?: Partial<ConversationDocumentsPanelClassNames>
49
117
  }
50
118
 
119
+ const DEFAULT_PER_PAGE = 10
120
+
51
121
  export function ConversationDocumentsPanel({
52
122
  conversationId,
53
123
  open,
124
+ perPage = DEFAULT_PER_PAGE,
54
125
  labels: labelsOverride,
55
126
  className,
56
127
  classNames,
@@ -58,49 +129,296 @@ export function ConversationDocumentsPanel({
58
129
  const labels = { ...DEFAULT_CONVERSATION_DOCUMENTS_LABELS, ...labelsOverride }
59
130
  const context = useConversations()
60
131
  const [search, setSearch] = useState('')
132
+ const [sourceFilter, setSourceFilter] = useState<DocumentSourceFilter>(DOCUMENT_SOURCE_FILTER.ALL)
133
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
134
+ const [page, setPage] = useState(1)
135
+ const [selectedIds, setSelectedIds] = useState<readonly string[]>([])
136
+ const [archiveError, setArchiveError] = useState(false)
137
+
138
+ const hasFilters = search !== '' || sourceFilter !== DOCUMENT_SOURCE_FILTER.ALL || sortDirection !== 'desc'
61
139
 
62
140
  // Só busca quando o painel abre: a lista de anexos é consulta extra e não deve pesar em toda
63
141
  // conversa aberta.
64
- const { documents, loading, error } = useConversationDocuments(open ? conversationId : undefined, { search })
142
+ const { documents, total, loading, error } = useConversationDocuments(open ? conversationId : undefined, {
143
+ search,
144
+ page,
145
+ limit: perPage,
146
+ sortDirection,
147
+ ...(sourceFilter === DOCUMENT_SOURCE_FILTER.ALL ? {} : { source: sourceFilter }),
148
+ })
65
149
 
66
- async function handleDownload(uploadId: string): Promise<void> {
67
- const url = await context?.api.getDocumentUrl(uploadId)
150
+ const lastPage = Math.max(1, Math.ceil(total / perPage))
151
+
152
+ /**
153
+ * Toda mudança de filtro volta para a página 1. Sem isso, filtrar na página 3 pede ao servidor
154
+ * uma fatia que o novo resultado talvez não tenha, e o painel aparece vazio como se não houvesse
155
+ * arquivo — o mesmo motivo do `setDocumentsPage(1)` em cada handler do financiamento.
156
+ */
157
+ function applyFilter(change: () => void): void {
158
+ change()
159
+ setPage(1)
160
+ }
161
+
162
+ async function handleOpen(uploadId: string, disposition: 'inline' | 'attachment'): Promise<void> {
163
+ const url = await context?.api.getDocumentUrl(uploadId, disposition)
68
164
  if (url) window.open(url, '_blank', 'noopener,noreferrer')
69
165
  }
70
166
 
167
+ // Ausente = host não sabe montar zip (só assina URL). Esconder é melhor que oferecer e falhar.
168
+ const canArchive = typeof context?.api.downloadDocumentsArchive === 'function'
169
+ const pageIds = documents.map((document) => document.id)
170
+ const allOnPageSelected = pageIds.length > 0 && pageIds.every((id) => selectedIds.includes(id))
171
+
172
+ function toggleSelected(uploadId: string): void {
173
+ setArchiveError(false)
174
+ setSelectedIds((current) =>
175
+ current.includes(uploadId) ? current.filter((id) => id !== uploadId) : [...current, uploadId],
176
+ )
177
+ }
178
+
179
+ function toggleAllOnPage(): void {
180
+ setArchiveError(false)
181
+ // Só mexe nos itens DESTA página: quem selecionou algo na página 1, foi para a 2 e marcou
182
+ // "todos" não deve perder a seleção anterior.
183
+ setSelectedIds((current) =>
184
+ allOnPageSelected
185
+ ? current.filter((id) => !pageIds.includes(id))
186
+ : [...current, ...pageIds.filter((id) => !current.includes(id))],
187
+ )
188
+ }
189
+
190
+ async function handleDownloadSelected(): Promise<void> {
191
+ const archive = context?.api.downloadDocumentsArchive
192
+ if (!archive || selectedIds.length === 0) return
193
+
194
+ setArchiveError(false)
195
+ try {
196
+ const blob = await archive(conversationId, selectedIds)
197
+ // Âncora temporária com objectURL: é o que faz o navegador salvar um Blob com nome. O
198
+ // revoke é obrigatório — sem ele o blob fica na memória da aba até recarregar.
199
+ const url = URL.createObjectURL(blob)
200
+ const anchor = document.createElement('a')
201
+ anchor.href = url
202
+ anchor.download = `conversa-${conversationId}.zip`
203
+ anchor.click()
204
+ URL.revokeObjectURL(url)
205
+ setSelectedIds([])
206
+ } catch {
207
+ setArchiveError(true)
208
+ }
209
+ }
210
+
71
211
  if (!open) return null
72
212
 
73
213
  return (
74
214
  <div className={cn('border-b', classNames?.root, className)}>
75
215
  <section className={cn('px-4 py-3', classNames?.body)}>
76
- <p className="mb-2 text-sm font-medium">{labels.title}</p>
216
+ <p className={cn('mb-2 text-sm font-medium', classNames?.title)}>{labels.title}</p>
217
+
218
+ {/* Barra de filtros com wrap: em tela estreita os quatro controles empilham em vez de
219
+ estourar a largura do painel. */}
220
+ <div className={cn('mb-2 flex flex-wrap items-center gap-2 border-b pb-2', classNames?.filters)}>
77
221
  <input
78
222
  type="search"
79
223
  value={search}
80
- onChange={(event) => setSearch(event.target.value)}
224
+ onChange={(event) => applyFilter(() => setSearch(event.target.value))}
81
225
  placeholder={labels.searchPlaceholder}
82
- className="mb-2 w-full rounded-md border px-3 py-2 text-sm"
226
+ aria-label={labels.searchPlaceholder}
227
+ className={cn('w-full rounded-md border px-3 py-2 text-sm sm:w-52', classNames?.search)}
83
228
  />
84
229
 
85
- {loading ? <p className="text-xs text-gray-500">{labels.loading}</p> : null}
86
- {error ? <p className="text-xs text-red-600 dark:text-red-400">{labels.failure}</p> : null}
87
- {!loading && !error && documents.length === 0 ? (
88
- <p className="text-xs text-gray-500">{labels.empty}</p>
230
+ <select
231
+ value={sourceFilter}
232
+ onChange={(event) => applyFilter(() => setSourceFilter(event.target.value as DocumentSourceFilter))}
233
+ aria-label={labels.sourceFilterAll}
234
+ className={cn('w-full rounded-md border px-2 py-2 text-sm sm:w-36', classNames?.sourceSelect)}
235
+ >
236
+ <option value={DOCUMENT_SOURCE_FILTER.ALL}>{labels.sourceFilterAll}</option>
237
+ <option value={DOCUMENT_SOURCE_FILTER.CUSTOMER}>{labels.sourceFilterCustomer}</option>
238
+ <option value={DOCUMENT_SOURCE_FILTER.TEAM}>{labels.sourceFilterTeam}</option>
239
+ </select>
240
+
241
+ <button
242
+ type="button"
243
+ onClick={() => applyFilter(() => setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc'))}
244
+ className={cn('cv-header-action inline-flex items-center gap-1', classNames?.sortButton)}
245
+ >
246
+ <ArrowUpDown size={14} />
247
+ {sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest}
248
+ </button>
249
+
250
+ {/* Só aparece com filtro ativo: botão que não faz nada visível é ruído. */}
251
+ {hasFilters ? (
252
+ <button
253
+ type="button"
254
+ onClick={() =>
255
+ applyFilter(() => {
256
+ setSearch('')
257
+ setSourceFilter(DOCUMENT_SOURCE_FILTER.ALL)
258
+ setSortDirection('desc')
259
+ })
260
+ }
261
+ className={cn('cv-header-action', classNames?.clearButton)}
262
+ >
263
+ {labels.clearFilters}
264
+ </button>
89
265
  ) : null}
266
+ </div>
267
+
268
+ {loading ? <p className={cn('text-xs text-gray-500', classNames?.status)}>{labels.loading}</p> : null}
269
+ {error ? (
270
+ <p role="alert" className={cn('text-xs text-red-600 dark:text-red-400', classNames?.status)}>
271
+ {labels.failure}
272
+ </p>
273
+ ) : null}
274
+ {!loading && !error && documents.length === 0 ? (
275
+ <p className={cn('text-xs text-gray-500', classNames?.status)}>
276
+ {/* Distinguir os dois evita o mal-entendido de "a conversa não tem anexo" quando o
277
+ filtro é que não casou. */}
278
+ {hasFilters ? labels.noResults : labels.empty}
279
+ </p>
280
+ ) : null}
281
+
282
+ {canArchive && documents.length > 0 ? (
283
+ <div className={cn('mb-2 flex flex-wrap items-center gap-3 text-xs', classNames?.selectionBar)}>
284
+ <label className="inline-flex items-center gap-1.5">
285
+ <input
286
+ type="checkbox"
287
+ checked={allOnPageSelected}
288
+ onChange={toggleAllOnPage}
289
+ className={cn(classNames?.checkbox)}
290
+ />
291
+ {labels.selectAll}
292
+ </label>
293
+ {selectedIds.length > 0 ? (
294
+ <button type="button" onClick={() => void handleDownloadSelected()} className="cv-header-action">
295
+ {labels.downloadSelected(selectedIds.length)}
296
+ </button>
297
+ ) : null}
298
+ {archiveError ? (
299
+ <span role="alert" className="text-red-600 dark:text-red-400">
300
+ {labels.archiveFailed}
301
+ </span>
302
+ ) : null}
303
+ </div>
304
+ ) : null}
90
305
 
91
- <ul className="space-y-1">
92
- {documents.map((document) => (
93
- <li key={document.id} className="flex items-center gap-2 text-xs">
94
- <FileIcon mimeType={document.mimeType} />
95
- <span className="min-w-0 flex-1 truncate">{document.filename}</span>
96
- <span className="text-gray-500">{formatFileSize(document.sizeBytes)}</span>
97
- <span className="text-gray-500">{formatTimestamp(document.linkedAt)}</span>
98
- <button type="button" onClick={() => void handleDownload(document.id)} className="cv-header-action">
99
- {labels.download}
100
- </button>
306
+ <ul className={cn('space-y-2', classNames?.list)}>
307
+ {documents.map((document) => {
308
+ const isFromCustomer = !TEAM_SOURCES.has(document.source)
309
+ const SourceIcon = isFromCustomer ? Users : Bot
310
+
311
+ return (
312
+ <li
313
+ key={document.id}
314
+ className={cn(
315
+ 'flex items-center justify-between gap-2 rounded-lg border px-3 py-2 dark:border-gray-700',
316
+ classNames?.item,
317
+ )}
318
+ >
319
+ <div className="flex min-w-0 flex-1 items-center gap-2">
320
+ {canArchive ? (
321
+ <input
322
+ type="checkbox"
323
+ checked={selectedIds.includes(document.id)}
324
+ onChange={() => toggleSelected(document.id)}
325
+ aria-label={`${labels.download}: ${document.filename}`}
326
+ className={cn('shrink-0', classNames?.checkbox)}
327
+ />
328
+ ) : null}
329
+ {/* `filename` junto do mimeType, e não só o mimeType: o mapa de ícones é indexado
330
+ por extensão curta, e o mimeType do Office é longo
331
+ (`…wordprocessingml.document`), então docx/doc/xlsx/xls caíam todos no ícone
332
+ genérico cinza. Com o nome, a extensão resolve primeiro. */}
333
+ <FileIcon filename={document.filename} mimeType={document.mimeType} />
334
+ <div className="min-w-0 flex-1">
335
+ <div
336
+ className={cn(
337
+ 'mb-0.5 flex items-center gap-1 text-[11px] font-medium',
338
+ isFromCustomer
339
+ ? 'text-blue-600 dark:text-blue-400'
340
+ : 'text-emerald-600 dark:text-emerald-400',
341
+ classNames?.sourceBadge,
342
+ )}
343
+ >
344
+ <SourceIcon size={11} />
345
+ {isFromCustomer ? labels.sourceFilterCustomer : labels.sourceFilterTeam}
346
+ </div>
347
+ {/* `title` no nome: truncado, o atendente não tem outro jeito de ler inteiro. */}
348
+ <div
349
+ className={cn('truncate text-sm font-medium', classNames?.filename)}
350
+ title={document.filename}
351
+ >
352
+ {document.filename}
353
+ </div>
354
+ <div className={cn('text-xs text-gray-500 dark:text-gray-400', classNames?.meta)}>
355
+ {formatDateTime(document.linkedAt)}
356
+ {' · '}
357
+ {formatFileSize(document.sizeBytes)}
358
+ </div>
359
+ </div>
360
+ </div>
361
+
362
+ {/* Ver e baixar são ações distintas, não a mesma com nome diferente: o
363
+ `disposition` viaja na assinatura da URL, e depois de assinada não há como o
364
+ cliente mudar entre abrir no navegador e salvar. */}
365
+ <div className="flex shrink-0 gap-1">
366
+ <button
367
+ type="button"
368
+ onClick={() => void handleOpen(document.id, 'inline')}
369
+ title={labels.view}
370
+ aria-label={`${labels.view}: ${document.filename}`}
371
+ className={cn('cv-header-icon', classNames?.viewButton)}
372
+ >
373
+ <Eye size={14} />
374
+ </button>
375
+ <button
376
+ type="button"
377
+ onClick={() => void handleOpen(document.id, 'attachment')}
378
+ title={labels.download}
379
+ aria-label={`${labels.download}: ${document.filename}`}
380
+ className={cn('cv-header-icon', classNames?.downloadButton)}
381
+ >
382
+ <Download size={14} />
383
+ </button>
384
+ </div>
101
385
  </li>
102
- ))}
103
- </ul>
386
+ )
387
+ })}
388
+ </ul>
389
+
390
+ {/* Só com mais de uma página: barra de paginação numa lista de três anexos é ruído. */}
391
+ {total > perPage ? (
392
+ <div
393
+ className={cn(
394
+ 'mt-2 flex items-center justify-between border-t pt-2 text-xs dark:border-gray-700',
395
+ classNames?.pagination,
396
+ )}
397
+ >
398
+ <span className="text-gray-400">{labels.total(total)}</span>
399
+ <div className="flex items-center gap-2">
400
+ <button
401
+ type="button"
402
+ onClick={() => setPage(page - 1)}
403
+ disabled={page <= 1}
404
+ aria-label={labels.sortOldest}
405
+ className="cv-header-icon disabled:opacity-40"
406
+ >
407
+
408
+ </button>
409
+ <span className="text-gray-500">{labels.page(page, lastPage)}</span>
410
+ <button
411
+ type="button"
412
+ onClick={() => setPage(page + 1)}
413
+ disabled={page >= lastPage}
414
+ aria-label={labels.sortMostRecent}
415
+ className="cv-header-icon disabled:opacity-40"
416
+ >
417
+
418
+ </button>
419
+ </div>
420
+ </div>
421
+ ) : null}
104
422
  </section>
105
423
  </div>
106
424
  )
@@ -0,0 +1,38 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+
3
+ import { resolveFileIconExtension } from './FileIcon'
4
+
5
+ describe('resolveFileIconExtension', () => {
6
+ // Regressão: o painel de documentos passava só o mimeType, e o do Office é longo
7
+ // (`application/vnd.openxmlformats-officedocument.wordprocessingml.document`), então docx, doc,
8
+ // xlsx e xls apareciam todos com o ícone genérico cinza.
9
+ const OFFICE_MIME_TYPES = [
10
+ {
11
+ filename: 'contrato.docx',
12
+ mimeType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
13
+ expected: 'docx',
14
+ },
15
+ { filename: 'procuracao.doc', mimeType: 'application/msword', expected: 'doc' },
16
+ {
17
+ filename: 'pedido.xlsx',
18
+ mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
19
+ expected: 'xlsx',
20
+ },
21
+ { filename: 'tabela.xls', mimeType: 'application/vnd.ms-excel', expected: 'xls' },
22
+ ]
23
+
24
+ for (const testCase of OFFICE_MIME_TYPES) {
25
+ it(`resolve ${testCase.expected} pelo nome, já que o mimeType do Office não bate`, () => {
26
+ expect(resolveFileIconExtension(testCase.filename, testCase.mimeType)).toBe(testCase.expected)
27
+ })
28
+ }
29
+
30
+ it('resolve pelo mimeType quando o nome não tem extensão conhecida', () => {
31
+ expect(resolveFileIconExtension(undefined, 'application/pdf')).toBe('pdf')
32
+ expect(resolveFileIconExtension('recibo', 'application/zip')).toBe('zip')
33
+ })
34
+
35
+ it('devolve algo fora do mapa para tipo desconhecido, caindo no ícone genérico', () => {
36
+ expect(resolveFileIconExtension('lista-compras.txt', 'text/plain')).toBe('plain')
37
+ })
38
+ })
package/src/FileIcon.tsx CHANGED
@@ -1,5 +1,7 @@
1
1
  import { FileArchive, FileSpreadsheet, FileText, File as FileGeneric } from 'lucide-react'
2
2
 
3
+ import { cn } from './lib/cn'
4
+
3
5
  export interface FileIconProps {
4
6
  filename?: string
5
7
  mimeType?: string
@@ -16,7 +18,14 @@ const EXTENSION_STYLE: Record<string, { Icon: typeof FileText; colorClass: strin
16
18
  zip: { Icon: FileArchive, colorClass: 'text-orange-500' },
17
19
  }
18
20
 
19
- function getExtension(filename?: string, mimeType?: string): string {
21
+ /**
22
+ * O nome do arquivo tem precedência sobre o mimeType porque o mapa é indexado por extensão curta:
23
+ * o mimeType do Office é longo (`…wordprocessingml.document`) e nunca casaria, então quem passa só
24
+ * mimeType perde o ícone de Word e de Excel.
25
+ *
26
+ * Exportada para teste: é a regra que já regrediu uma vez no painel de documentos.
27
+ */
28
+ export function resolveFileIconExtension(filename?: string, mimeType?: string): string {
20
29
  const fromFilename = filename?.split('.').pop()?.toLowerCase()
21
30
  if (fromFilename && EXTENSION_STYLE[fromFilename]) return fromFilename
22
31
  return mimeType?.split('/')[1]?.toLowerCase() ?? ''
@@ -24,10 +33,12 @@ function getExtension(filename?: string, mimeType?: string): string {
24
33
 
25
34
  // Melhoria sobre a paridade da bolha de documento (T6.7 usava um único ícone genérico
26
35
  // para qualquer tipo de arquivo) — ícone e cor variam por extensão/mimeType.
27
- export function FileIcon({ filename, mimeType, size = 20, className = '' }: FileIconProps) {
28
- const extension = getExtension(filename, mimeType)
36
+ export function FileIcon({ filename, mimeType, size = 20, className }: FileIconProps) {
37
+ const extension = resolveFileIconExtension(filename, mimeType)
29
38
  const style = EXTENSION_STYLE[extension] ?? { Icon: FileGeneric, colorClass: 'text-gray-500' }
30
39
  const { Icon, colorClass } = style
31
40
 
32
- return <Icon size={size} className={`${colorClass} ${className}`.trim()} />
41
+ // `cn` em vez de concatenar: a cor por extensão é a base, e produto que passa `text-*` precisa
42
+ // ganhar dela — concatenado, quem vence é a ordem no CSS gerado, não a intenção de quem chamou.
43
+ return <Icon size={size} className={cn(colorClass, className)} />
33
44
  }
@@ -3,6 +3,7 @@ import { AudioPlayer } from './AudioPlayer'
3
3
  import { FileIcon } from './FileIcon'
4
4
  import { useConversationLocales } from './ConversationLocalesProvider'
5
5
  import { formatFileSize } from './lib/format'
6
+ import { cn } from './lib/cn'
6
7
  import type { MessagePayload } from './types'
7
8
 
8
9
  function resolveMediaSource(message: MessagePayload): string | null {
@@ -30,6 +31,8 @@ export interface MediaRendererProps {
30
31
  // loadUrl/loadMedia de financiamento-imobiliario-bot/apps/web/src/components/MessageBubble.tsx,
31
32
  // porém delegando o fetch ao host em vez de hardcodar `/uploads/:id/download-url`.
32
33
  onResolveUrl?: ResolveMediaUrl
34
+ /** Aplicado no wrapper de cada tipo de mídia — imagem, vídeo, áudio e documento. */
35
+ className?: string
33
36
  }
34
37
 
35
38
  function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl) {
@@ -55,7 +58,7 @@ function useLazyMediaUrl(message: MessagePayload, onResolveUrl?: ResolveMediaUrl
55
58
  return { url, loading, error, load }
56
59
  }
57
60
 
58
- export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRendererProps) {
61
+ export function MediaRenderer({ message, onLightbox, onResolveUrl, className }: MediaRendererProps) {
59
62
  const { bubble } = useConversationLocales()
60
63
  const eagerSrc = resolveMediaSource(message)
61
64
  const lazy = useLazyMediaUrl(message, onResolveUrl)
@@ -126,7 +129,7 @@ export function MediaRenderer({ message, onLightbox, onResolveUrl }: MediaRender
126
129
  const typeLabel = message.mimeType?.split('/')[1]?.toUpperCase() ?? 'FILE'
127
130
  const sizeLabel = message.sizeBytes ? formatFileSize(message.sizeBytes) : null
128
131
  return (
129
- <div className="flex items-center gap-3 min-w-[200px]">
132
+ <div className={cn('flex items-center gap-3 min-w-[200px]', className)}>
130
133
  <div className="w-10 h-10 bg-gray-200 rounded-lg flex items-center justify-center flex-shrink-0">
131
134
  <FileIcon filename={message.filename} mimeType={message.mimeType} />
132
135
  </div>
@@ -0,0 +1,56 @@
1
+ import { useMemo } from 'react'
2
+ import { useConversations } from '../providers/ConversationsProvider'
3
+ import type { ConversationTemplate } from '../providers/types'
4
+
5
+ export interface UseConversationActionsResult {
6
+ /** `undefined` quando a API do host não implementa a operação — a UI esconde a afordância. */
7
+ takeover: (() => Promise<void>) | undefined
8
+ release: (() => Promise<void>) | undefined
9
+ finalize: (() => Promise<void>) | undefined
10
+ }
11
+
12
+ /**
13
+ * Ações de atendimento de UMA conversa, já ligadas ao id.
14
+ *
15
+ * Separado de `useConversationMessages` porque assumir e devolver conversa também acontece a
16
+ * partir da lista, onde nenhuma thread está aberta — embutir nas mensagens obrigaria a carregar
17
+ * a thread inteira só para desenhar um botão na linha.
18
+ */
19
+ export function useConversationActions(conversationId: string): UseConversationActionsResult {
20
+ const context = useConversations()
21
+ if (!context) {
22
+ throw new Error('useConversationActions requires an ancestor <ConversationsProvider>')
23
+ }
24
+ const { api } = context
25
+
26
+ return useMemo(
27
+ () => ({
28
+ takeover: api.takeover ? () => api.takeover!(conversationId) : undefined,
29
+ release: api.release ? () => api.release!(conversationId) : undefined,
30
+ finalize: api.finalize ? () => api.finalize!(conversationId) : undefined,
31
+ }),
32
+ [api, conversationId],
33
+ )
34
+ }
35
+
36
+ export interface UseInboxActionsResult {
37
+ markAllRead: (() => Promise<void>) | undefined
38
+ listTemplates: (() => Promise<ConversationTemplate[]>) | undefined
39
+ }
40
+
41
+ /** Ações que valem para a caixa inteira, sem conversa selecionada. */
42
+ export function useInboxActions(): UseInboxActionsResult {
43
+ const context = useConversations()
44
+ if (!context) {
45
+ throw new Error('useInboxActions requires an ancestor <ConversationsProvider>')
46
+ }
47
+ const { api } = context
48
+
49
+ return useMemo(
50
+ () => ({
51
+ markAllRead: api.markAllRead ? () => api.markAllRead!() : undefined,
52
+ listTemplates: api.listTemplates ? () => api.listTemplates!() : undefined,
53
+ }),
54
+ [api],
55
+ )
56
+ }
@@ -1,14 +1,14 @@
1
1
  import { useConversations } from '../providers/ConversationsProvider'
2
2
  import { useAsyncResource } from './useAsyncResource'
3
- import type { ConversationDocument } from '../providers/types'
3
+ import { documentsOf, totalOf } from '../lib/paginated'
4
+ import type { ConversationDocument, ListDocumentsParams } from '../providers/types'
4
5
 
5
- export interface UseConversationDocumentsParams {
6
- search?: string
7
- page?: number
8
- }
6
+ export type UseConversationDocumentsParams = ListDocumentsParams
9
7
 
10
8
  export interface UseConversationDocumentsResult {
11
9
  documents: ConversationDocument[]
10
+ /** Total no servidor. Cai para o tamanho da página quando a API devolve só o array. */
11
+ total: number
12
12
  loading: boolean
13
13
  error: Error | undefined
14
14
  refetch: () => Promise<void>
@@ -28,8 +28,12 @@ export function useConversationDocuments(
28
28
 
29
29
  const { data, loading, error, refetch } = useAsyncResource(
30
30
  () => (conversationId ? api.getDocuments(conversationId, params) : Promise.resolve([])),
31
- [conversationId, params?.search, params?.page],
31
+ [conversationId, params?.search, params?.page, params?.limit, params?.source, params?.sortDirection],
32
32
  )
33
33
 
34
- return { documents: data ?? [], loading, error, refetch }
34
+ if (data === undefined) {
35
+ return { documents: [], total: 0, loading, error, refetch }
36
+ }
37
+
38
+ return { documents: documentsOf(data), total: totalOf(data), loading, error, refetch }
35
39
  }