@adatechnology/conversations-ui 0.1.0-rc.2 → 0.1.0-rc.21

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.
Files changed (106) hide show
  1. package/dist/{chunk-ZDURDZTM.js → chunk-2AYDBWNE.js} +14 -0
  2. package/dist/chunk-TV4OQRGH.js +2187 -0
  3. package/dist/flows/index.d.ts +14 -2
  4. package/dist/flows/index.js +117 -39
  5. package/dist/index.d.ts +862 -136
  6. package/dist/index.js +1746 -1124
  7. package/dist/preview/index.d.ts +470 -0
  8. package/dist/preview/index.js +1329 -0
  9. package/dist/styles.css +228 -0
  10. package/dist/types-C6A_9edv.d.ts +456 -0
  11. package/package.json +10 -3
  12. package/src/AudioRecorderButton.test.tsx +30 -0
  13. package/src/AudioRecorderButton.tsx +248 -0
  14. package/src/AudioTranscription.test.tsx +115 -0
  15. package/src/AudioTranscription.tsx +249 -0
  16. package/src/Avatar.tsx +30 -4
  17. package/src/ChannelIcon.tsx +87 -0
  18. package/src/ConversationContextPanel.tsx +280 -0
  19. package/src/ConversationDocumentsPanel.tsx +425 -0
  20. package/src/ConversationHeader.tsx +257 -0
  21. package/src/ConversationListItem.tsx +54 -7
  22. package/src/ConversationLocalesProvider.tsx +44 -0
  23. package/src/ConversationRow.tsx +137 -0
  24. package/src/DateDivider.tsx +16 -3
  25. package/src/DocumentsLibrary.tsx +322 -0
  26. package/src/EmojiPicker.tsx +69 -55
  27. package/src/FileIcon.test.ts +83 -0
  28. package/src/FileIcon.tsx +88 -11
  29. package/src/InteractiveMessage.test.tsx +41 -0
  30. package/src/InteractiveMessage.tsx +143 -0
  31. package/src/Lightbox.tsx +18 -3
  32. package/src/MediaRenderer.tsx +96 -22
  33. package/src/MessageBubble.tsx +77 -5
  34. package/src/MessageComposer.test.tsx +35 -0
  35. package/src/MessageComposer.tsx +165 -19
  36. package/src/RichMessageComposer.test.tsx +83 -0
  37. package/src/RichMessageComposer.tsx +380 -0
  38. package/src/Wallpaper.test.tsx +21 -0
  39. package/src/Wallpaper.tsx +69 -7
  40. package/src/WhatsAppMessageEditor.tsx +28 -4
  41. package/src/WindowExpiredNotice.tsx +57 -0
  42. package/src/audioRecorderFormat.test.ts +67 -0
  43. package/src/conversationChannel.test.ts +53 -0
  44. package/src/conversationChannel.ts +146 -0
  45. package/src/conversationTranscript.test.ts +122 -0
  46. package/src/conversationTranscript.ts +89 -0
  47. package/src/conversationWindow.test.ts +90 -0
  48. package/src/conversationWindow.ts +78 -0
  49. package/src/documentTypeLabel.test.ts +57 -0
  50. package/src/emojiCatalog.test.ts +35 -0
  51. package/src/emojiCatalog.ts +189 -0
  52. package/src/flows/FlowGroupHeader.tsx +12 -2
  53. package/src/flows/FlowMapCanvas.tsx +17 -14
  54. package/src/flows/FlowMapNode.tsx +3 -1
  55. package/src/flows/FlowNodeCard.tsx +22 -4
  56. package/src/flows/FlowNodePanel.tsx +132 -35
  57. package/src/flows/FlowPalette.tsx +6 -2
  58. package/src/flows/FlowWhatsAppPreview.tsx +14 -3
  59. package/src/flows/flowGraph.ts +5 -5
  60. package/src/flows/labels.ts +5 -0
  61. package/src/hooks/useConversationActions.ts +56 -0
  62. package/src/hooks/useConversationDocuments.ts +15 -9
  63. package/src/hooks/useConversationList.ts +15 -9
  64. package/src/hooks/useConversationMessages.ts +2 -2
  65. package/src/hooks/useScrollToLatestMessage.ts +127 -0
  66. package/src/index.ts +140 -16
  67. package/src/lib/cn.test.ts +29 -0
  68. package/src/lib/cn.ts +15 -0
  69. package/src/lib/createMediaUrlResolver.ts +33 -0
  70. package/src/lib/paginated.test.ts +33 -0
  71. package/src/lib/paginated.ts +26 -0
  72. package/src/lib/phone.ts +34 -0
  73. package/src/preview/ConversationPreview.tsx +334 -0
  74. package/src/preview/MediaTypesPreview.tsx +87 -0
  75. package/src/preview/conversationPreviewFailures.test.ts +64 -0
  76. package/src/preview/createMockConversationsApi.ts +271 -0
  77. package/src/preview/createMockSSEProvider.ts +40 -0
  78. package/src/preview/createPreviewBridgeClient.test.ts +92 -0
  79. package/src/preview/createPreviewBridgeClient.ts +124 -0
  80. package/src/preview/createPreviewMediaUploader.ts +82 -0
  81. package/src/preview/createPreviewWebhookClient.test.ts +194 -0
  82. package/src/preview/createPreviewWebhookClient.ts +222 -0
  83. package/src/preview/index.ts +67 -0
  84. package/src/preview/mediaTypeOf.test.ts +15 -0
  85. package/src/preview/mockDocumentsSearch.test.ts +57 -0
  86. package/src/preview/mockEventSource.ts +53 -0
  87. package/src/preview/preview.test.ts +177 -0
  88. package/src/preview/previewFileSamples.test.ts +151 -0
  89. package/src/preview/previewFileSamples.ts +74 -0
  90. package/src/preview/previewFixtures.ts +440 -0
  91. package/src/preview/previewMediaSource.test.ts +62 -0
  92. package/src/preview/previewMediaSource.ts +91 -0
  93. package/src/preview/previewMediaUploader.test.ts +61 -0
  94. package/src/preview/previewStore.ts +193 -0
  95. package/src/preview/startPreviewScript.ts +60 -0
  96. package/src/providers/types.ts +175 -12
  97. package/src/quickReply.test.ts +58 -0
  98. package/src/settings/TranscriptionSettingsForm.test.tsx +81 -0
  99. package/src/settings/TranscriptionSettingsForm.tsx +189 -0
  100. package/src/settings/WhatsAppCreateTemplateForm.tsx +3 -1
  101. package/src/settings/WhatsAppTemplatesSettings.tsx +106 -0
  102. package/src/styles.css +173 -0
  103. package/src/types.ts +72 -1
  104. package/src/useDarkMode.ts +26 -0
  105. package/src/useIsNarrow.ts +29 -0
  106. package/src/useWaitingNotifications.ts +74 -29
@@ -1,14 +1,38 @@
1
1
  import { useMemo } from 'react'
2
2
  import { Avatar } from './Avatar'
3
- import { formatPhone } from './lib/phone'
3
+ import { contactFlag, formatContactHandle } from './conversationChannel'
4
4
  import type { ConversationSummary } from './providers/types'
5
5
 
6
+ export interface ConversationListItemLabels {
7
+ /** Tooltip do ponto vermelho: a janela de atendimento de 24h já fechou. */
8
+ expiredWindow: string
9
+ /** Tooltip do ponto laranja: a janela de atendimento está perto de fechar. */
10
+ warningWindow: string
11
+ }
12
+
13
+ export const DEFAULT_CONVERSATION_LIST_ITEM_LABELS: ConversationListItemLabels = {
14
+ expiredWindow: 'Janela expirada',
15
+ warningWindow: 'Janela próxima do fim',
16
+ }
17
+
6
18
  export interface ConversationListItemProps {
7
19
  conversation: ConversationSummary
20
+ labels?: Partial<ConversationListItemLabels>
8
21
  active?: boolean
9
22
  selected?: boolean
10
23
  onClick?: () => void
11
24
  onSelect?: (id: string) => void
25
+ /**
26
+ * Desliga a borda inferior quando o item é composto dentro de outra linha (ver `ConversationRow`):
27
+ * com ela ligada, a borda corta a própria linha ao meio, separando o item do rodapé de status.
28
+ */
29
+ showDivider?: boolean
30
+ /**
31
+ * Desliga o fundo de selecionado. Par do `showDivider`: quando o item é composto dentro de uma
32
+ * linha maior, quem pinta o realce é a linha — senão só o bloco do item fica cinza e o resto
33
+ * (checkbox, pills, barra lateral) continua branco, como se metade da linha estivesse selecionada.
34
+ */
35
+ highlightActive?: boolean
12
36
  }
13
37
 
14
38
  function formatRelativeTime(dateStr: string): string {
@@ -79,12 +103,22 @@ export const ConversationListItem = ({
79
103
  selected = false,
80
104
  onClick,
81
105
  onSelect,
106
+ showDivider = true,
107
+ highlightActive = true,
108
+ labels,
82
109
  }: ConversationListItemProps) => {
110
+ const expiredWindowLabel = labels?.expiredWindow ?? DEFAULT_CONVERSATION_LIST_ITEM_LABELS.expiredWindow
111
+ const warningWindowLabel = labels?.warningWindow ?? DEFAULT_CONVERSATION_LIST_ITEM_LABELS.warningWindow
83
112
  const isActive = active || selected
84
113
  const windowStatus = useMemo(() => getWindowStatus(conversation.lastInboundAt), [conversation.lastInboundAt])
85
114
  const preview = useMemo(() => lastMessagePreview(conversation), [conversation.lastContent])
86
- const name = conversation.clientName || formatPhone(conversation.whatsappNumber) || conversation.whatsappNumber
115
+ // `contactId` é o identificador neutro; `whatsappNumber` fica como fallback enquanto o backend
116
+ // não informa canal.
117
+ const handle = conversation.contactId ?? conversation.whatsappNumber
118
+ const displayHandle = formatContactHandle({ handle, channel: conversation.channel })
119
+ const name = conversation.clientName || displayHandle || handle
87
120
  const timestamp = formatRelativeTime(conversation.lastAt)
121
+ const flag = contactFlag({ handle, channel: conversation.channel })
88
122
  const isInbound = conversation.lastDirection === 'inbound'
89
123
 
90
124
  const handleClick = () => {
@@ -95,13 +129,15 @@ export const ConversationListItem = ({
95
129
  return (
96
130
  <button
97
131
  onClick={handleClick}
98
- className="w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-[#f0f2f5] border-b border-[#e9edef]"
132
+ className={`w-full flex items-center gap-3 px-3 py-2.5 text-left transition-colors ${highlightActive ? "hover:bg-[#f0f2f5]" : ""} ${showDivider ? "border-b border-[#e9edef]" : ""}`}
99
133
  style={{
100
- backgroundColor: isActive ? '#f0f2f5' : 'transparent',
134
+ backgroundColor: isActive && highlightActive ? '#f0f2f5' : 'transparent',
101
135
  }}
102
136
  >
103
137
  <div className="relative flex-shrink-0">
104
- <Avatar name={name} size="lg" />
138
+ {/* o nome do cliente: passar o telefone faria o avatar exibir dígitos soltos em vez da
139
+ silhueta de contato sem nome. */}
140
+ <Avatar name={conversation.clientName} size="lg" />
105
141
  {conversation.waitingHuman && (
106
142
  <div className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 bg-amber-400 rounded-full border-2 border-white" />
107
143
  )}
@@ -110,18 +146,29 @@ export const ConversationListItem = ({
110
146
  <div className="flex-1 min-w-0">
111
147
  <div className="flex items-center justify-between gap-2">
112
148
  <div className="flex items-center gap-1.5 min-w-0">
149
+ {/* Sem nome, o título já é o telefone — a bandeira vai nele. */}
150
+ {!conversation.clientName && flag ? <span aria-hidden>{flag}</span> : null}
113
151
  <span className="text-[16px] text-[#111b21] truncate">{name}</span>
114
152
  {windowStatus && windowStatus.label === 'expired' && (
115
- <span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title="Janela expirada" />
153
+ <span className="w-2 h-2 rounded-full bg-red-500 flex-shrink-0" title={expiredWindowLabel} />
116
154
  )}
117
155
  {windowStatus && windowStatus.label === 'warning' && (
118
- <span className="w-2 h-2 rounded-full bg-orange-500 flex-shrink-0" title="Janela próxima do fim" />
156
+ <span className="w-2 h-2 rounded-full bg-orange-500 flex-shrink-0" title={warningWindowLabel} />
119
157
  )}
120
158
  </div>
121
159
  {timestamp && (
122
160
  <span className="text-xs text-[#667781] flex-shrink-0">{timestamp}</span>
123
161
  )}
124
162
  </div>
163
+ {/* Telefone só como subtítulo quando o título é o nome — repetir o número embaixo dele
164
+ mesmo gastaria uma linha para dizer duas vezes a mesma coisa. */}
165
+ {conversation.clientName ? (
166
+ <div className="flex items-center gap-1 text-xs text-[#667781]">
167
+ {flag ? <span aria-hidden>{flag}</span> : null}
168
+ <span className="truncate">{displayHandle}</span>
169
+ </div>
170
+ ) : null}
171
+
125
172
  <div className="flex items-center justify-between mt-0.5">
126
173
  <span className="text-sm text-[#667781] truncate max-w-[180px]">
127
174
  {preview.icon && <span className="mr-1">{preview.icon}</span>}
@@ -13,6 +13,28 @@ export interface ConversationLocales {
13
13
  viewImage: string
14
14
  listenAudio: string
15
15
  viewVideo: string
16
+ moderationFlagged: string
17
+ mediaLoading: string
18
+ mediaRetry: string
19
+ mediaError: string
20
+ mediaUnavailable: string
21
+ imageAlt: string
22
+ untitledDocument: string
23
+ downloadFile: string
24
+ }
25
+ transcription: {
26
+ label: string
27
+ copy: string
28
+ copied: string
29
+ transcribe: string
30
+ transcribing: string
31
+ retry: string
32
+ failed: string
33
+ /** Áudio processado sem fala detectada — distinto de "não transcrito". */
34
+ empty: string
35
+ unsupported: string
36
+ showMore: string
37
+ showLess: string
16
38
  }
17
39
  selection: {
18
40
  select: string
@@ -36,6 +58,27 @@ const DEFAULT_LOCALES: ConversationLocales = {
36
58
  viewImage: 'Ver imagem',
37
59
  listenAudio: 'Ouvir áudio',
38
60
  viewVideo: 'Ver vídeo',
61
+ moderationFlagged: 'Linguagem ofensiva',
62
+ mediaLoading: 'Carregando...',
63
+ mediaRetry: 'Erro — tentar novamente',
64
+ mediaError: 'Erro',
65
+ mediaUnavailable: 'Mídia indisponível',
66
+ imageAlt: 'Imagem',
67
+ untitledDocument: 'Documento',
68
+ downloadFile: 'Baixar',
69
+ },
70
+ transcription: {
71
+ label: 'Transcrição',
72
+ copy: 'Copiar',
73
+ copied: 'Copiado!',
74
+ transcribe: 'Transcrever áudio',
75
+ transcribing: 'Transcrevendo...',
76
+ retry: 'Transcrever novamente',
77
+ failed: 'Falha ao transcrever — tentar novamente',
78
+ empty: 'Sem fala detectada',
79
+ unsupported: 'Formato de áudio não suportado para transcrição',
80
+ showMore: 'ver transcrição completa',
81
+ showLess: 'ver menos',
39
82
  },
40
83
  selection: {
41
84
  select: 'Selecionar',
@@ -60,6 +103,7 @@ export interface ConversationLocalesProviderProps {
60
103
  export function ConversationLocalesProvider({ children, locales }: ConversationLocalesProviderProps) {
61
104
  const merged: ConversationLocales = {
62
105
  bubble: { ...DEFAULT_LOCALES.bubble, ...locales?.bubble },
106
+ transcription: { ...DEFAULT_LOCALES.transcription, ...locales?.transcription },
63
107
  selection: { ...DEFAULT_LOCALES.selection, ...locales?.selection },
64
108
  dateDivider: { ...DEFAULT_LOCALES.dateDivider, ...locales?.dateDivider },
65
109
  }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Linha da inbox operacional: o `ConversationListItem` cuida do visual padrão (avatar, prévia,
3
+ * hora, não lidas) e aqui em volta ficam as affordances de trabalho — seleção em massa, sinal da
4
+ * janela de 24h, tempo parada e retomada de atendimento.
5
+ *
6
+ * Fica no pacote, e não em cada produto, porque nada disto é regra de um negócio específico: é
7
+ * como se opera uma fila de atendimento no WhatsApp.
8
+ */
9
+
10
+ import { ConversationListItem } from './ConversationListItem'
11
+ import { capabilitiesOf } from './conversationChannel'
12
+ import { cn } from './lib/cn'
13
+ import { ChannelIcon } from './ChannelIcon'
14
+ import type { ConversationSummary } from './providers/types'
15
+ import {
16
+ CONVERSATION_WINDOW,
17
+ formatStalledFor,
18
+ windowOf,
19
+ type ConversationWindow,
20
+ } from './conversationWindow'
21
+
22
+
23
+ const WINDOW_TITLE: Record<ConversationWindow, string> = {
24
+ [CONVERSATION_WINDOW.ALL]: '',
25
+ [CONVERSATION_WINDOW.FRESH]: 'Janela de 24h aberta (menos de 12h)',
26
+ [CONVERSATION_WINDOW.WARNING]: 'Janela de 24h fechando (12-21h)',
27
+ [CONVERSATION_WINDOW.CRITICAL]: 'Janela de 24h quase expirada (21-24h)',
28
+ [CONVERSATION_WINDOW.EXPIRED]: 'Janela de 24h expirada — só template',
29
+ }
30
+
31
+ // Abaixo disto a conversa é recente e o aviso de "parada" só faria ruído.
32
+ const STALLED_THRESHOLD_MS = 60 * 60 * 1000
33
+
34
+ const WINDOW_BAR_CLASS: Record<ConversationWindow, string> = {
35
+ [CONVERSATION_WINDOW.ALL]: 'bg-transparent',
36
+ [CONVERSATION_WINDOW.FRESH]: 'bg-green-500',
37
+ [CONVERSATION_WINDOW.WARNING]: 'bg-yellow-500',
38
+ [CONVERSATION_WINDOW.CRITICAL]: 'bg-red-500',
39
+ [CONVERSATION_WINDOW.EXPIRED]: 'bg-gray-300',
40
+ }
41
+
42
+ export type ConversationRowClassNames = {
43
+ root: string
44
+ windowBar: string
45
+ }
46
+
47
+ export type ConversationRowProps = {
48
+ conversation: ConversationSummary
49
+ active: boolean
50
+ selected: boolean
51
+ now: number
52
+ busy: boolean
53
+ onOpen: () => void
54
+ onToggleSelected: () => void
55
+ onTakeover: () => void
56
+ className?: string
57
+ classNames?: Partial<ConversationRowClassNames>
58
+ }
59
+
60
+ export function ConversationRow({
61
+ conversation,
62
+ active,
63
+ selected,
64
+ now,
65
+ busy,
66
+ onOpen,
67
+ onToggleSelected,
68
+ onTakeover,
69
+ className,
70
+ classNames,
71
+ }: ConversationRowProps) {
72
+ const capabilities = capabilitiesOf(conversation.channel)
73
+ const window = windowOf({ lastInboundAt: conversation.lastInboundAt, now, channel: conversation.channel })
74
+ const stalledMs = now - new Date(conversation.lastAt).getTime()
75
+ // "Parada" é sobre a conversa estar sem resposta há tempo, independente de quem conduz. Amarrar
76
+ // ao `waitingHuman` escondia justamente o caso ruim: conversa assumida e esquecida.
77
+ const isStalled = stalledMs > STALLED_THRESHOLD_MS
78
+ const isWaiting = conversation.mode === 'bot' && conversation.waitingHuman
79
+
80
+ // Realce e hover ficam na linha inteira: com eles no item, só o bloco dele ficava cinza enquanto
81
+ // checkbox, barra lateral e pills continuavam brancos — parecia meia linha selecionada.
82
+ return (
83
+ <div className={cn('cv-row flex border-b', active && 'cv-row--active', classNames?.root, className)}>
84
+ {/* Barra lateral com a cor da janela: informa sem competir por espaço com o conteúdo, e não
85
+ duplica as bolinhas que o próprio ConversationListItem já desenha. */}
86
+ <span
87
+ className={cn('w-1 shrink-0', WINDOW_BAR_CLASS[window], classNames?.windowBar)}
88
+ title={WINDOW_TITLE[window]}
89
+ aria-label={WINDOW_TITLE[window]}
90
+ />
91
+
92
+ <div className="min-w-0 flex-1">
93
+ <div className="flex items-start gap-2 px-2 pt-2">
94
+ <input
95
+ type="checkbox"
96
+ checked={selected}
97
+ onChange={onToggleSelected}
98
+ aria-label={`Selecionar conversa ${conversation.whatsappNumber}`}
99
+ className="mt-3"
100
+ />
101
+ <div className="min-w-0 flex-1">
102
+ {/* Sem divisória: a borda do item cortaria esta linha ao meio, separando-o do rodapé
103
+ de status que pertence à mesma conversa. */}
104
+ <ConversationListItem
105
+ conversation={conversation}
106
+ active={active}
107
+ onClick={onOpen}
108
+ showDivider={false}
109
+ highlightActive={false}
110
+ />
111
+ </div>
112
+ </div>
113
+
114
+ <div className="flex flex-wrap items-center gap-1.5 px-4 pb-2 pl-8">
115
+ {/* Canal na linha: numa inbox multicanal, saber de onde a conversa veio muda o que o
116
+ atendente pode fazer nela. */}
117
+ <span className="cv-pill">
118
+ <ChannelIcon channel={conversation.channel} /> {capabilities.label}
119
+ </span>
120
+ {conversation.mode === 'human' ? (
121
+ <span className="cv-pill cv-pill--success">🧑‍💼 em atendimento</span>
122
+ ) : null}
123
+ {isWaiting ? <span className="cv-pill cv-pill--warning">⏳ aguardando atendimento</span> : null}
124
+ {!isWaiting && conversation.mode === 'bot' ? <span className="cv-pill">🤖 bot ativo</span> : null}
125
+ {isStalled ? (
126
+ <span className="cv-pill cv-pill--danger">⏱️ parada há {formatStalledFor(conversation.lastAt, now)}</span>
127
+ ) : null}
128
+ {isWaiting ? (
129
+ <button type="button" onClick={onTakeover} disabled={busy} className="cv-header-action">
130
+ ▶️ Continuar Atendimento
131
+ </button>
132
+ ) : null}
133
+ </div>
134
+ </div>
135
+ </div>
136
+ )
137
+ }
@@ -1,18 +1,31 @@
1
1
  import { useConversationLocales } from './ConversationLocalesProvider'
2
2
  import { isSameDay } from './lib/format'
3
+ import { cn } from './lib/cn'
4
+
5
+ export interface DateDividerClassNames {
6
+ root: string
7
+ label: string
8
+ }
3
9
 
4
10
  export interface DateDividerProps {
5
11
  iso: string
12
+ className?: string
13
+ classNames?: Partial<DateDividerClassNames>
6
14
  }
7
15
 
8
16
  // Paridade com financiamento-imobiliario-bot/apps/web/src/components/DateDivider.tsx —
9
17
  // sticky no topo do scroll, "Hoje"/"Ontem" localizados, senão data completa pt-BR.
10
- export function DateDivider({ iso }: DateDividerProps) {
18
+ export function DateDivider({ iso, className, classNames }: DateDividerProps) {
11
19
  const { dateDivider } = useConversationLocales()
12
20
 
13
21
  return (
14
- <div className="flex justify-center sticky top-0 z-10 my-2 pointer-events-none">
15
- <span className="bg-white/95 dark:bg-gray-800/95 text-gray-600 dark:text-gray-300 text-xs font-medium px-3 py-1 rounded-lg shadow-sm">
22
+ <div className={cn('flex justify-center sticky top-0 z-10 my-2 pointer-events-none', classNames?.root, className)}>
23
+ <span
24
+ className={cn(
25
+ 'bg-white/95 dark:bg-gray-800/95 text-gray-600 dark:text-gray-300 text-xs font-medium px-3 py-1 rounded-lg shadow-sm',
26
+ classNames?.label,
27
+ )}
28
+ >
16
29
  {formatDividerLabel(iso, dateDivider)}
17
30
  </span>
18
31
  </div>
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Biblioteca de arquivos de TODAS as conversas — a tela de Documentos do painel, fora do
3
+ * atendimento.
4
+ *
5
+ * Distinta do `ConversationDocumentsPanel`: aquele parte de uma conversa aberta e vive dentro dela;
6
+ * esta varre a empresa e por isso mostra de qual conversa cada arquivo veio, com o número
7
+ * clicável. Sem essa referência, uma lista global de anexos não responde nenhuma pergunta.
8
+ */
9
+
10
+ import { useEffect, useState } from 'react'
11
+ import { ArrowUpDown, Bot, Download, Eye, MessageSquare, Users } from 'lucide-react'
12
+ import { useConversations } from './providers/ConversationsProvider'
13
+ import { DOCUMENT_SOURCE_FILTER, type DocumentSourceFilter } from './ConversationDocumentsPanel'
14
+ import { FileIcon } from './FileIcon'
15
+ import { cn } from './lib/cn'
16
+ import { formatDateTime, formatFileSize } from './lib/format'
17
+ import { formatPhone } from './lib/phone'
18
+ import type { CompanyDocument } from './providers/types'
19
+
20
+ export interface DocumentsLibraryLabels {
21
+ title: string
22
+ searchPlaceholder: string
23
+ empty: string
24
+ noResults: string
25
+ loading: string
26
+ failure: string
27
+ view: string
28
+ download: string
29
+ openConversation: string
30
+ sourceFilterAll: string
31
+ sourceFilterCustomer: string
32
+ sourceFilterTeam: string
33
+ sortMostRecent: string
34
+ sortOldest: string
35
+ clearFilters: string
36
+ total: (count: number) => string
37
+ page: (current: number, last: number) => string
38
+ }
39
+
40
+ export const DEFAULT_DOCUMENTS_LIBRARY_LABELS: DocumentsLibraryLabels = {
41
+ title: 'Documentos',
42
+ searchPlaceholder: 'Buscar por nome do arquivo ou telefone',
43
+ empty: 'Nenhum arquivo trocado ainda.',
44
+ noResults: 'Nenhum arquivo encontrado para os filtros aplicados',
45
+ loading: 'Carregando arquivos…',
46
+ failure: 'Não foi possível carregar os arquivos.',
47
+ view: 'Visualizar',
48
+ download: 'Baixar',
49
+ openConversation: 'Abrir conversa',
50
+ sourceFilterAll: 'Todas as origens',
51
+ sourceFilterCustomer: 'Cliente',
52
+ sourceFilterTeam: 'Equipe',
53
+ sortMostRecent: 'Mais recentes',
54
+ sortOldest: 'Mais antigos',
55
+ clearFilters: 'Limpar filtros',
56
+ total: (count: number) => `${count} arquivo${count === 1 ? '' : 's'}`,
57
+ page: (current: number, last: number) => `${current} / ${last}`,
58
+ }
59
+
60
+ export interface DocumentsLibraryClassNames {
61
+ root: string
62
+ title: string
63
+ filters: string
64
+ search: string
65
+ sourceSelect: string
66
+ sortButton: string
67
+ clearButton: string
68
+ status: string
69
+ list: string
70
+ item: string
71
+ conversationLink: string
72
+ filename: string
73
+ meta: string
74
+ pagination: string
75
+ }
76
+
77
+ export interface DocumentsLibraryProps {
78
+ /** Itens por página. O total vem do servidor. */
79
+ perPage?: number
80
+ /** Abrir a conversa de origem. Ausente, o número aparece como texto e não como link. */
81
+ onOpenConversation?: (conversationId: string) => void
82
+ labels?: Partial<DocumentsLibraryLabels>
83
+ className?: string
84
+ classNames?: Partial<DocumentsLibraryClassNames>
85
+ }
86
+
87
+ const TEAM_SOURCES = new Set(['agent', 'bot'])
88
+ const DEFAULT_PER_PAGE = 20
89
+
90
+ export function DocumentsLibrary({
91
+ perPage = DEFAULT_PER_PAGE,
92
+ onOpenConversation,
93
+ labels: labelsOverride,
94
+ className,
95
+ classNames,
96
+ }: DocumentsLibraryProps) {
97
+ const labels = { ...DEFAULT_DOCUMENTS_LIBRARY_LABELS, ...labelsOverride }
98
+ const context = useConversations()
99
+ const [search, setSearch] = useState('')
100
+ const [sourceFilter, setSourceFilter] = useState<DocumentSourceFilter>(DOCUMENT_SOURCE_FILTER.ALL)
101
+ const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc')
102
+ const [page, setPage] = useState(1)
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
+
108
+ const hasFilters = search !== '' || sourceFilter !== DOCUMENT_SOURCE_FILTER.ALL || sortDirection !== 'desc'
109
+ const lastPage = Math.max(1, Math.ceil(total / perPage))
110
+ const fetchAll = context?.api.getAllDocuments
111
+
112
+ useEffect(() => {
113
+ if (!fetchAll) return
114
+ let active = true
115
+ setLoading(true)
116
+ setFailed(false)
117
+
118
+ void fetchAll({
119
+ search,
120
+ page,
121
+ limit: perPage,
122
+ sortDirection,
123
+ ...(sourceFilter === DOCUMENT_SOURCE_FILTER.ALL ? {} : { source: sourceFilter }),
124
+ })
125
+ .then((result) => {
126
+ // `active` evita que uma resposta antiga sobrescreva a nova: digitar rápido na busca dispara
127
+ // várias chamadas e a ordem de retorno não é garantida.
128
+ if (!active) return
129
+ setDocuments(result.documents)
130
+ setTotal(result.total)
131
+ })
132
+ .catch(() => {
133
+ if (active) setFailed(true)
134
+ })
135
+ .finally(() => {
136
+ if (active) setLoading(false)
137
+ })
138
+
139
+ return () => {
140
+ active = false
141
+ }
142
+ }, [fetchAll, search, sourceFilter, sortDirection, page, perPage])
143
+
144
+ function applyFilter(change: () => void): void {
145
+ change()
146
+ setPage(1)
147
+ }
148
+
149
+ async function handleOpen(uploadId: string, disposition: 'inline' | 'attachment'): Promise<void> {
150
+ const url = await context?.api.getDocumentUrl(uploadId, disposition)
151
+ if (url) window.open(url, '_blank', 'noopener,noreferrer')
152
+ }
153
+
154
+ // Host sem `getAllDocuments` não tem o que mostrar aqui — some, em vez de renderizar vazio para
155
+ // sempre e fazer parecer que a empresa não tem arquivo nenhum.
156
+ if (!fetchAll) return null
157
+
158
+ return (
159
+ <div className={cn('space-y-3', classNames?.root, className)}>
160
+ <h2 className={cn('text-lg font-semibold', classNames?.title)}>{labels.title}</h2>
161
+
162
+ <div className={cn('flex flex-wrap items-center gap-2', classNames?.filters)}>
163
+ <input
164
+ type="search"
165
+ value={search}
166
+ onChange={(event) => applyFilter(() => setSearch(event.target.value))}
167
+ placeholder={labels.searchPlaceholder}
168
+ aria-label={labels.searchPlaceholder}
169
+ className={cn('w-full rounded-md border px-3 py-2 text-sm sm:w-64', classNames?.search)}
170
+ />
171
+
172
+ <select
173
+ value={sourceFilter}
174
+ onChange={(event) => applyFilter(() => setSourceFilter(event.target.value as DocumentSourceFilter))}
175
+ aria-label={labels.sourceFilterAll}
176
+ className={cn('w-full rounded-md border px-2 py-2 text-sm sm:w-40', classNames?.sourceSelect)}
177
+ >
178
+ <option value={DOCUMENT_SOURCE_FILTER.ALL}>{labels.sourceFilterAll}</option>
179
+ <option value={DOCUMENT_SOURCE_FILTER.CUSTOMER}>{labels.sourceFilterCustomer}</option>
180
+ <option value={DOCUMENT_SOURCE_FILTER.TEAM}>{labels.sourceFilterTeam}</option>
181
+ </select>
182
+
183
+ <button
184
+ type="button"
185
+ onClick={() => applyFilter(() => setSortDirection(sortDirection === 'desc' ? 'asc' : 'desc'))}
186
+ className={cn('cv-header-action inline-flex items-center gap-1', classNames?.sortButton)}
187
+ >
188
+ <ArrowUpDown size={14} />
189
+ {sortDirection === 'desc' ? labels.sortMostRecent : labels.sortOldest}
190
+ </button>
191
+
192
+ {hasFilters ? (
193
+ <button
194
+ type="button"
195
+ onClick={() =>
196
+ applyFilter(() => {
197
+ setSearch('')
198
+ setSourceFilter(DOCUMENT_SOURCE_FILTER.ALL)
199
+ setSortDirection('desc')
200
+ })
201
+ }
202
+ className={cn('cv-header-action', classNames?.clearButton)}
203
+ >
204
+ {labels.clearFilters}
205
+ </button>
206
+ ) : null}
207
+ </div>
208
+
209
+ {loading ? <p className={cn('text-sm text-gray-500', classNames?.status)}>{labels.loading}</p> : null}
210
+ {failed ? (
211
+ <p role="alert" className={cn('text-sm text-red-600 dark:text-red-400', classNames?.status)}>
212
+ {labels.failure}
213
+ </p>
214
+ ) : null}
215
+ {!loading && !failed && documents.length === 0 ? (
216
+ <p className={cn('text-sm text-gray-500', classNames?.status)}>
217
+ {hasFilters ? labels.noResults : labels.empty}
218
+ </p>
219
+ ) : null}
220
+
221
+ <ul className={cn('space-y-2', classNames?.list)}>
222
+ {documents.map((document) => {
223
+ const isFromCustomer = !TEAM_SOURCES.has(document.source)
224
+ const SourceIcon = isFromCustomer ? Users : Bot
225
+
226
+ return (
227
+ <li
228
+ key={`${document.conversationId}:${document.id}`}
229
+ className={cn(
230
+ 'flex items-center justify-between gap-3 rounded-lg border px-3 py-2 dark:border-gray-700',
231
+ classNames?.item,
232
+ )}
233
+ >
234
+ <div className="flex min-w-0 flex-1 items-center gap-3">
235
+ <FileIcon filename={document.filename} mimeType={document.mimeType} />
236
+ <div className="min-w-0 flex-1">
237
+ <div className={cn('truncate text-sm font-medium', classNames?.filename)} title={document.filename}>
238
+ {document.filename}
239
+ </div>
240
+ <div className={cn('flex flex-wrap items-center gap-x-2 text-xs text-gray-500', classNames?.meta)}>
241
+ <span className="inline-flex items-center gap-1">
242
+ <SourceIcon size={11} />
243
+ {isFromCustomer ? labels.sourceFilterCustomer : labels.sourceFilterTeam}
244
+ </span>
245
+ <span>·</span>
246
+ <span>{formatDateTime(document.linkedAt)}</span>
247
+ <span>·</span>
248
+ <span>{formatFileSize(document.sizeBytes)}</span>
249
+ </div>
250
+ </div>
251
+ </div>
252
+
253
+ {/* A conversa de origem é o dado que só esta tela tem. Vira botão quando o host sabe
254
+ navegar; sem handler, fica texto — link que não leva a lugar nenhum é pior. */}
255
+ {onOpenConversation ? (
256
+ <button
257
+ type="button"
258
+ onClick={() => onOpenConversation(document.conversationId)}
259
+ title={labels.openConversation}
260
+ className={cn('cv-header-action inline-flex shrink-0 items-center gap-1', classNames?.conversationLink)}
261
+ >
262
+ <MessageSquare size={12} />
263
+ {formatPhone(document.conversationId)}
264
+ </button>
265
+ ) : (
266
+ <span className={cn('shrink-0 text-xs text-gray-500', classNames?.conversationLink)}>
267
+ {formatPhone(document.conversationId)}
268
+ </span>
269
+ )}
270
+
271
+ <div className="flex shrink-0 gap-1">
272
+ <button
273
+ type="button"
274
+ onClick={() => void handleOpen(document.id, 'inline')}
275
+ title={labels.view}
276
+ aria-label={`${labels.view}: ${document.filename}`}
277
+ className="cv-header-icon"
278
+ >
279
+ <Eye size={14} />
280
+ </button>
281
+ <button
282
+ type="button"
283
+ onClick={() => void handleOpen(document.id, 'attachment')}
284
+ title={labels.download}
285
+ aria-label={`${labels.download}: ${document.filename}`}
286
+ className="cv-header-icon"
287
+ >
288
+ <Download size={14} />
289
+ </button>
290
+ </div>
291
+ </li>
292
+ )
293
+ })}
294
+ </ul>
295
+
296
+ {total > perPage ? (
297
+ <div className={cn('flex items-center justify-between border-t pt-2 text-xs dark:border-gray-700', classNames?.pagination)}>
298
+ <span className="text-gray-400">{labels.total(total)}</span>
299
+ <div className="flex items-center gap-2">
300
+ <button
301
+ type="button"
302
+ onClick={() => setPage(page - 1)}
303
+ disabled={page <= 1}
304
+ className="cv-header-icon disabled:opacity-40"
305
+ >
306
+
307
+ </button>
308
+ <span className="text-gray-500">{labels.page(page, lastPage)}</span>
309
+ <button
310
+ type="button"
311
+ onClick={() => setPage(page + 1)}
312
+ disabled={page >= lastPage}
313
+ className="cv-header-icon disabled:opacity-40"
314
+ >
315
+
316
+ </button>
317
+ </div>
318
+ </div>
319
+ ) : null}
320
+ </div>
321
+ )
322
+ }