+page.svelte 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746
  1. <script>
  2. import Header from '$lib/layout/Header.svelte';
  3. import Tables from '$lib/components/Tables.svelte';
  4. import ConfirmModal from '$lib/components/ui/PopUpDelete.svelte';
  5. import { authToken } from '$lib/utils/stores';
  6. import { page } from '$app/stores';
  7. import { onMount } from 'svelte';
  8. const apiUrl = import.meta.env.VITE_API_URL;
  9. const cprHistoryEndpoint = `${apiUrl}/cpr/history`;
  10. const breadcrumb = [{ label: 'Início' }, { label: 'CPR' }, { label: 'Monitoring', active: true }];
  11. const columns = [
  12. { key: 'id', label: 'ID' },
  13. { key: 'preview', label: 'Preview' },
  14. { key: 'description', label: 'Descrição' },
  15. { key: 'link', label: 'Link' }
  16. ];
  17. let cprOptions = [];
  18. let cprOptionsLoading = false;
  19. let cprOptionsError = '';
  20. let selectedCprId = '';
  21. let cprOptionsLoadedOnce = false;
  22. let requestedCprId = '';
  23. let requestedCprApplied = false;
  24. let data = [];
  25. let loadError = '';
  26. let successMessage = '';
  27. let isLoading = false;
  28. let showCreate = false;
  29. let createLoading = false;
  30. let createError = '';
  31. let createPreview = false;
  32. let createDescription = '';
  33. let createLink = '';
  34. let showEdit = false;
  35. let editLoading = false;
  36. let editError = '';
  37. let editId = null;
  38. let editPreview = false;
  39. let editDescription = '';
  40. let editLink = '';
  41. let showDeleteConfirm = false;
  42. let rowToDelete = null;
  43. let deleteLoading = false;
  44. let deleteError = '';
  45. function toCprOptionLabel(row) {
  46. const id = row?.cpr_id ?? row?.id;
  47. const product = row?.cpr_product_class_name ?? row?.cpr_product_name;
  48. const issuer = row?.cpr_issuer_name;
  49. const parts = [`#${id}`];
  50. if (product) parts.push(String(product));
  51. if (issuer) parts.push(String(issuer));
  52. return parts.join(' - ');
  53. }
  54. async function fetchCprOptions() {
  55. if (cprOptionsLoading) return;
  56. cprOptionsLoading = true;
  57. cprOptionsError = '';
  58. try {
  59. const token = $authToken;
  60. if (!token) {
  61. throw new Error('Sessão expirada. Faça login novamente.');
  62. }
  63. const res = await fetch(cprHistoryEndpoint, {
  64. method: 'POST',
  65. headers: {
  66. 'content-type': 'application/json',
  67. Authorization: `Bearer ${token}`
  68. },
  69. body: JSON.stringify({})
  70. });
  71. const raw = await res.text();
  72. const body = safeParseJson(raw);
  73. if (!res.ok) {
  74. throw new Error(body?.message ?? body?.msg ?? `Falha ao carregar CPRs (HTTP ${res.status}).`);
  75. }
  76. const list = normalizeList(body);
  77. cprOptions = list
  78. .map((row) => {
  79. const id = Number(row?.cpr_id ?? row?.id);
  80. if (!Number.isInteger(id) || id <= 0) return null;
  81. return {
  82. id,
  83. label: toCprOptionLabel({ ...row, cpr_id: id })
  84. };
  85. })
  86. .filter(Boolean);
  87. if (!requestedCprApplied && requestedCprId) {
  88. const wantedId = Number(requestedCprId);
  89. const exists = cprOptions.some((opt) => opt.id === wantedId);
  90. if (exists) {
  91. selectedCprId = String(wantedId);
  92. }
  93. requestedCprApplied = true;
  94. }
  95. if (!selectedCprId && cprOptions.length) {
  96. selectedCprId = String(cprOptions[0].id);
  97. }
  98. } catch (err) {
  99. console.error('[CPR Monitoring] Erro ao buscar CPRs:', err);
  100. cprOptionsError = err?.message ?? 'Falha ao carregar CPRs.';
  101. cprOptions = [];
  102. } finally {
  103. cprOptionsLoading = false;
  104. cprOptionsLoadedOnce = true;
  105. }
  106. }
  107. $: if (cprOptionsLoadedOnce && !cprOptionsLoading && selectedCprId) {
  108. void fetchMonitoring();
  109. }
  110. function safeParseJson(raw) {
  111. if (!raw) return null;
  112. try {
  113. return JSON.parse(raw);
  114. } catch {
  115. return null;
  116. }
  117. }
  118. function normalizeList(body) {
  119. if (Array.isArray(body)) return body;
  120. if (Array.isArray(body?.data)) return body.data;
  121. return [];
  122. }
  123. function mapMonitoringRow(item) {
  124. const previewValue = item?.preview;
  125. const previewLabel =
  126. previewValue === true || previewValue === 1 || previewValue === 't' || previewValue === 'true' ? 'Sim' : 'Não';
  127. return {
  128. __raw: item,
  129. id: item?.id ?? '-',
  130. preview: previewLabel,
  131. description: item?.description ?? '-',
  132. link: item?.link ?? '-'
  133. };
  134. }
  135. function resolveMonitoringId(row) {
  136. return row?.__raw?.id ?? row?.id;
  137. }
  138. function openCreate() {
  139. showCreate = true;
  140. createError = '';
  141. createLoading = false;
  142. createPreview = false;
  143. createDescription = '';
  144. createLink = '';
  145. if (!cprOptions.length) {
  146. void fetchCprOptions();
  147. }
  148. }
  149. function closeCreate() {
  150. showCreate = false;
  151. createLoading = false;
  152. createError = '';
  153. createPreview = false;
  154. createDescription = '';
  155. createLink = '';
  156. }
  157. function openEditFromRow(row) {
  158. const raw = row?.__raw ?? row;
  159. const id = Number(raw?.id);
  160. if (!Number.isInteger(id) || id <= 0) {
  161. loadError = 'Registro inválido para edição.';
  162. return;
  163. }
  164. editId = id;
  165. editPreview = Boolean(raw?.preview);
  166. editDescription = String(raw?.description ?? '');
  167. editLink = String(raw?.link ?? '');
  168. editError = '';
  169. editLoading = false;
  170. showEdit = true;
  171. }
  172. function closeEdit() {
  173. showEdit = false;
  174. editLoading = false;
  175. editError = '';
  176. editId = null;
  177. editPreview = false;
  178. editDescription = '';
  179. editLink = '';
  180. }
  181. async function fetchMonitoring() {
  182. if (isLoading) return;
  183. const cprId = Number(selectedCprId);
  184. if (!Number.isInteger(cprId) || cprId <= 0) {
  185. loadError = 'Selecione uma CPR válida para listar.';
  186. data = [];
  187. return;
  188. }
  189. isLoading = true;
  190. loadError = '';
  191. try {
  192. const token = $authToken;
  193. if (!token) {
  194. throw new Error('Sessão expirada. Faça login novamente.');
  195. }
  196. const res = await fetch(`${apiUrl}/cpr/monitoring/list`, {
  197. method: 'POST',
  198. headers: {
  199. 'content-type': 'application/json',
  200. Authorization: `Bearer ${token}`
  201. },
  202. body: JSON.stringify({ cpr_id: cprId })
  203. });
  204. if (res.status === 204) {
  205. data = [];
  206. return;
  207. }
  208. const raw = await res.text();
  209. const body = safeParseJson(raw);
  210. if (!res.ok || body?.status !== 'ok') {
  211. throw new Error(body?.msg ?? body?.message ?? `Falha ao carregar registros (HTTP ${res.status}).`);
  212. }
  213. const list = normalizeList(body);
  214. data = list.map(mapMonitoringRow);
  215. } catch (err) {
  216. console.error('[CPR Monitoring] Erro ao buscar registros:', err);
  217. loadError = err?.message ?? 'Falha ao carregar registros.';
  218. data = [];
  219. } finally {
  220. isLoading = false;
  221. }
  222. }
  223. function handleEditRow(e) {
  224. const { row } = e?.detail || {};
  225. if (!row) return;
  226. openEditFromRow(row);
  227. }
  228. function handleDeleteRow(e) {
  229. const { row } = e?.detail || {};
  230. if (!row) return;
  231. rowToDelete = row;
  232. deleteError = '';
  233. deleteLoading = false;
  234. showDeleteConfirm = true;
  235. }
  236. async function handleCreateSubmit() {
  237. if (createLoading) return;
  238. createError = '';
  239. const cprId = Number(selectedCprId);
  240. if (!Number.isInteger(cprId) || cprId <= 0) {
  241. createError = 'Selecione uma CPR válida.';
  242. return;
  243. }
  244. const description = String(createDescription ?? '').trim();
  245. if (!description || description.length > 5000) {
  246. createError = 'Informe uma descrição válida (1 a 5000 caracteres).';
  247. return;
  248. }
  249. const link = String(createLink ?? '').trim();
  250. if (!link || link.length > 2048) {
  251. createError = 'Informe um link válido (1 a 2048 caracteres).';
  252. return;
  253. }
  254. const token = $authToken;
  255. if (!token) {
  256. createError = 'Sessão expirada. Faça login novamente.';
  257. return;
  258. }
  259. createLoading = true;
  260. try {
  261. const createPayload = {
  262. cpr_id: cprId,
  263. preview: createPreview === true,
  264. description,
  265. link
  266. };
  267. const res = await fetch(`${apiUrl}/cpr/monitoring/create`, {
  268. method: 'POST',
  269. headers: {
  270. 'content-type': 'application/json',
  271. Authorization: `Bearer ${token}`
  272. },
  273. body: JSON.stringify(createPayload)
  274. });
  275. const raw = await res.text();
  276. const body = safeParseJson(raw);
  277. const isSuccess = res.ok && body?.status === 'ok' && body?.code === 'S_CREATED' && body?.data?.id != null;
  278. if (!isSuccess) {
  279. throw new Error(body?.msg ?? body?.message ?? `Falha ao criar registro (HTTP ${res.status}).`);
  280. }
  281. successMessage = body?.msg ?? body?.message ?? 'Registro criado com sucesso!';
  282. closeCreate();
  283. await fetchMonitoring();
  284. } catch (err) {
  285. console.error('[CPR Monitoring] Erro ao criar registro:', err);
  286. createError = err?.message ?? 'Falha ao criar registro.';
  287. } finally {
  288. createLoading = false;
  289. }
  290. }
  291. async function handleEditSubmit() {
  292. if (editLoading) return;
  293. editError = '';
  294. const id = Number(editId);
  295. if (!Number.isInteger(id) || id <= 0) {
  296. editError = 'ID inválido.';
  297. return;
  298. }
  299. const description = String(editDescription ?? '').trim();
  300. if (!description || description.length > 5000) {
  301. editError = 'Informe uma descrição válida (1 a 5000 caracteres).';
  302. return;
  303. }
  304. const link = String(editLink ?? '').trim();
  305. if (!link || link.length > 2048) {
  306. editError = 'Informe um link válido (1 a 2048 caracteres).';
  307. return;
  308. }
  309. const token = $authToken;
  310. if (!token) {
  311. editError = 'Sessão expirada. Faça login novamente.';
  312. return;
  313. }
  314. editLoading = true;
  315. try {
  316. const editPayload = {
  317. id,
  318. preview: editPreview === true,
  319. description,
  320. link
  321. };
  322. const res = await fetch(`${apiUrl}/cpr/monitoring/update`, {
  323. method: 'POST',
  324. headers: {
  325. 'content-type': 'application/json',
  326. Authorization: `Bearer ${token}`
  327. },
  328. body: JSON.stringify(editPayload)
  329. });
  330. if (res.status === 204) {
  331. throw new Error('Registro não encontrado ou não atualizado.');
  332. }
  333. const raw = await res.text();
  334. const body = safeParseJson(raw);
  335. const isSuccess = res.ok && body?.status === 'ok' && body?.code === 'S_UPDATED' && body?.data?.id != null;
  336. if (!isSuccess) {
  337. throw new Error(body?.msg ?? body?.message ?? `Falha ao atualizar registro (HTTP ${res.status}).`);
  338. }
  339. successMessage = body?.msg ?? body?.message ?? 'Registro atualizado com sucesso!';
  340. closeEdit();
  341. await fetchMonitoring();
  342. } catch (err) {
  343. console.error('[CPR Monitoring] Erro ao atualizar registro:', err);
  344. editError = err?.message ?? 'Falha ao atualizar registro.';
  345. } finally {
  346. editLoading = false;
  347. }
  348. }
  349. async function confirmDelete() {
  350. if (deleteLoading) return;
  351. deleteError = '';
  352. const idRaw = resolveMonitoringId(rowToDelete);
  353. const id = Number(idRaw);
  354. if (!Number.isInteger(id) || id <= 0) {
  355. deleteError = 'ID inválido para exclusão.';
  356. return;
  357. }
  358. const token = $authToken;
  359. if (!token) {
  360. deleteError = 'Sessão expirada. Faça login novamente.';
  361. return;
  362. }
  363. deleteLoading = true;
  364. let deleted = false;
  365. try {
  366. const res = await fetch(`${apiUrl}/cpr/monitoring/delete`, {
  367. method: 'POST',
  368. headers: {
  369. 'content-type': 'application/json',
  370. Authorization: `Bearer ${token}`
  371. },
  372. body: JSON.stringify({ id })
  373. });
  374. if (res.status === 204) {
  375. throw new Error('Registro não encontrado.');
  376. }
  377. const raw = await res.text();
  378. const body = safeParseJson(raw);
  379. const isSuccess = res.ok && body?.status === 'ok' && body?.code === 'S_DELETED' && body?.data?.deleted === true;
  380. if (!isSuccess) {
  381. throw new Error(body?.msg ?? body?.message ?? `Falha ao excluir registro (HTTP ${res.status}).`);
  382. }
  383. deleted = true;
  384. successMessage = body?.msg ?? body?.message ?? 'Registro excluído com sucesso!';
  385. await fetchMonitoring();
  386. } catch (err) {
  387. console.error('[CPR Monitoring] Erro ao excluir registro:', err);
  388. deleteError = err?.message ?? 'Falha ao excluir registro.';
  389. } finally {
  390. deleteLoading = false;
  391. if (deleted) {
  392. showDeleteConfirm = false;
  393. rowToDelete = null;
  394. }
  395. }
  396. }
  397. function cancelDelete() {
  398. showDeleteConfirm = false;
  399. rowToDelete = null;
  400. deleteError = '';
  401. deleteLoading = false;
  402. }
  403. onMount(() => {
  404. loadError = '';
  405. requestedCprId = String($page.url.searchParams.get('cpr_id') ?? '');
  406. requestedCprApplied = false;
  407. void fetchCprOptions();
  408. });
  409. </script>
  410. <div>
  411. <Header title="CPR - Monitoring" subtitle="Gestão de registros de monitoramento por CPR" breadcrumb={breadcrumb} />
  412. <div class="p-4">
  413. <div class="max-w-6xl mx-auto mt-4">
  414. {#if loadError}
  415. <div class="mb-4 rounded border border-red-300 bg-red-50 text-red-700 px-3 py-2 text-sm dark:border-red-700 dark:bg-red-900/30 dark:text-red-200">{loadError}</div>
  416. {/if}
  417. {#if successMessage}
  418. <div class="mb-4 rounded border border-green-300 bg-green-50 text-green-700 px-3 py-2 text-sm dark:border-green-700 dark:bg-green-900/20 dark:text-green-200">
  419. {successMessage}
  420. </div>
  421. {/if}
  422. <section class="space-y-4">
  423. <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
  424. <div>
  425. <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-50">Registros de monitoramento</h2>
  426. <p class="text-sm text-gray-500 dark:text-gray-400">Liste, crie, edite ou remova registros vinculados a uma CPR.</p>
  427. </div>
  428. <div class="flex flex-col gap-2 sm:flex-row sm:items-center">
  429. <select
  430. class="min-w-64 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700/70 text-gray-900 dark:text-gray-100 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-60"
  431. bind:value={selectedCprId}
  432. disabled={isLoading || cprOptionsLoading}
  433. >
  434. <option value="" disabled>Selecione uma CPR</option>
  435. {#each cprOptions as opt}
  436. <option value={String(opt.id)}>{opt.label}</option>
  437. {/each}
  438. </select>
  439. {#if cprOptionsError}
  440. <div class="text-xs text-red-600 dark:text-red-400">{cprOptionsError}</div>
  441. {/if}
  442. <button
  443. type="button"
  444. class="inline-flex items-center gap-2 rounded-md border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-60"
  445. on:click={fetchMonitoring}
  446. disabled={isLoading}
  447. >
  448. {isLoading ? 'Atualizando...' : 'Atualizar'}
  449. </button>
  450. <button
  451. type="button"
  452. class="inline-flex items-center gap-2 rounded-md border border-gray-300 dark:border-gray-600 px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700 disabled:opacity-60"
  453. on:click={openCreate}
  454. disabled={isLoading}
  455. >
  456. Novo registro
  457. </button>
  458. </div>
  459. </div>
  460. <Tables
  461. title="Registros"
  462. {columns}
  463. {data}
  464. on:editRow={handleEditRow}
  465. on:deleteRow={handleDeleteRow}
  466. showAdd={false}
  467. />
  468. </section>
  469. {#if showCreate}
  470. <div
  471. class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
  472. role="button"
  473. tabindex="0"
  474. on:click={(e) => {
  475. if (e.target === e.currentTarget && !createLoading) closeCreate();
  476. }}
  477. on:keydown={(e) => {
  478. if (e.key === 'Escape' && !createLoading) closeCreate();
  479. }}
  480. >
  481. <div
  482. class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg w-full max-w-2xl p-6"
  483. role="dialog"
  484. aria-modal="true"
  485. on:keydown|stopPropagation
  486. >
  487. <div class="flex items-center justify-between mb-4">
  488. <h4 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Criar registro</h4>
  489. <button type="button" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" on:click={() => (!createLoading ? closeCreate() : null)}>✕</button>
  490. </div>
  491. <div class="space-y-4">
  492. <div>
  493. <label class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">CPR</label>
  494. <select
  495. class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-800 dark:text-gray-100 disabled:opacity-60"
  496. bind:value={selectedCprId}
  497. disabled={createLoading || cprOptionsLoading}
  498. >
  499. <option value="" disabled>Selecione uma CPR</option>
  500. {#each cprOptions as opt}
  501. <option value={String(opt.id)}>{opt.label}</option>
  502. {/each}
  503. </select>
  504. {#if cprOptionsError}
  505. <div class="mt-1 text-xs text-red-600 dark:text-red-400">{cprOptionsError}</div>
  506. {/if}
  507. </div>
  508. <div class="flex items-center gap-2">
  509. <input id="create-preview" type="checkbox" bind:checked={createPreview} disabled={createLoading} />
  510. <label for="create-preview" class="text-sm text-gray-700 dark:text-gray-200">Preview</label>
  511. </div>
  512. <div>
  513. <label class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Descrição</label>
  514. <textarea
  515. class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-800 dark:text-gray-100"
  516. rows="6"
  517. maxlength="5000"
  518. bind:value={createDescription}
  519. disabled={createLoading}
  520. ></textarea>
  521. </div>
  522. <div>
  523. <label class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Link</label>
  524. <input
  525. class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-800 dark:text-gray-100"
  526. type="text"
  527. maxlength="2048"
  528. bind:value={createLink}
  529. disabled={createLoading}
  530. />
  531. </div>
  532. {#if createError}
  533. <div class="rounded border border-red-200 dark:border-red-700 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-200 px-3 py-2 text-sm">
  534. {createError}
  535. </div>
  536. {/if}
  537. <div class="grid grid-cols-1 md:grid-cols-2 gap-2 pt-2">
  538. <button
  539. type="button"
  540. class="rounded border border-gray-300 dark:border-gray-600 py-2 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-60"
  541. on:click={closeCreate}
  542. disabled={createLoading}
  543. >
  544. Cancelar
  545. </button>
  546. <button
  547. type="button"
  548. class="rounded bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 disabled:opacity-60"
  549. on:click={handleCreateSubmit}
  550. disabled={createLoading}
  551. >
  552. {createLoading ? 'Criando...' : 'Criar'}
  553. </button>
  554. </div>
  555. </div>
  556. </div>
  557. </div>
  558. {/if}
  559. {#if showEdit}
  560. <div
  561. class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
  562. role="button"
  563. tabindex="0"
  564. on:click={(e) => {
  565. if (e.target === e.currentTarget && !editLoading) closeEdit();
  566. }}
  567. on:keydown={(e) => {
  568. if (e.key === 'Escape' && !editLoading) closeEdit();
  569. }}
  570. >
  571. <div
  572. class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg w-full max-w-2xl p-6"
  573. role="dialog"
  574. aria-modal="true"
  575. on:keydown|stopPropagation
  576. >
  577. <div class="flex items-center justify-between mb-4">
  578. <h4 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Editar registro #{editId}</h4>
  579. <button type="button" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" on:click={() => (!editLoading ? closeEdit() : null)}>✕</button>
  580. </div>
  581. <div class="space-y-4">
  582. <div class="flex items-center gap-2">
  583. <input id="edit-preview" type="checkbox" bind:checked={editPreview} disabled={editLoading} />
  584. <label for="edit-preview" class="text-sm text-gray-700 dark:text-gray-200">Preview</label>
  585. </div>
  586. <div>
  587. <label class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Descrição</label>
  588. <textarea
  589. class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-800 dark:text-gray-100"
  590. rows="6"
  591. maxlength="5000"
  592. bind:value={editDescription}
  593. disabled={editLoading}
  594. ></textarea>
  595. </div>
  596. <div>
  597. <label class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Link</label>
  598. <input
  599. class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-3 py-2 text-sm text-gray-800 dark:text-gray-100"
  600. type="text"
  601. maxlength="2048"
  602. bind:value={editLink}
  603. disabled={editLoading}
  604. />
  605. </div>
  606. {#if editError}
  607. <div class="rounded border border-red-200 dark:border-red-700 bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-200 px-3 py-2 text-sm">
  608. {editError}
  609. </div>
  610. {/if}
  611. <div class="grid grid-cols-1 md:grid-cols-2 gap-2 pt-2">
  612. <button
  613. type="button"
  614. class="rounded border border-gray-300 dark:border-gray-600 py-2 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-60"
  615. on:click={closeEdit}
  616. disabled={editLoading}
  617. >
  618. Cancelar
  619. </button>
  620. <button
  621. type="button"
  622. class="rounded bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 disabled:opacity-60"
  623. on:click={handleEditSubmit}
  624. disabled={editLoading}
  625. >
  626. {editLoading ? 'Salvando...' : 'Salvar'}
  627. </button>
  628. </div>
  629. </div>
  630. </div>
  631. </div>
  632. {/if}
  633. <ConfirmModal
  634. visible={showDeleteConfirm}
  635. title="Confirmar exclusão"
  636. confirmText={deleteLoading ? 'Excluindo...' : 'Excluir'}
  637. cancelText="Cancelar"
  638. disableBackdropClose={deleteLoading}
  639. confirmDisabled={deleteLoading}
  640. on:confirm={confirmDelete}
  641. on:cancel={cancelDelete}
  642. >
  643. <p>Tem certeza que deseja excluir o registro "{rowToDelete?.id}"?</p>
  644. {#if deleteError}
  645. <p class="mt-3 text-sm text-red-600 dark:text-red-400">{deleteError}</p>
  646. {/if}
  647. </ConfirmModal>
  648. </div>
  649. </div>
  650. </div>