@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.
- package/dist/ConversationSimulatorPanel--5fIzXWY.d.ts +804 -0
- package/dist/{chunk-G5BM3VBP.js → chunk-BJNRLLDO.js} +1248 -282
- package/dist/chunk-DKPXKQGC.js +110 -0
- package/dist/{chunk-2AYDBWNE.js → chunk-WCBDXZ3X.js} +13 -3
- package/dist/flows/index.d.ts +422 -5
- package/dist/flows/index.js +2502 -676
- package/dist/index.d.ts +919 -17
- package/dist/index.js +3676 -675
- package/dist/preview/index.d.ts +62 -105
- package/dist/preview/index.js +162 -284
- package/dist/styles.css +893 -0
- package/package.json +9 -8
- package/src/AudioPlayer.tsx +8 -0
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +248 -0
- package/src/AudioTranscription.test.tsx +115 -0
- package/src/AudioTranscription.tsx +252 -0
- package/src/Avatar.tsx +1 -1
- package/src/ConversationContextPanel.tsx +218 -44
- package/src/ConversationDocumentsPanel.tsx +11 -6
- package/src/ConversationHeader.test.tsx +66 -0
- package/src/ConversationHeader.tsx +147 -47
- package/src/ConversationListItem.tsx +8 -6
- package/src/ConversationLocalesProvider.tsx +28 -0
- package/src/ConversationRow.tsx +53 -7
- package/src/DarkModeToggle.test.tsx +76 -0
- package/src/DarkModeToggle.tsx +92 -0
- package/src/DocumentsLibrary.tsx +67 -7
- package/src/EmojiPicker.tsx +2 -1
- package/src/InteractiveMessage.tsx +3 -0
- package/src/Lightbox.tsx +1 -1
- package/src/MediaRenderer.tsx +88 -15
- package/src/MessageBubble.test.tsx +41 -0
- package/src/MessageBubble.tsx +47 -5
- package/src/MessageComposer.test.tsx +35 -0
- package/src/MessageComposer.tsx +122 -17
- package/src/MessageText.tsx +2 -1
- package/src/MessageTimestamp.tsx +2 -1
- package/src/RichMessageComposer.test.tsx +113 -0
- package/src/RichMessageComposer.tsx +551 -0
- package/src/SimpleEmojiPicker.tsx +5 -3
- package/src/StatusTicks.tsx +1 -1
- package/src/Toast.tsx +4 -0
- package/src/Tooltip.test.ts +42 -0
- package/src/Tooltip.tsx +167 -0
- package/src/Wallpaper.tsx +27 -13
- package/src/WhatsAppMessageEditor.tsx +10 -7
- package/src/WindowExpiredNotice.tsx +12 -4
- package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
- package/src/buildOutput.test.ts +79 -0
- package/src/composer.constant.ts +33 -0
- package/src/conversationTranscript.test.ts +57 -0
- package/src/conversationTranscript.ts +29 -4
- package/src/conversationWindow.ts +7 -5
- package/src/documentTypeLabel.test.ts +57 -0
- package/src/documents/DocumentsWorkspace.tsx +550 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +92 -0
- package/src/flows/FlowConnectionEdge.tsx +104 -0
- package/src/flows/FlowGroupHeader.tsx +12 -2
- package/src/flows/FlowLegend.tsx +125 -0
- package/src/flows/FlowMapCanvas.tsx +15 -12
- package/src/flows/FlowMapNode.tsx +4 -1
- package/src/flows/FlowNodeCard.tsx +219 -34
- package/src/flows/FlowNodePanel.tsx +153 -39
- package/src/flows/FlowPalette.tsx +156 -70
- package/src/flows/FlowPortalNode.tsx +1 -1
- package/src/flows/FlowWhatsAppPreview.tsx +14 -3
- package/src/flows/FlowsWorkspace.tsx +1255 -0
- package/src/flows/flowCanvasModel.test.ts +456 -0
- package/src/flows/flowCanvasModel.ts +378 -0
- package/src/flows/flowEditorOps.test.ts +276 -0
- package/src/flows/flowEditorOps.ts +202 -0
- package/src/flows/flowGraph.ts +78 -53
- package/src/flows/flowMenuPlacement.test.ts +130 -0
- package/src/flows/flowMenuPlacement.ts +86 -0
- package/src/flows/index.ts +51 -2
- package/src/flows/labels.ts +180 -0
- package/src/flows/workspaceContract.test.ts +126 -0
- package/src/hooks/useContainerWidth.ts +35 -0
- package/src/hooks/useConversationRealtime.ts +10 -8
- package/src/hooks/useScrollToLatestMessage.ts +127 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/icon.constant.ts +12 -0
- package/src/index.ts +100 -0
- package/src/lib/composer-formatting.test.ts +78 -0
- package/src/lib/composer-formatting.ts +145 -0
- package/src/lib/whatsapp-formatting.test.tsx +37 -0
- package/src/lib/whatsapp-formatting.tsx +28 -3
- package/src/listing/index.tsx +202 -0
- package/src/pagination.constant.ts +10 -0
- package/src/preview/ConversationPreview.tsx +84 -45
- package/src/preview/ConversationSimulatorClient.ts +143 -0
- package/src/preview/ConversationSimulatorPanel.test.tsx +55 -0
- package/src/preview/ConversationSimulatorPanel.tsx +131 -0
- package/src/preview/createPreviewBridgeClient.test.ts +92 -0
- package/src/preview/createPreviewBridgeClient.ts +124 -0
- package/src/preview/createPreviewMediaUploader.ts +82 -0
- package/src/preview/createPreviewWebhookClient.test.ts +96 -0
- package/src/preview/createPreviewWebhookClient.ts +99 -3
- package/src/preview/index.ts +36 -2
- package/src/preview/previewMediaUploader.test.ts +61 -0
- package/src/providers/ConversationsProvider.tsx +8 -6
- package/src/providers/types.ts +59 -2
- package/src/quickReply.test.ts +58 -0
- package/src/replyLatency.test.ts +71 -0
- package/src/replyLatency.ts +57 -0
- package/src/settings/MessagesWorkspace.tsx +571 -0
- package/src/settings/TopicsForm.tsx +2 -0
- package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
- package/src/settings/TranscriptionSettingsForm.tsx +190 -0
- package/src/settings/WelcomeFarewellForm.tsx +1 -0
- package/src/settings/WhatsAppCreateTemplateForm.tsx +1 -0
- package/src/settings/WhatsAppTemplateSettingsForm.tsx +5 -2
- package/src/settings/WhatsAppTemplatesSettings.test.tsx +61 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +22 -2
- package/src/styles.css +858 -0
- package/src/theme.ts +13 -0
- package/src/types.ts +26 -0
- package/src/workspace/BulkTemplateModal.tsx +132 -0
- package/src/workspace/ConversationPane.tsx +432 -0
- package/src/workspace/ConversationsInboxList.tsx +194 -0
- package/src/workspace/ConversationsWorkspace.tsx +423 -0
- package/src/workspace/index.ts +17 -0
- package/src/workspace/labels.test.ts +17 -0
- package/src/workspace/labels.ts +85 -0
- package/src/workspace/useConversationsInbox.ts +332 -0
- package/dist/types-B5C1DLu1.d.ts +0 -365
- 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
|
+
}
|