@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.
@@ -9,6 +9,7 @@ export { FlowPortalNode, flowPortalNodeTypes } from './FlowPortalNode'
9
9
  export { FlowPalette } from './FlowPalette'
10
10
  export { FlowNodePanel } from './FlowNodePanel'
11
11
  export { FlowWhatsAppPreview } from './FlowWhatsAppPreview'
12
+ export { FlowsWorkspace } from './FlowsWorkspace'
12
13
 
13
14
  export { DEFAULT_FLOW_EDITOR_LABELS, mergeFlowEditorLabels } from './labels'
14
15
 
@@ -43,7 +44,8 @@ export type {
43
44
  CollectionChain,
44
45
  } from './flowGraph'
45
46
 
46
- export type { FlowEditorLabels } from './labels'
47
+ export type { FlowEditorLabels, FlowValidationLabels } from './labels'
48
+ export type { FlowsWorkspaceProps, FlowsWorkspaceApi, FlowLivePosition, CreateFlowInput } from './FlowsWorkspace'
47
49
  export type { FlowNodeCardData } from './FlowNodeCard'
48
50
  export type { FlowMapNodeData } from './FlowMapNode'
49
51
  export type { FlowMapCanvasProps } from './FlowMapCanvas'
@@ -3,6 +3,7 @@ import type { FlowConditionOperator, FlowNodeType, FlowQuestionType } from './fl
3
3
  export interface FlowEditorLabels {
4
4
  legend: Record<FlowNodeType, string>
5
5
  startNodeTooltip: string
6
+ detachedNodeTooltip: string
6
7
  liveCountTooltip: (count: number) => string
7
8
  edgeFallbackLabel: string
8
9
  // Rótulo por `actionKind` — o host estende esse mapa para registrar seus próprios kinds
@@ -13,6 +14,9 @@ export interface FlowEditorLabels {
13
14
  nodePanel: {
14
15
  title: string
15
16
  contextKey: string
17
+ nodeName: string
18
+ nodeNamePlaceholder: string
19
+ nodeNameHint: string
16
20
  questionType: string
17
21
  question: string
18
22
  options: string
@@ -61,6 +65,8 @@ export interface FlowEditorLabels {
61
65
  flowMap: {
62
66
  nodeCount: (count: number) => string
63
67
  openFlow: string
68
+ toggleToMap: string
69
+ toggleToDetail: string
64
70
  }
65
71
  flowGroup: {
66
72
  focus: string
@@ -70,6 +76,67 @@ export interface FlowEditorLabels {
70
76
  tooltip: string
71
77
  goesTo: (label: string) => string
72
78
  }
79
+ collectionChain: {
80
+ feeds: (label: string) => string
81
+ }
82
+ /**
83
+ * Texto dos problemas encontrados por `validateGraph`. Vive aqui, e não no host, porque a tela
84
+ * composta é quem valida — deixar de fora obrigaria todo produto a repassar o mesmo mapa de
85
+ * funções só para a barra de erros aparecer.
86
+ */
87
+ validation: FlowValidationLabels
88
+ /** Barra de cima, estados de carregamento e ações do editor inteiro. */
89
+ workspace: {
90
+ title: string
91
+ subtitle: string
92
+ loading: string
93
+ loadError: string
94
+ saveGraph: string
95
+ saving: string
96
+ saveSuccess: string
97
+ saveError: string
98
+ organize: string
99
+ organizeTooltip: string
100
+ discardChanges: string
101
+ discardTooltip: string
102
+ discardConfirm: string
103
+ unsavedChangesConfirm: string
104
+ }
105
+ flowManager: {
106
+ newFlow: string
107
+ createTitle: string
108
+ key: string
109
+ keyHint: string
110
+ keyInvalid: string
111
+ label: string
112
+ showInMenu: string
113
+ menuOptionLabel: string
114
+ create: string
115
+ creating: string
116
+ deleteFlow: string
117
+ deleteConfirm: (label: string) => string
118
+ createError: string
119
+ deleteError: string
120
+ }
121
+ }
122
+
123
+ export interface FlowValidationLabels {
124
+ title: string
125
+ errors: (count: number) => string
126
+ warnings: (count: number) => string
127
+ noStart: string
128
+ brokenRef: (from: string, to: string) => string
129
+ choiceWithoutOptions: (id: string) => string
130
+ duplicatedOptionId: (id: string, optionId: string) => string
131
+ optionWithoutTarget: (id: string, optionLabel: string) => string
132
+ tooManyOptions: (id: string, count: number) => string
133
+ buttonTitleTooLong: (id: string, label: string) => string
134
+ listTitleTooLong: (id: string, label: string) => string
135
+ bodyTooLong: (id: string) => string
136
+ unreachable: (id: string) => string
137
+ deadEndQuestion: (id: string) => string
138
+ conditionIncomplete: (id: string) => string
139
+ conditionBranchMissing: (id: string, branch: string) => string
73
140
  }
74
141
 
75
142
  // Paridade de texto com financiamento-imobiliario-bot/apps/web/src/locales/modules/flows.ts —
@@ -83,6 +150,7 @@ export const DEFAULT_FLOW_EDITOR_LABELS: FlowEditorLabels = {
83
150
  condition: 'Condição',
84
151
  },
85
152
  startNodeTooltip: 'Início do fluxo',
153
+ detachedNodeTooltip: 'Sem ligação de entrada — o bot não chega neste nó. Puxe um fio de outro card até ele.',
86
154
  liveCountTooltip: (count) => `${count} conversa(s) ativa(s) aqui agora`,
87
155
  edgeFallbackLabel: 'outro',
88
156
  actionKindLabels: {
@@ -111,6 +179,9 @@ export const DEFAULT_FLOW_EDITOR_LABELS: FlowEditorLabels = {
111
179
  nodePanel: {
112
180
  title: 'Editar nó',
113
181
  contextKey: 'Chave (contexto)',
182
+ nodeName: 'Nome do nó (opcional)',
183
+ nodeNamePlaceholder: 'Ex.: Enviar tabela de preços',
184
+ nodeNameHint: 'Só aparece no editor — o cliente não vê.',
114
185
  questionType: 'Tipo de resposta',
115
186
  question: 'Texto da pergunta',
116
187
  options: 'Opções (choice)',
@@ -160,6 +231,8 @@ export const DEFAULT_FLOW_EDITOR_LABELS: FlowEditorLabels = {
160
231
  flowMap: {
161
232
  nodeCount: (count) => `${count} nó(s)`,
162
233
  openFlow: 'Abrir fluxo',
234
+ toggleToMap: 'Mapa de fluxos',
235
+ toggleToDetail: 'Voltar ao editor',
163
236
  },
164
237
  flowGroup: {
165
238
  focus: 'Focar neste fluxo',
@@ -169,6 +242,61 @@ export const DEFAULT_FLOW_EDITOR_LABELS: FlowEditorLabels = {
169
242
  tooltip: 'Clique para abrir esse fluxo aqui do lado, ligado ao ponto de onde ele é chamado',
170
243
  goesTo: (label) => `↪ Vai para: ${label}`,
171
244
  },
245
+ collectionChain: {
246
+ feeds: (label) => `Alimenta: ${label}`,
247
+ },
248
+ validation: {
249
+ title: 'Antes de publicar',
250
+ errors: (count) => `${count} erro(s) — corrija antes de salvar`,
251
+ warnings: (count) => `${count} aviso(s)`,
252
+ noStart: 'O fluxo precisa de um nó inicial válido.',
253
+ brokenRef: (from, to) => `Nó "${from}": ligação aponta para "${to}", que não existe.`,
254
+ choiceWithoutOptions: (id) => `Nó "${id}": escolha sem nenhuma opção.`,
255
+ duplicatedOptionId: (id, optionId) => `Nó "${id}": valor de opção "${optionId}" duplicado.`,
256
+ optionWithoutTarget: (id, label) => `Nó "${id}": opção "${label}" não tem destino definido.`,
257
+ tooManyOptions: (id, count) => `Nó "${id}": ${count} opções — o WhatsApp aceita no máximo 10 em lista.`,
258
+ buttonTitleTooLong: (id, label) => `Nó "${id}": botão "${label}" passa de 20 caracteres.`,
259
+ listTitleTooLong: (id, label) => `Nó "${id}": item de lista "${label}" passa de 24 caracteres.`,
260
+ bodyTooLong: (id) => `Nó "${id}": texto passa de 1024 caracteres.`,
261
+ unreachable: (id) => `Nó "${id}" é inalcançável a partir do início do fluxo.`,
262
+ deadEndQuestion: (id) => `Nó "${id}": pergunta sem próximo passo definido.`,
263
+ conditionIncomplete: (id) => `Nó "${id}": condição incompleta — defina variável, operador e valor.`,
264
+ conditionBranchMissing: (id, branch) =>
265
+ `Nó "${id}": ramo "${branch === 'true' ? 'Verdadeiro' : 'Falso'}" sem destino definido.`,
266
+ },
267
+ workspace: {
268
+ title: 'Fluxos do Bot',
269
+ subtitle: 'Blueprint visual dos fluxos de conversa, sincronizado com o que está em produção.',
270
+ loading: 'Carregando fluxos…',
271
+ loadError: 'Não foi possível carregar os fluxos.',
272
+ saveGraph: 'Publicar alterações',
273
+ saving: 'Publicando…',
274
+ saveSuccess: 'Fluxo publicado! O bot já está usando a versão nova.',
275
+ saveError: 'Não foi possível salvar — verifique se todos os destinos apontam para nós existentes.',
276
+ organize: 'Organizar',
277
+ organizeTooltip: 'Reorganiza os nós automaticamente e salva as novas posições',
278
+ discardChanges: 'Desfazer alterações',
279
+ discardTooltip: 'Devolve os fluxos abertos ao que está publicado, descartando o que não foi salvo',
280
+ discardConfirm: 'Descartar todas as alterações não publicadas e voltar ao fluxo que está no ar?',
281
+ unsavedChangesConfirm:
282
+ 'Você tem alterações não publicadas neste fluxo. Trocar de fluxo agora descarta essas edições. Continuar?',
283
+ },
284
+ flowManager: {
285
+ newFlow: 'Novo fluxo',
286
+ createTitle: 'Criar novo fluxo',
287
+ key: 'Identificador único',
288
+ keyHint: 'letras minúsculas, números e _ (ex.: promocoes_semana)',
289
+ keyInvalid: 'Use apenas letras minúsculas, números e _ (2 a 40 caracteres)',
290
+ label: 'Nome exibido',
291
+ showInMenu: 'Exibir como opção no menu principal do bot',
292
+ menuOptionLabel: 'Texto da opção no menu',
293
+ create: 'Criar fluxo',
294
+ creating: 'Criando…',
295
+ deleteFlow: 'Excluir fluxo',
296
+ deleteConfirm: (label) => `Excluir o fluxo "${label}"? Esta ação não pode ser desfeita.`,
297
+ createError: 'Não foi possível criar o fluxo.',
298
+ deleteError: 'Não foi possível excluir o fluxo.',
299
+ },
172
300
  }
173
301
 
174
302
  export function mergeFlowEditorLabels(override?: Partial<FlowEditorLabels>): FlowEditorLabels {
@@ -188,5 +316,9 @@ export function mergeFlowEditorLabels(override?: Partial<FlowEditorLabels>): Flo
188
316
  flowMap: { ...DEFAULT_FLOW_EDITOR_LABELS.flowMap, ...override.flowMap },
189
317
  flowGroup: { ...DEFAULT_FLOW_EDITOR_LABELS.flowGroup, ...override.flowGroup },
190
318
  crossFlowPortal: { ...DEFAULT_FLOW_EDITOR_LABELS.crossFlowPortal, ...override.crossFlowPortal },
319
+ collectionChain: { ...DEFAULT_FLOW_EDITOR_LABELS.collectionChain, ...override.collectionChain },
320
+ validation: { ...DEFAULT_FLOW_EDITOR_LABELS.validation, ...override.validation },
321
+ workspace: { ...DEFAULT_FLOW_EDITOR_LABELS.workspace, ...override.workspace },
322
+ flowManager: { ...DEFAULT_FLOW_EDITOR_LABELS.flowManager, ...override.flowManager },
191
323
  }
192
324
  }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Estado de listagem espelhado na URL — ordenação, filtros e paginação (regra `web.md` §7).
3
+ *
4
+ * Escrito sobre `history.replaceState` e não sobre um router: o pacote roda em três produtos com
5
+ * routers diferentes, e exigir um deles arrastaria dependência de framework para dentro do módulo.
6
+ * `replaceState` também é o comportamento certo aqui — filtrar não é navegar, e cada tecla digitada
7
+ * na busca não deve virar uma entrada no botão "voltar".
8
+ */
9
+
10
+ import { useCallback, useEffect, useState } from 'react'
11
+
12
+ const LIST_SEPARATOR = ','
13
+
14
+ function readParams(): URLSearchParams {
15
+ if (typeof window === 'undefined') return new URLSearchParams()
16
+ return new URLSearchParams(window.location.search)
17
+ }
18
+
19
+ function writeParam(key: string, value: string | undefined): void {
20
+ if (typeof window === 'undefined') return
21
+ const params = readParams()
22
+ if (value === undefined || value === '') params.delete(key)
23
+ else params.set(key, value)
24
+ const query = params.toString()
25
+ window.history.replaceState(null, '', query ? `${window.location.pathname}?${query}` : window.location.pathname)
26
+ }
27
+
28
+ /** `enabled: false` mantém o mesmo contrato de estado sem tocar na URL — para uso em preview e teste. */
29
+ export interface UrlStateOptions {
30
+ readonly enabled?: boolean
31
+ }
32
+
33
+ export function useUrlStringState(
34
+ key: string,
35
+ initial: string,
36
+ { enabled = true }: UrlStateOptions = {},
37
+ ): [string, (next: string) => void] {
38
+ const [value, setValue] = useState(() => (enabled ? (readParams().get(key) ?? initial) : initial))
39
+
40
+ const update = useCallback(
41
+ (next: string) => {
42
+ setValue(next)
43
+ if (enabled) writeParam(key, next === initial ? undefined : next)
44
+ },
45
+ [enabled, key, initial],
46
+ )
47
+
48
+ return [value, update]
49
+ }
50
+
51
+ export function useUrlNumberState(
52
+ key: string,
53
+ initial: number,
54
+ { enabled = true }: UrlStateOptions = {},
55
+ ): [number, (next: number) => void] {
56
+ const [value, setValue] = useState(() => {
57
+ if (!enabled) return initial
58
+ const raw = Number(readParams().get(key))
59
+ return Number.isFinite(raw) && raw > 0 ? raw : initial
60
+ })
61
+
62
+ const update = useCallback(
63
+ (next: number) => {
64
+ setValue(next)
65
+ if (enabled) writeParam(key, next === initial ? undefined : String(next))
66
+ },
67
+ [enabled, key, initial],
68
+ )
69
+
70
+ return [value, update]
71
+ }
72
+
73
+ export function useUrlArrayState(
74
+ key: string,
75
+ { enabled = true }: UrlStateOptions = {},
76
+ ): [readonly string[], (next: readonly string[]) => void] {
77
+ const [value, setValue] = useState<readonly string[]>(() => {
78
+ if (!enabled) return []
79
+ const raw = readParams().get(key)
80
+ return raw ? raw.split(LIST_SEPARATOR).filter(Boolean) : []
81
+ })
82
+
83
+ const update = useCallback(
84
+ (next: readonly string[]) => {
85
+ setValue(next)
86
+ if (enabled) writeParam(key, next.length > 0 ? next.join(LIST_SEPARATOR) : undefined)
87
+ },
88
+ [enabled, key],
89
+ )
90
+
91
+ return [value, update]
92
+ }
93
+
94
+ /**
95
+ * Espera o usuário parar de digitar antes de deixar o valor chegar à query. Sem isto, cada tecla
96
+ * na busca vira uma chamada de rede e uma reescrita de URL.
97
+ */
98
+ export function useDebouncedValue<TValue>(value: TValue, delayMs = 300): TValue {
99
+ const [debounced, setDebounced] = useState(value)
100
+
101
+ useEffect(() => {
102
+ const timer = setTimeout(() => setDebounced(value), delayMs)
103
+ return () => clearTimeout(timer)
104
+ }, [value, delayMs])
105
+
106
+ return debounced
107
+ }
package/src/index.ts CHANGED
@@ -86,6 +86,24 @@ export { WindowExpiredNotice, isWindowBlocking, DEFAULT_WINDOW_EXPIRED_LABELS }
86
86
  export type { WindowExpiredNoticeProps, WindowExpiredNoticeLabels } from './WindowExpiredNotice'
87
87
  export { DocumentsLibrary, DEFAULT_DOCUMENTS_LIBRARY_LABELS } from './DocumentsLibrary'
88
88
  export type { DocumentsLibraryProps, DocumentsLibraryLabels, DocumentsLibraryClassNames } from './DocumentsLibrary'
89
+ export { DocumentsWorkspace, DEFAULT_DOCUMENTS_WORKSPACE_LABELS } from './documents'
90
+ export type {
91
+ DocumentsWorkspaceProps,
92
+ DocumentsWorkspaceLabels,
93
+ DocumentsWorkspaceClassNames,
94
+ DocumentsFiltersContext,
95
+ } from './documents'
96
+ export { SortableHead, MultiSelectFilter, BulkActionBar, ListingPagination } from './listing'
97
+ export type {
98
+ SortableHeadProps,
99
+ MultiSelectFilterProps,
100
+ BulkActionBarProps,
101
+ ListingPaginationProps,
102
+ FilterOption,
103
+ SortDirection,
104
+ } from './listing'
105
+ export { useUrlStringState, useUrlNumberState, useUrlArrayState, useDebouncedValue } from './hooks/useUrlFilterState'
106
+ export type { UrlStateOptions } from './hooks/useUrlFilterState'
89
107
  export { DOCUMENT_SOURCE_FILTER } from './ConversationDocumentsPanel'
90
108
  export type { DocumentSourceFilter } from './ConversationDocumentsPanel'
91
109
  export type { ConversationDocumentsPanelClassNames } from './ConversationDocumentsPanel'
@@ -118,6 +136,18 @@ export {
118
136
  } from './settings/WhatsAppTemplatesSettings'
119
137
  export { TopicsForm } from './settings/TopicsForm'
120
138
 
139
+ // Tela composta de Mensagens: junta os formulários acima com abas, estado e salvamento, para o
140
+ // host não remontar essa mesma colagem em cada produto (foi assim que eles divergiram).
141
+ export { MessagesWorkspace } from './settings/MessagesWorkspace'
142
+ export type {
143
+ MessagesWorkspaceProps,
144
+ MessagesWorkspaceApi,
145
+ MessagesWorkspaceLabels,
146
+ BotMessages,
147
+ TemplateSettings,
148
+ TranscriptionSettings,
149
+ } from './settings/MessagesWorkspace'
150
+
121
151
  // Camada headless (T6.9) — hooks de dados/ações independentes de qualquer tela, para o
122
152
  // produto montar sua própria UI sobre eles. Requerem <ConversationsProvider> como ancestral.
123
153
  export { useConversationMessages } from './hooks/useConversationMessages'
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Peças de listagem que toda tela tabular do pacote precisa ter iguais: cabeçalho ordenável,
3
+ * filtro de seleção múltipla, barra de ação em lote e paginação (regra `web.md` §7).
4
+ *
5
+ * Vivem aqui, e não dentro de cada tela, porque a alternativa já se provou pior: cada workspace
6
+ * recriava o seu, e o mesmo "limpar filtros" ficava em três lugares com três comportamentos.
7
+ */
8
+
9
+ import { useState, type ReactNode } from 'react'
10
+ import { ArrowDown, ArrowUp, ArrowUpDown, ChevronDown, X } from 'lucide-react'
11
+
12
+ import { cn } from '../lib/cn'
13
+
14
+ export type SortDirection = 'asc' | 'desc'
15
+
16
+ export interface SortableHeadProps {
17
+ readonly label: string
18
+ readonly field: string
19
+ readonly activeField: string
20
+ readonly direction: SortDirection
21
+ readonly onSort: (field: string) => void
22
+ readonly className?: string
23
+ }
24
+
25
+ export function SortableHead({ label, field, activeField, direction, onSort, className }: SortableHeadProps) {
26
+ const isActive = activeField === field
27
+ const Icon = !isActive ? ArrowUpDown : direction === 'asc' ? ArrowUp : ArrowDown
28
+
29
+ return (
30
+ <th scope="col" className={cn('px-3 py-2 text-left text-xs font-medium', className)}>
31
+ <button
32
+ type="button"
33
+ onClick={() => onSort(field)}
34
+ aria-label={label}
35
+ className="inline-flex items-center gap-1 hover:text-gray-900 dark:hover:text-gray-100"
36
+ >
37
+ {label}
38
+ <Icon size={12} className={isActive ? '' : 'opacity-40'} aria-hidden="true" />
39
+ </button>
40
+ </th>
41
+ )
42
+ }
43
+
44
+ export interface FilterOption {
45
+ readonly value: string
46
+ readonly label: string
47
+ }
48
+
49
+ export interface MultiSelectFilterProps {
50
+ readonly label: string
51
+ readonly options: readonly FilterOption[]
52
+ readonly selected: readonly string[]
53
+ readonly onChange: (selected: readonly string[]) => void
54
+ readonly className?: string
55
+ }
56
+
57
+ export function MultiSelectFilter({ label, options, selected, onChange, className }: MultiSelectFilterProps) {
58
+ const [open, setOpen] = useState(false)
59
+
60
+ function toggle(value: string): void {
61
+ onChange(selected.includes(value) ? selected.filter((item) => item !== value) : [...selected, value])
62
+ }
63
+
64
+ return (
65
+ <div className={cn('relative', className)}>
66
+ <button
67
+ type="button"
68
+ onClick={() => setOpen((current) => !current)}
69
+ className="cv-header-action inline-flex items-center gap-1"
70
+ >
71
+ {label}
72
+ {selected.length > 0 ? <span className="cv-filter-count">{selected.length}</span> : null}
73
+ <ChevronDown size={12} className={open ? 'rotate-180 transition-transform' : 'transition-transform'} aria-hidden="true" />
74
+ </button>
75
+
76
+ {open ? (
77
+ <>
78
+ {/* Sem a camada de fundo o dropdown só fechava clicando de novo no botão, e ficava aberto
79
+ por cima da tabela enquanto o usuário tentava clicar numa linha. */}
80
+ <div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
81
+ <div className="cv-filter-menu">
82
+ {options.map((option) => (
83
+ <label key={option.value} className="cv-filter-option">
84
+ <input
85
+ type="checkbox"
86
+ checked={selected.includes(option.value)}
87
+ onChange={() => toggle(option.value)}
88
+ className="cursor-pointer rounded border-gray-300 text-blue-600 dark:border-gray-600"
89
+ />
90
+ {option.label}
91
+ </label>
92
+ ))}
93
+ </div>
94
+ </>
95
+ ) : null}
96
+ </div>
97
+ )
98
+ }
99
+
100
+ export interface BulkActionBarProps {
101
+ readonly selectedCount: number
102
+ readonly selectedLabel: (count: number) => string
103
+ readonly clearLabel: string
104
+ readonly onClear: () => void
105
+ readonly children?: ReactNode
106
+ }
107
+
108
+ export function BulkActionBar({ selectedCount, selectedLabel, clearLabel, onClear, children }: BulkActionBarProps) {
109
+ if (selectedCount === 0) return null
110
+
111
+ return (
112
+ <div className="cv-bulk-bar" role="toolbar" aria-label={selectedLabel(selectedCount)}>
113
+ <span className="text-xs font-medium">{selectedLabel(selectedCount)}</span>
114
+ {children}
115
+ <button type="button" onClick={onClear} className="cv-header-action ml-auto inline-flex items-center gap-1">
116
+ <X size={12} aria-hidden="true" />
117
+ {clearLabel}
118
+ </button>
119
+ </div>
120
+ )
121
+ }
122
+
123
+ export interface ListingPaginationProps {
124
+ readonly page: number
125
+ readonly total: number
126
+ readonly perPage: number
127
+ readonly perPageOptions?: readonly number[]
128
+ readonly onPageChange: (page: number) => void
129
+ readonly onPerPageChange?: (perPage: number) => void
130
+ readonly labels: {
131
+ readonly show: string
132
+ readonly perPage: string
133
+ readonly total: (count: number) => string
134
+ readonly page: (current: number, last: number) => string
135
+ readonly previous: string
136
+ readonly next: string
137
+ }
138
+ }
139
+
140
+ export function ListingPagination({
141
+ page,
142
+ total,
143
+ perPage,
144
+ perPageOptions,
145
+ onPageChange,
146
+ onPerPageChange,
147
+ labels,
148
+ }: ListingPaginationProps) {
149
+ const lastPage = Math.max(1, Math.ceil(total / perPage))
150
+
151
+ return (
152
+ <div className="cv-listing-pagination">
153
+ <div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
154
+ {onPerPageChange && perPageOptions ? (
155
+ <>
156
+ <span>{labels.show}</span>
157
+ <select
158
+ value={perPage}
159
+ onChange={(event) => onPerPageChange(Number(event.target.value))}
160
+ aria-label={labels.perPage}
161
+ className="cv-listing-perpage"
162
+ >
163
+ {perPageOptions.map((option) => (
164
+ <option key={option} value={option}>
165
+ {option}
166
+ </option>
167
+ ))}
168
+ </select>
169
+ <span>{labels.perPage}</span>
170
+ </>
171
+ ) : null}
172
+ <span className="ml-1">{labels.total(total)}</span>
173
+ </div>
174
+
175
+ <div className="flex items-center gap-2">
176
+ <button
177
+ type="button"
178
+ onClick={() => onPageChange(page - 1)}
179
+ disabled={page <= 1}
180
+ aria-label={labels.previous}
181
+ className="cv-header-icon disabled:opacity-40"
182
+ >
183
+ ‹
184
+ </button>
185
+ <span className="text-xs text-gray-500">{labels.page(page, lastPage)}</span>
186
+ <button
187
+ type="button"
188
+ onClick={() => onPageChange(page + 1)}
189
+ disabled={page >= lastPage}
190
+ aria-label={labels.next}
191
+ className="cv-header-icon disabled:opacity-40"
192
+ >
193
+ ›
194
+ </button>
195
+ </div>
196
+ </div>
197
+ )
198
+ }
@@ -31,14 +31,38 @@ export interface ListDocumentsParams {
31
31
  page?: number
32
32
  /** Tamanho da página. Sem ele, `page` sozinho não define fatia nenhuma. */
33
33
  limit?: number
34
- /** Origem do arquivo (`customer`, `agent`, `bot`…). O vocabulário é do host. */
34
+ /**
35
+ * Origem do arquivo (`customer`, `agent`, `bot`…). O vocabulário é do host.
36
+ *
37
+ * Seleção múltipla viaja como lista separada por vírgula em vez de virar `string[]`: mudar o
38
+ * tipo quebraria em compile-time toda implementação de host que já repassa este campo adiante,
39
+ * e o ganho seria nenhum — quem recebe faz `split(',')`.
40
+ */
35
41
  source?: string
42
+ /** Categoria do arquivo (`document`, `image`, `audio`, `video`…), mesma convenção de lista. */
43
+ fileCategory?: string
44
+ /** Recorte por data de recebimento, em `YYYY-MM-DD`. */
45
+ startDate?: string
46
+ endDate?: string
36
47
  sortDirection?: 'asc' | 'desc'
48
+ /** Coluna ordenada. Ausente, o host ordena pela data — é o padrão de toda listagem de arquivo. */
49
+ sortField?: string
50
+ /**
51
+ * Filtros que só existem no produto (`clientId`, `unidade`…). O pacote não os interpreta: passa
52
+ * adiante o que o host injetou pelo slot de filtros. É a porta que evita um fork da tela por
53
+ * causa de um `<select>`.
54
+ */
55
+ extra?: Readonly<Record<string, string | number>>
37
56
  }
38
57
 
39
58
  /** Arquivo na biblioteca da empresa: o mesmo da conversa, mais de qual conversa veio. */
40
59
  export interface CompanyDocument extends ConversationDocument {
41
60
  conversationId: string
61
+ /**
62
+ * Nome de quem enviou, quando o host o conhece. Opcional porque a biblioteca sempre tem o
63
+ * telefone e nem todo produto tem cadastro por trás dele — ausente, a coluna cai para o número.
64
+ */
65
+ contactName?: string | null
42
66
  }
43
67
 
44
68
  export interface CompanyDocumentPage {
@@ -110,6 +134,17 @@ export interface ConversationsApi {
110
134
  * componente de biblioteca simplesmente não é usável — melhor que uma tela que sempre erra.
111
135
  */
112
136
  getAllDocuments?(params?: ListDocumentsParams): Promise<CompanyDocumentPage>
137
+ /**
138
+ * Remove um arquivo da biblioteca. **Opcional por capacidade:** apagar anexo trocado com o
139
+ * cliente é decisão de retenção do produto — instalação que precisa guardar tudo por obrigação
140
+ * legal não implementa, e a tela simplesmente não desenha a lixeira.
141
+ */
142
+ deleteDocument?(uploadId: string): Promise<void>
143
+ /**
144
+ * Zip de arquivos avulsos da biblioteca, sem conversa de origem única — irmão do
145
+ * `downloadDocumentsArchive`, que é por conversa. Ausente, a seleção em lote não oferece o botão.
146
+ */
147
+ downloadDocumentsArchiveByIds?(uploadIds: readonly string[]): Promise<Blob>
113
148
  getMediaProxyUrl(mediaId: string): Promise<{ mimeType: string; data: string }>
114
149
 
115
150
  /**