+page.svelte 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. <script>
  2. import {
  3. ShieldCheck,
  4. Clock,
  5. CheckCircle,
  6. AlertTriangle,
  7. XCircle,
  8. Plus,
  9. X
  10. } from 'lucide-svelte';
  11. import { onMount } from 'svelte';
  12. let isLoading = $state(true);
  13. onMount(() => {
  14. setTimeout(() => {
  15. isLoading = false;
  16. }, 600);
  17. });
  18. // ── Department configs ────────────────────────────────────────────────────
  19. let departments = $state([
  20. {
  21. id: 'sac',
  22. name: 'SAC',
  23. firstResponseH: 1,
  24. firstResponseM: 30,
  25. resolutionH: 24,
  26. alertPct: 80,
  27. liveStatus: 'breach',
  28. liveDetail: '14h estourado',
  29. lastUpdated: 'há 8 minutos'
  30. },
  31. {
  32. id: 'vendas',
  33. name: 'Vendas',
  34. firstResponseH: 0,
  35. firstResponseM: 45,
  36. resolutionH: 8,
  37. alertPct: 80,
  38. liveStatus: 'warning',
  39. liveDetail: 'próximo de estourar (18% restante)',
  40. lastUpdated: 'há 3 minutos'
  41. },
  42. {
  43. id: 'suporte',
  44. name: 'Suporte',
  45. firstResponseH: 2,
  46. firstResponseM: 0,
  47. resolutionH: 48,
  48. alertPct: 80,
  49. liveStatus: 'ok',
  50. liveDetail: 'dentro do SLA',
  51. lastUpdated: 'há 1 minuto'
  52. }
  53. ]);
  54. let savedDeptIds = $state([]);
  55. function saveDepartment(deptId, deptName) {
  56. savedDeptIds = [...savedDeptIds, deptId];
  57. setTimeout(() => {
  58. savedDeptIds = savedDeptIds.filter((id) => id !== deptId);
  59. }, 3000);
  60. }
  61. // ── Add department modal ──────────────────────────────────────────────────
  62. let showAddModal = $state(false);
  63. let newDept = $state({ name: '', firstResponseH: 1, firstResponseM: 0, resolutionH: 24, alertPct: 80 });
  64. function addDepartment() {
  65. if (!newDept.name.trim()) return;
  66. departments = [
  67. ...departments,
  68. {
  69. id: newDept.name.toLowerCase().replace(/\s+/g, '-'),
  70. name: newDept.name,
  71. firstResponseH: newDept.firstResponseH,
  72. firstResponseM: newDept.firstResponseM,
  73. resolutionH: newDept.resolutionH,
  74. alertPct: newDept.alertPct,
  75. liveStatus: 'ok',
  76. liveDetail: 'dentro do SLA',
  77. lastUpdated: 'agora'
  78. }
  79. ];
  80. newDept = { name: '', firstResponseH: 1, firstResponseM: 0, resolutionH: 24, alertPct: 80 };
  81. showAddModal = false;
  82. }
  83. // ── Live status helpers ───────────────────────────────────────────────────
  84. function statusBadge(status) {
  85. const map = {
  86. ok: {
  87. label: 'OK',
  88. cls: 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-400/10 dark:text-emerald-400 dark:border-emerald-400/20'
  89. },
  90. warning: {
  91. label: 'ALERTA',
  92. cls: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-400/10 dark:text-amber-400 dark:border-amber-400/20'
  93. },
  94. breach: {
  95. label: 'CRÍTICO',
  96. cls: 'bg-red-50 text-red-700 border-red-200 dark:bg-red-400/10 dark:text-red-400 dark:border-red-400/20'
  97. }
  98. };
  99. return map[status] ?? map['ok'];
  100. }
  101. function statusIndicatorCls(status) {
  102. if (status === 'ok') return 'bg-emerald-500';
  103. if (status === 'warning') return 'bg-amber-500';
  104. return 'bg-red-500';
  105. }
  106. function statusCardBorder(status) {
  107. if (status === 'ok') return 'border-emerald-200 dark:border-emerald-400/20';
  108. if (status === 'warning') return 'border-amber-200 dark:border-amber-400/20';
  109. return 'border-red-200 dark:border-red-400/20';
  110. }
  111. function statusIcon(status) {
  112. if (status === 'ok') return CheckCircle;
  113. if (status === 'warning') return AlertTriangle;
  114. return XCircle;
  115. }
  116. function statusIconCls(status) {
  117. if (status === 'ok') return 'text-emerald-600 dark:text-emerald-400';
  118. if (status === 'warning') return 'text-amber-600 dark:text-amber-400';
  119. return 'text-red-600 dark:text-red-400';
  120. }
  121. const inputCls =
  122. 'rounded-lg border border-slate-300 bg-slate-50 px-3 py-1.5 text-sm text-slate-900 transition-colors focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 focus:outline-none dark:border-slate-700 dark:bg-slate-900 dark:text-slate-200 w-full';
  123. </script>
  124. <svelte:head>
  125. <title>Configuração de SLA - Nettown Analytics</title>
  126. </svelte:head>
  127. <!-- Add Department Modal -->
  128. {#if showAddModal}
  129. <div
  130. class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 p-4"
  131. onclick={(e) => e.target === e.currentTarget && (showAddModal = false)}
  132. onkeydown={(e) => e.key === 'Escape' && (showAddModal = false)}
  133. role="dialog"
  134. aria-modal="true"
  135. tabindex="-1"
  136. >
  137. <div
  138. class="w-full max-w-md rounded-xl border border-slate-200 bg-white shadow-2xl dark:border-slate-700 dark:bg-[#1e293b]"
  139. >
  140. <div
  141. class="flex items-center justify-between border-b border-slate-200 p-5 dark:border-slate-700"
  142. >
  143. <h2 class="text-base font-bold text-slate-900 dark:text-white">Adicionar Departamento</h2>
  144. <button
  145. onclick={() => (showAddModal = false)}
  146. class="rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700 dark:hover:bg-slate-800 dark:hover:text-white"
  147. >
  148. <X size={18} />
  149. </button>
  150. </div>
  151. <div class="space-y-4 p-5">
  152. <div>
  153. <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Nome do departamento</label>
  154. <input type="text" bind:value={newDept.name} placeholder="Ex: Financeiro" class={inputCls} />
  155. </div>
  156. <div class="grid grid-cols-2 gap-4">
  157. <div>
  158. <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Primeira resposta (h)</label>
  159. <input type="number" min="0" max="72" bind:value={newDept.firstResponseH} class={inputCls} />
  160. </div>
  161. <div>
  162. <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Primeira resposta (min)</label>
  163. <input type="number" min="0" max="59" bind:value={newDept.firstResponseM} class={inputCls} />
  164. </div>
  165. </div>
  166. <div>
  167. <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Tempo máx. de resolução (h)</label>
  168. <input type="number" min="1" max="720" bind:value={newDept.resolutionH} class={inputCls} />
  169. </div>
  170. <div>
  171. <label class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Alerta de estouro em (% do tempo)</label>
  172. <input type="number" min="1" max="99" bind:value={newDept.alertPct} class={inputCls} />
  173. </div>
  174. </div>
  175. <div class="flex justify-end gap-3 border-t border-slate-200 p-5 dark:border-slate-700">
  176. <button
  177. onclick={() => (showAddModal = false)}
  178. class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
  179. >
  180. Cancelar
  181. </button>
  182. <button
  183. onclick={addDepartment}
  184. disabled={!newDept.name.trim()}
  185. class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-indigo-700 disabled:opacity-50 dark:bg-indigo-500 dark:hover:bg-indigo-600"
  186. >
  187. Adicionar
  188. </button>
  189. </div>
  190. </div>
  191. </div>
  192. {/if}
  193. <div class="mx-auto max-w-[1600px] space-y-6">
  194. <!-- Page header -->
  195. <div
  196. class="rounded-xl border border-slate-200 bg-white p-5 shadow-sm transition-colors duration-200 dark:border-slate-800 dark:bg-[#1e293b]"
  197. >
  198. <div class="flex items-center gap-3">
  199. <div
  200. class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-indigo-500/20 text-indigo-500"
  201. >
  202. <ShieldCheck size={20} strokeWidth={2.5} />
  203. </div>
  204. <h1 class="text-xl font-bold text-slate-900 dark:text-white">Configuração de SLA</h1>
  205. </div>
  206. <p class="mt-2 text-sm text-slate-600 dark:text-slate-400">
  207. Defina os tempos de atendimento por departamento. O sistema alertará automaticamente quando os
  208. limites estiverem próximos de estourar.
  209. </p>
  210. </div>
  211. <!-- Department cards -->
  212. {#if isLoading}
  213. <div class="space-y-4">
  214. {#each [1, 2, 3] as _}
  215. <div
  216. class="h-56 animate-pulse rounded-xl border border-slate-200 bg-slate-100 dark:border-slate-800 dark:bg-slate-800"
  217. ></div>
  218. {/each}
  219. </div>
  220. {:else}
  221. <div class="space-y-4">
  222. {#each departments as dept, i}
  223. {@const badge = statusBadge(dept.liveStatus)}
  224. {@const StatusIcon = statusIcon(dept.liveStatus)}
  225. <div
  226. class="overflow-hidden rounded-xl border bg-white shadow-sm transition-colors duration-200 dark:bg-[#1e293b] {statusCardBorder(dept.liveStatus)}"
  227. >
  228. <!-- Card header -->
  229. <div
  230. class="flex items-center justify-between border-b border-slate-200 bg-slate-50 px-5 py-4 dark:border-slate-800 dark:bg-slate-900/30"
  231. >
  232. <div class="flex items-center gap-3">
  233. <div class="h-3 w-3 shrink-0 rounded-full {statusIndicatorCls(dept.liveStatus)}"></div>
  234. <h2 class="text-base font-bold text-slate-900 dark:text-white">{dept.name}</h2>
  235. <span class="rounded-md border px-2 py-0.5 text-xs font-bold {badge.cls}"
  236. >{badge.label}</span
  237. >
  238. </div>
  239. <div class="flex items-center gap-2 text-xs text-slate-500 dark:text-slate-400">
  240. <StatusIcon size={14} class={statusIconCls(dept.liveStatus)} />
  241. <span>{dept.liveDetail}</span>
  242. </div>
  243. </div>
  244. <!-- Form fields -->
  245. <div class="grid grid-cols-1 gap-6 p-5 sm:grid-cols-2 xl:grid-cols-4">
  246. <!-- Primeira resposta horas -->
  247. <div>
  248. <label
  249. class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
  250. >
  251. Primeira resposta — horas
  252. </label>
  253. <div class="flex items-center gap-2">
  254. <input
  255. type="number"
  256. min="0"
  257. max="72"
  258. bind:value={departments[i].firstResponseH}
  259. class={inputCls}
  260. />
  261. <span class="shrink-0 text-xs text-slate-500 dark:text-slate-400">h</span>
  262. </div>
  263. </div>
  264. <!-- Primeira resposta minutos -->
  265. <div>
  266. <label
  267. class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
  268. >
  269. Primeira resposta — minutos
  270. </label>
  271. <div class="flex items-center gap-2">
  272. <input
  273. type="number"
  274. min="0"
  275. max="59"
  276. bind:value={departments[i].firstResponseM}
  277. class={inputCls}
  278. />
  279. <span class="shrink-0 text-xs text-slate-500 dark:text-slate-400">min</span>
  280. </div>
  281. </div>
  282. <!-- Tempo máx. de resolução -->
  283. <div>
  284. <label
  285. class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
  286. >
  287. Tempo máx. de resolução
  288. </label>
  289. <div class="flex items-center gap-2">
  290. <input
  291. type="number"
  292. min="1"
  293. max="720"
  294. bind:value={departments[i].resolutionH}
  295. class={inputCls}
  296. />
  297. <span class="shrink-0 text-xs text-slate-500 dark:text-slate-400">horas</span>
  298. </div>
  299. </div>
  300. <!-- Alerta de estouro -->
  301. <div>
  302. <label
  303. class="mb-1.5 block text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
  304. >
  305. Alertar quando atingir
  306. </label>
  307. <div class="flex items-center gap-2">
  308. <input
  309. type="number"
  310. min="1"
  311. max="99"
  312. bind:value={departments[i].alertPct}
  313. class={inputCls}
  314. />
  315. <span class="shrink-0 text-xs text-slate-500 dark:text-slate-400">% do tempo</span>
  316. </div>
  317. </div>
  318. </div>
  319. <!-- Summary + save -->
  320. <div
  321. class="flex flex-wrap items-center justify-between gap-4 border-t border-slate-100 px-5 py-4 dark:border-slate-800"
  322. >
  323. <div class="flex flex-wrap gap-4 text-xs text-slate-500 dark:text-slate-400">
  324. <span>
  325. Primeira resposta:
  326. <strong class="text-slate-900 dark:text-slate-200">
  327. {dept.firstResponseH > 0 ? `${dept.firstResponseH}h ` : ''}{dept.firstResponseM > 0
  328. ? `${dept.firstResponseM}min`
  329. : dept.firstResponseH === 0
  330. ? '0min'
  331. : ''}
  332. </strong>
  333. </span>
  334. <span>
  335. Resolução: <strong class="text-slate-900 dark:text-slate-200">{dept.resolutionH}h</strong>
  336. </span>
  337. <span>
  338. Alerta em: <strong class="text-slate-900 dark:text-slate-200">{dept.alertPct}%</strong>
  339. </span>
  340. </div>
  341. <div class="flex items-center gap-3">
  342. {#if savedDeptIds.includes(dept.id)}
  343. <span class="flex items-center gap-1.5 text-sm font-medium text-emerald-600 dark:text-emerald-400">
  344. <CheckCircle size={16} />
  345. SLA do {dept.name} atualizado
  346. </span>
  347. {/if}
  348. <button
  349. onclick={() => saveDepartment(dept.id, dept.name)}
  350. class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-all hover:bg-indigo-700 dark:bg-indigo-500 dark:hover:bg-indigo-600"
  351. >
  352. Salvar
  353. </button>
  354. </div>
  355. </div>
  356. </div>
  357. {/each}
  358. </div>
  359. {/if}
  360. <!-- Add Department button -->
  361. {#if !isLoading}
  362. <div class="flex justify-center">
  363. <button
  364. onclick={() => (showAddModal = true)}
  365. class="flex items-center gap-2 rounded-lg border border-dashed border-slate-300 bg-white px-6 py-3 text-sm font-medium text-slate-600 shadow-sm transition-all hover:border-indigo-400 hover:text-indigo-600 dark:border-slate-700 dark:bg-[#1e293b] dark:text-slate-400 dark:hover:border-indigo-500 dark:hover:text-indigo-400"
  366. >
  367. <Plus size={16} strokeWidth={2.5} />
  368. + Adicionar Departamento
  369. </button>
  370. </div>
  371. {/if}
  372. <!-- Live SLA Status panel -->
  373. {#if !isLoading}
  374. <div
  375. class="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm transition-colors duration-200 dark:border-slate-800 dark:bg-[#1e293b]"
  376. >
  377. <div
  378. class="flex items-center gap-3 border-b border-slate-200 bg-slate-50 px-5 py-4 dark:border-slate-800 dark:bg-slate-900/30"
  379. >
  380. <div
  381. 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"
  382. >
  383. <Clock size={18} strokeWidth={2} />
  384. </div>
  385. <h2 class="text-base font-bold text-slate-900 dark:text-white">
  386. Status de SLA em Tempo Real
  387. </h2>
  388. </div>
  389. <div class="overflow-x-auto">
  390. <table class="w-full text-sm">
  391. <thead>
  392. <tr
  393. class="border-b border-slate-100 bg-slate-50/50 dark:border-slate-800 dark:bg-slate-900/20"
  394. >
  395. <th
  396. class="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
  397. >Departamento</th
  398. >
  399. <th
  400. class="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
  401. >Status atual</th
  402. >
  403. <th
  404. class="px-5 py-3 text-center text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
  405. >Situação</th
  406. >
  407. <th
  408. class="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
  409. >Primeira resposta máx.</th
  410. >
  411. <th
  412. class="px-5 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
  413. >Resolução máx.</th
  414. >
  415. <th
  416. class="px-5 py-3 text-right text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
  417. >Atualizado</th
  418. >
  419. </tr>
  420. </thead>
  421. <tbody class="divide-y divide-slate-100 dark:divide-slate-800">
  422. {#each departments as dept}
  423. {@const badge = statusBadge(dept.liveStatus)}
  424. {@const StatusIcon = statusIcon(dept.liveStatus)}
  425. <tr class="transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/40">
  426. <td class="px-5 py-4">
  427. <div class="flex items-center gap-2">
  428. <div
  429. class="h-2.5 w-2.5 shrink-0 rounded-full {statusIndicatorCls(dept.liveStatus)}"
  430. ></div>
  431. <span class="font-semibold text-slate-900 dark:text-white">{dept.name}</span>
  432. </div>
  433. </td>
  434. <td class="px-5 py-4">
  435. <div class="flex items-center gap-2 text-sm">
  436. <StatusIcon size={15} class={statusIconCls(dept.liveStatus)} />
  437. <span class="text-slate-700 dark:text-slate-300">{dept.liveDetail}</span>
  438. </div>
  439. </td>
  440. <td class="px-5 py-4 text-center">
  441. <span class="rounded-md border px-2.5 py-1 text-xs font-bold {badge.cls}"
  442. >{badge.label}</span
  443. >
  444. </td>
  445. <td class="px-5 py-4 text-slate-700 dark:text-slate-300">
  446. {dept.firstResponseH > 0 ? `${dept.firstResponseH}h` : ''}{dept.firstResponseM > 0
  447. ? ` ${dept.firstResponseM}min`
  448. : ''}
  449. {dept.firstResponseH === 0 && dept.firstResponseM === 0 ? '—' : ''}
  450. </td>
  451. <td class="px-5 py-4 text-slate-700 dark:text-slate-300">
  452. {dept.resolutionH}h
  453. </td>
  454. <td class="px-5 py-4 text-right text-xs text-slate-400 dark:text-slate-500">
  455. {dept.lastUpdated}
  456. </td>
  457. </tr>
  458. {/each}
  459. </tbody>
  460. </table>
  461. </div>
  462. </div>
  463. {/if}
  464. </div>