+page.svelte 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. <script>
  2. import { onMount } from 'svelte';
  3. import { Settings, Phone, Bell, HelpCircle, ShieldCheck, LoaderCircle, RefreshCw, ExternalLink } from 'lucide-svelte';
  4. import SlaConfigManager from '$lib/features/sla/SlaConfigManager.svelte';
  5. import {
  6. listWhatsappAccounts,
  7. createWhatsappHostedLink,
  8. reconnectWhatsappAccount
  9. } from '$lib/features/integrations/unipile/api.js';
  10. // Settings sections
  11. const staticSettingsSections = [
  12. {
  13. title: 'Notificações',
  14. icon: Bell,
  15. items: [
  16. {
  17. title: 'Alertas de Sentimento',
  18. description: 'Receba alertas sobre mudanças no sentimento dos clientes',
  19. status: 'enabled',
  20. action: 'configurar'
  21. },
  22. {
  23. title: 'Relatórios Diários',
  24. description: 'Receba relatórios automáticos por e-mail',
  25. status: 'disabled',
  26. action: 'ativar'
  27. }
  28. ]
  29. },
  30. {
  31. title: 'Suporte',
  32. icon: HelpCircle,
  33. items: [
  34. {
  35. title: 'Central de Ajuda',
  36. description: 'Acesse nossa documentação e tutoriais',
  37. status: 'available',
  38. action: 'acessar'
  39. },
  40. {
  41. title: 'Contato',
  42. description: 'Fale com nossa equipe de suporte',
  43. status: 'available',
  44. action: 'abrir chamado'
  45. }
  46. ]
  47. }
  48. ];
  49. const MAX_WHATSAPP_POLL_ATTEMPTS = 12;
  50. let whatsappAccounts = $state([]);
  51. let whatsappLoading = $state(true);
  52. let whatsappLoadError = $state('');
  53. let whatsappActionError = $state('');
  54. let whatsappOpening = $state(false);
  55. let whatsappRefreshing = $state(false);
  56. let whatsappPolling = $state(false);
  57. let lastHostedAuthUrl = $state('');
  58. let whatsappPollInterval = $state(null);
  59. let whatsappPollTimeout = $state(null);
  60. let whatsappPollAttempts = $state(0);
  61. const primaryWhatsappAccount = $derived(
  62. whatsappAccounts.find((account) => account?.isConnected) ?? whatsappAccounts[0] ?? null
  63. );
  64. const settingsSections = $derived([
  65. {
  66. title: 'Integrações',
  67. icon: Phone,
  68. // "WhatsApp dos Vendedores" (suporte multiconta: um número por vendedor) ainda não
  69. // foi implementado — o botão "configurar" não fazia nada e o "3 de 5" era texto fixo.
  70. // Comentado até o cliente decidir aderir. Para reativar: adicione o objeto abaixo de
  71. // volta ao array `items` e implemente o ramo 'whatsapp-sellers' em handleAction().
  72. // {
  73. // key: 'whatsapp-sellers',
  74. // title: 'WhatsApp dos Vendedores',
  75. // description: 'Adicione os números de WhatsApp da equipe de vendas',
  76. // status: 'partial',
  77. // action: 'configurar',
  78. // count: '3 de 5 configurados'
  79. // }
  80. items: [buildWhatsappItem()]
  81. },
  82. ...staticSettingsSections
  83. ]);
  84. function handleAction(item) {
  85. // Handle different actions based on item
  86. if (item.key === 'whatsapp-business') {
  87. void handleWhatsappPrimaryAction();
  88. return;
  89. }
  90. // For WhatsApp integration, we could open a modal or navigate to a specific page
  91. if (item.key === 'whatsapp-business') {
  92. // TODO: Open WhatsApp configuration modal
  93. } else if (item.key === 'whatsapp-sellers') {
  94. // TODO: Open sellers WhatsApp configuration
  95. }
  96. }
  97. function handleSecondaryAction(item) {
  98. if (item.key === 'whatsapp-business') {
  99. void refreshWhatsappAccounts();
  100. }
  101. }
  102. function getWhatsappStatus() {
  103. if (whatsappLoading) return 'loading';
  104. if (whatsappOpening || whatsappPolling) return 'connecting';
  105. if (whatsappLoadError || whatsappActionError) return 'error';
  106. if (primaryWhatsappAccount?.isConnected) return 'connected';
  107. if (primaryWhatsappAccount) return 'partial';
  108. return 'disabled';
  109. }
  110. function buildWhatsappItem() {
  111. const connectedAccounts = whatsappAccounts.filter((account) => account?.isConnected);
  112. const status = getWhatsappStatus();
  113. const hasAccount = primaryWhatsappAccount !== null;
  114. const action = hasAccount ? 'reconectar' : 'conectar';
  115. const description = whatsappLoading
  116. ? 'Consultando o status da integração WhatsApp.'
  117. : primaryWhatsappAccount?.isConnected
  118. ? 'Sua conta do operador está conectada e pronta para receber e enviar mensagens da empresa pelo backend.'
  119. : hasAccount
  120. ? 'A conta do seu operador existe, mas ainda não está conectada. Gere um novo link para concluir ou repetir o Hosted Auth.'
  121. : 'Nenhuma conta WhatsApp foi conectada para este operador ainda. Gere um link, conclua o Hosted Auth e volte para atualizar o status.';
  122. const count = whatsappLoading
  123. ? ''
  124. : connectedAccounts.length > 0
  125. ? `${connectedAccounts.length} conta${connectedAccounts.length > 1 ? 's' : ''} conectada${connectedAccounts.length > 1 ? 's' : ''} para este operador`
  126. : whatsappAccounts.length > 0
  127. ? `${whatsappAccounts.length} conta${whatsappAccounts.length > 1 ? 's' : ''} cadastrada${whatsappAccounts.length > 1 ? 's' : ''} para este operador`
  128. : '';
  129. const meta = primaryWhatsappAccount
  130. ? [
  131. primaryWhatsappAccount.accountName ? `Conta: ${primaryWhatsappAccount.accountName}` : '',
  132. primaryWhatsappAccount.accountId ? `Account ID: ${primaryWhatsappAccount.accountId}` : '',
  133. primaryWhatsappAccount.status ? `Status: ${primaryWhatsappAccount.status}` : '',
  134. primaryWhatsappAccount.lastSyncAt ? `Última sincronização: ${formatDateTime(primaryWhatsappAccount.lastSyncAt)}` : ''
  135. ]
  136. .filter(Boolean)
  137. .join(' • ')
  138. : '';
  139. const helper = whatsappOpening || whatsappPolling
  140. ? 'Complete a autenticação na janela do Unipile. Esta tela está verificando automaticamente se a conta do seu operador entrou no backend.'
  141. : lastHostedAuthUrl !== ''
  142. ? 'Se você fechou a janela do Unipile, use “abrir link” para retomar o Hosted Auth ou “atualizar” para consultar o backend. As mensagens continuarão chegando vinculadas à empresa.'
  143. : 'Depois de concluir o Hosted Auth, use “atualizar” para confirmar se o callback registrou a integração do seu operador. As mensagens continuarão chegando vinculadas à empresa.';
  144. return {
  145. key: 'whatsapp-business',
  146. title: 'WhatsApp Business',
  147. description,
  148. status,
  149. action: whatsappOpening ? 'abrindo...' : action,
  150. secondaryAction: whatsappRefreshing ? 'atualizando...' : 'atualizar',
  151. tertiaryAction: lastHostedAuthUrl !== '' ? 'abrir link' : '',
  152. count,
  153. meta,
  154. error: whatsappLoadError || whatsappActionError || primaryWhatsappAccount?.lastError || '',
  155. helper,
  156. disablePrimary: whatsappLoading || whatsappOpening,
  157. disableSecondary: whatsappLoading || whatsappRefreshing,
  158. disableTertiary: whatsappOpening
  159. };
  160. }
  161. async function loadWhatsappAccounts({ silent = false } = {}) {
  162. if (!silent) {
  163. whatsappLoading = true;
  164. whatsappLoadError = '';
  165. }
  166. try {
  167. const accounts = await listWhatsappAccounts();
  168. whatsappAccounts = accounts;
  169. if (accounts.some((account) => account?.isConnected)) {
  170. stopWhatsappPolling();
  171. }
  172. return accounts;
  173. } catch (err) {
  174. const message = err?.message ?? 'Falha ao carregar as integrações do WhatsApp.';
  175. if (silent) {
  176. whatsappActionError = message;
  177. return whatsappAccounts;
  178. }
  179. whatsappLoadError = message;
  180. whatsappAccounts = [];
  181. return [];
  182. } finally {
  183. if (!silent) {
  184. whatsappLoading = false;
  185. }
  186. }
  187. }
  188. async function refreshWhatsappAccounts() {
  189. if (whatsappRefreshing) {
  190. return whatsappAccounts;
  191. }
  192. whatsappRefreshing = true;
  193. whatsappActionError = '';
  194. try {
  195. return await loadWhatsappAccounts({ silent: true });
  196. } finally {
  197. whatsappRefreshing = false;
  198. }
  199. }
  200. function stopWhatsappPolling() {
  201. if (whatsappPollTimeout !== null) {
  202. window.clearTimeout(whatsappPollTimeout);
  203. whatsappPollTimeout = null;
  204. }
  205. if (whatsappPollInterval !== null) {
  206. window.clearInterval(whatsappPollInterval);
  207. whatsappPollInterval = null;
  208. }
  209. whatsappPolling = false;
  210. whatsappPollAttempts = 0;
  211. }
  212. function startWhatsappPolling() {
  213. stopWhatsappPolling();
  214. whatsappPolling = true;
  215. whatsappPollAttempts = 0;
  216. const poll = async () => {
  217. whatsappPollAttempts += 1;
  218. const accounts = await refreshWhatsappAccounts();
  219. if (accounts.some((account) => account?.isConnected) || whatsappPollAttempts >= MAX_WHATSAPP_POLL_ATTEMPTS) {
  220. stopWhatsappPolling();
  221. }
  222. };
  223. whatsappPollTimeout = window.setTimeout(() => {
  224. void poll();
  225. }, 2000);
  226. whatsappPollInterval = window.setInterval(() => {
  227. void poll();
  228. }, 5000);
  229. }
  230. async function handleWhatsappPrimaryAction() {
  231. if (whatsappOpening) return;
  232. whatsappActionError = '';
  233. whatsappOpening = true;
  234. let popup = null;
  235. try {
  236. popup = window.open('', '_blank');
  237. const url = primaryWhatsappAccount?.id
  238. ? await reconnectWhatsappAccount(primaryWhatsappAccount.id)
  239. : await createWhatsappHostedLink();
  240. if (!url) {
  241. throw new Error('A API não retornou a URL de conexão do WhatsApp.');
  242. }
  243. lastHostedAuthUrl = url;
  244. if (popup && !popup.closed) {
  245. popup.location.replace(url);
  246. } else {
  247. const opened = window.open(url, '_blank');
  248. if (!opened) {
  249. throw new Error('Não foi possível abrir a janela do Unipile. Verifique o bloqueador de pop-up.');
  250. }
  251. }
  252. startWhatsappPolling();
  253. await refreshWhatsappAccounts();
  254. } catch (err) {
  255. if (popup && !popup.closed) {
  256. popup.close();
  257. }
  258. whatsappActionError = err?.message ?? 'Falha ao iniciar a conexão do WhatsApp.';
  259. } finally {
  260. whatsappOpening = false;
  261. }
  262. }
  263. function openLastHostedAuthLink() {
  264. if (!lastHostedAuthUrl) return;
  265. window.open(lastHostedAuthUrl, '_blank');
  266. }
  267. function formatDateTime(value) {
  268. if (!value) return '';
  269. const parsed = new Date(value);
  270. if (Number.isNaN(parsed.getTime())) {
  271. return value;
  272. }
  273. return new Intl.DateTimeFormat('pt-BR', {
  274. dateStyle: 'short',
  275. timeStyle: 'short'
  276. }).format(parsed);
  277. }
  278. onMount(() => {
  279. void loadWhatsappAccounts();
  280. const onFocus = () => {
  281. if (lastHostedAuthUrl !== '' || whatsappPolling) {
  282. void refreshWhatsappAccounts();
  283. }
  284. };
  285. window.addEventListener('focus', onFocus);
  286. return () => {
  287. window.removeEventListener('focus', onFocus);
  288. stopWhatsappPolling();
  289. };
  290. });
  291. function getStatusBadge(status) {
  292. const statusConfig = {
  293. loading: { text: 'Carregando', class: 'bg-slate-100 text-slate-800 dark:bg-slate-900/30 dark:text-slate-400' },
  294. connecting: { text: 'Conectando', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' },
  295. connected: { text: 'Conectado', class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' },
  296. partial: { text: 'Parcial', class: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400' },
  297. error: { text: 'Erro', class: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' },
  298. enabled: { text: 'Ativo', class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' },
  299. disabled: { text: 'Inativo', class: 'bg-slate-100 text-slate-800 dark:bg-slate-900/30 dark:text-slate-400' },
  300. configured: { text: 'Configurado', class: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' },
  301. available: { text: 'Disponível', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' },
  302. dark: { text: 'Escuro', class: 'bg-slate-100 text-slate-800 dark:bg-slate-900/30 dark:text-slate-400' },
  303. 'pt-BR': { text: 'Português', class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400' }
  304. };
  305. return statusConfig[status] || statusConfig.available;
  306. }
  307. </script>
  308. <div class="mx-auto max-w-[1600px] space-y-6">
  309. <!-- Header -->
  310. <div class="rounded-xl border border-slate-200 bg-white p-5 md:p-6 shadow-sm transition-colors duration-200 dark:border-slate-800 dark:bg-[#161f30]">
  311. <div class="flex items-center gap-3">
  312. <div class="h-8 w-8 shrink-0 rounded-lg bg-indigo-500/20 text-indigo-500 flex items-center justify-center">
  313. <Settings size={20} strokeWidth={2.5} />
  314. </div>
  315. <h1 class="text-xl font-bold text-slate-900 dark:text-white">Configurações</h1>
  316. </div>
  317. <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
  318. Gerencie as configurações do sistema, integrações e preferências da sua conta
  319. </p>
  320. </div>
  321. <!-- Settings Sections -->
  322. <div class="grid grid-cols-1 gap-6">
  323. {#each settingsSections as section}
  324. <div class="rounded-xl border border-slate-200 bg-white shadow-sm transition-colors duration-200 dark:border-slate-800 dark:bg-[#161f30]">
  325. <!-- Section Header -->
  326. <div class="border-b border-slate-200 p-5 dark:border-slate-800">
  327. <div class="flex items-center gap-3">
  328. <div class="h-8 w-8 shrink-0 rounded-lg bg-slate-100 text-slate-600 flex items-center justify-center dark:bg-slate-800 dark:text-slate-400">
  329. <section.icon size={18} strokeWidth={2} />
  330. </div>
  331. <h2 class="text-lg font-semibold text-slate-900 dark:text-white">{section.title}</h2>
  332. </div>
  333. </div>
  334. <!-- Section Items -->
  335. <div class="divide-y divide-slate-200 dark:divide-slate-800">
  336. {#each section.items as item}
  337. <div class="p-5 hover:bg-slate-50 transition-colors duration-150 dark:hover:bg-slate-800/50">
  338. <div class="flex items-start justify-between gap-4">
  339. <div class="flex-1 min-w-0">
  340. <div class="flex items-center gap-3">
  341. <h3 class="text-sm font-medium text-slate-900 dark:text-white">{item.title}</h3>
  342. <span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium {getStatusBadge(item.status).class}">
  343. {getStatusBadge(item.status).text}
  344. </span>
  345. </div>
  346. <p class="mt-1 text-sm text-slate-600 dark:text-slate-400">{item.description}</p>
  347. {#if item.count}
  348. <p class="mt-1 text-xs text-amber-600 dark:text-amber-400">{item.count}</p>
  349. {/if}
  350. {#if item.meta}
  351. <p class="mt-2 text-xs text-slate-500 dark:text-slate-400">{item.meta}</p>
  352. {/if}
  353. {#if item.error}
  354. <p class="mt-2 text-xs text-red-600 dark:text-red-400">{item.error}</p>
  355. {/if}
  356. {#if item.helper}
  357. <p class="mt-2 text-xs text-slate-500 dark:text-slate-400">{item.helper}</p>
  358. {/if}
  359. </div>
  360. <div class="ml-4 flex shrink-0 flex-col items-end gap-2">
  361. {#if item.secondaryAction}
  362. <button
  363. onclick={() => handleSecondaryAction(item)}
  364. disabled={item.disableSecondary}
  365. class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md border border-slate-300 text-slate-700 hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60 transition-colors duration-150 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
  366. >
  367. {#if item.disableSecondary}
  368. <LoaderCircle size={14} class="animate-spin" />
  369. {:else}
  370. <RefreshCw size={14} />
  371. {/if}
  372. {item.secondaryAction}
  373. </button>
  374. {/if}
  375. <button
  376. onclick={() => handleAction(item)}
  377. disabled={item.disablePrimary}
  378. class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md bg-indigo-600 text-white hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-60 transition-colors duration-150"
  379. >
  380. {#if item.disablePrimary}
  381. <LoaderCircle size={14} class="animate-spin" />
  382. {/if}
  383. {item.action}
  384. </button>
  385. {#if item.tertiaryAction}
  386. <button
  387. onclick={openLastHostedAuthLink}
  388. disabled={item.disableTertiary}
  389. class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md text-indigo-700 hover:bg-indigo-50 disabled:cursor-not-allowed disabled:opacity-60 transition-colors duration-150 dark:text-indigo-300 dark:hover:bg-indigo-500/10"
  390. >
  391. <ExternalLink size={14} />
  392. {item.tertiaryAction}
  393. </button>
  394. {/if}
  395. </div>
  396. </div>
  397. </div>
  398. {/each}
  399. </div>
  400. </div>
  401. {/each}
  402. </div>
  403. <!-- SLA / Tempo de Resposta -->
  404. <div
  405. class="rounded-xl border border-slate-200 bg-white shadow-sm transition-colors duration-200 dark:border-slate-800 dark:bg-[#161f30]"
  406. >
  407. <div class="border-b border-slate-200 p-5 dark:border-slate-800">
  408. <div class="flex items-center gap-3">
  409. <div
  410. class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400"
  411. >
  412. <ShieldCheck size={18} strokeWidth={2} />
  413. </div>
  414. <div>
  415. <h2 class="text-lg font-semibold text-slate-900 dark:text-white">SLA por Departamento</h2>
  416. <p class="text-sm text-slate-500 dark:text-slate-400">
  417. Tempos de primeira resposta e resolução, com status em tempo real
  418. </p>
  419. </div>
  420. </div>
  421. </div>
  422. <div class="p-5">
  423. <SlaConfigManager />
  424. </div>
  425. </div>
  426. </div>