@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.
- package/dist/flows/index.d.ts +128 -2
- package/dist/flows/index.js +1085 -6
- package/dist/index.d.ts +220 -4
- package/dist/index.js +1237 -329
- package/dist/preview/index.d.ts +2 -2
- package/dist/styles.css +82 -0
- package/dist/{types-C6A_9edv.d.ts → types-BfINicc-.d.ts} +36 -1
- package/package.json +2 -2
- package/src/documents/DocumentsWorkspace.tsx +489 -0
- package/src/documents/index.ts +8 -0
- package/src/documents/labels.ts +88 -0
- package/src/flows/FlowNodeCard.tsx +12 -3
- package/src/flows/FlowNodePanel.tsx +15 -2
- package/src/flows/FlowsWorkspace.tsx +1254 -0
- package/src/flows/index.ts +3 -1
- package/src/flows/labels.ts +132 -0
- package/src/hooks/useUrlFilterState.ts +107 -0
- package/src/index.ts +30 -0
- package/src/listing/index.tsx +198 -0
- package/src/providers/types.ts +36 -1
- package/src/settings/MessagesWorkspace.tsx +465 -0
- package/src/settings/WhatsAppTemplatesSettings.tsx +7 -1
- package/src/styles.css +70 -0
package/src/flows/index.ts
CHANGED
|
@@ -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'
|
package/src/flows/labels.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/providers/types.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
/**
|