Răsfoiți Sursa

CRUD Monitoramento de CPR's
Configurações para testes locais

Ranghetti 4 săptămâni în urmă
părinte
comite
2cd53891e6
4 a modificat fișierele cu 795 adăugiri și 2 ștergeri
  1. 25 0
      Dockerfile
  2. 11 0
      nginx.conf
  3. 27 2
      src/routes/cpr/+page.svelte
  4. 732 0
      src/routes/cpr/monitoring/+page.svelte

+ 25 - 0
Dockerfile

@@ -0,0 +1,25 @@
+# syntax=docker/dockerfile:1
+
+FROM node:20-alpine AS build
+
+WORKDIR /app
+
+COPY package.json package-lock.json* ./
+RUN npm ci
+
+COPY . .
+
+ARG VITE_API_URL
+ENV VITE_API_URL=${VITE_API_URL}
+
+RUN npm run build
+
+
+FROM nginx:1.27-alpine
+
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=build /app/build /usr/share/nginx/html
+
+EXPOSE 80
+
+CMD ["nginx", "-g", "daemon off;"]

+ 11 - 0
nginx.conf

@@ -0,0 +1,11 @@
+server {
+  listen 80;
+  server_name _;
+
+  root /usr/share/nginx/html;
+  index index.html;
+
+  location / {
+    try_files $uri $uri/ /index.html;
+  }
+}

+ 27 - 2
src/routes/cpr/+page.svelte

@@ -1105,19 +1105,20 @@
                   {#each historyColumns as column}
                     <th class="px-4 py-3 text-left font-semibold">{column.label}</th>
                   {/each}
+                  <th class="px-4 py-3 text-left font-semibold">Monitoramento</th>
                   <th class="px-4 py-3 text-left font-semibold">Ação</th>
                 </tr>
               </thead>
               <tbody class="divide-y divide-gray-200 dark:divide-gray-700 text-gray-800 dark:text-gray-100">
                 {#if historyLoading && !historyInitialized}
                   <tr>
-                    <td class="px-4 py-6 text-center" colspan={historyColumns.length + 1}>
+                    <td class="px-4 py-6 text-center" colspan={historyColumns.length + 2}>
                       Carregando histórico...
                     </td>
                   </tr>
                 {:else if !historyRows.length}
                   <tr>
-                    <td class="px-4 py-6 text-center text-gray-500 dark:text-gray-400" colspan={historyColumns.length + 1}>
+                    <td class="px-4 py-6 text-center text-gray-500 dark:text-gray-400" colspan={historyColumns.length + 2}>
                       Nenhuma CPR encontrada.
                     </td>
                   </tr>
@@ -1129,6 +1130,30 @@
                       <td class="px-4 py-3">{row.cpr_issue_date ?? '—'}</td>
                       <td class="px-4 py-3">{row.cpr_issuer_name ?? '—'}</td>
                       <td class="px-4 py-3">{formatCurrency(row.cpr_issue_financial_value)}</td>
+                      <td class="px-4 py-3">
+                        {#if row.cpr_id}
+                          <a
+                            class="inline-flex items-center justify-center rounded-full p-1.5 text-gray-700 hover:text-gray-900 dark:text-gray-200 dark:hover:text-white"
+                            href={`/cpr/monitoring?cpr_id=${encodeURIComponent(String(row.cpr_id))}`}
+                            aria-label="Abrir monitoramento"
+                          >
+                            <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
+                              <path
+                                d="M4 7h3l2-2h6l2 2h3a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2z"
+                                stroke-linecap="round"
+                                stroke-linejoin="round"
+                              />
+                              <path
+                                d="M12 18a4 4 0 1 0 0-8 4 4 0 0 0 0 8z"
+                                stroke-linecap="round"
+                                stroke-linejoin="round"
+                              />
+                            </svg>
+                          </a>
+                        {:else}
+                          —
+                        {/if}
+                      </td>
                       <td class="px-4 py-3">
                         <button
                           type="button"

+ 732 - 0
src/routes/cpr/monitoring/+page.svelte

@@ -0,0 +1,732 @@
+<script>
+  import Header from '$lib/layout/Header.svelte';
+  import Tables from '$lib/components/Tables.svelte';
+  import ConfirmModal from '$lib/components/ui/PopUpDelete.svelte';
+  import { authToken } from '$lib/utils/stores';
+  import { page } from '$app/stores';
+  import { onMount } from 'svelte';
+
+  const apiUrl = import.meta.env.VITE_API_URL;
+
+  const cprHistoryEndpoint = `${apiUrl}/cpr/history`;
+
+  const breadcrumb = [{ label: 'Início' }, { label: 'CPR' }, { label: 'Monitoring', active: true }];
+
+  const columns = [
+    { key: 'id', label: 'ID' },
+    { key: 'preview', label: 'Preview' },
+    { key: 'description', label: 'Descrição' },
+    { key: 'link', label: 'Link' }
+  ];
+
+  let cprOptions = [];
+  let cprOptionsLoading = false;
+  let cprOptionsError = '';
+  let selectedCprId = '';
+  let cprOptionsLoadedOnce = false;
+
+  let requestedCprId = '';
+  let requestedCprApplied = false;
+
+  let data = [];
+  let loadError = '';
+  let successMessage = '';
+  let isLoading = false;
+
+  let showCreate = false;
+  let createLoading = false;
+  let createError = '';
+  let createPreview = false;
+  let createDescription = '';
+  let createLink = '';
+
+  let showEdit = false;
+  let editLoading = false;
+  let editError = '';
+  let editId = null;
+  let editPreview = false;
+  let editDescription = '';
+  let editLink = '';
+
+  let showDeleteConfirm = false;
+  let rowToDelete = null;
+  let deleteLoading = false;
+  let deleteError = '';
+
+  function toCprOptionLabel(row) {
+    const id = row?.cpr_id ?? row?.id;
+    const product = row?.cpr_product_class_name ?? row?.cpr_product_name;
+    const issuer = row?.cpr_issuer_name;
+    const parts = [`#${id}`];
+    if (product) parts.push(String(product));
+    if (issuer) parts.push(String(issuer));
+    return parts.join(' - ');
+  }
+
+  async function fetchCprOptions() {
+    if (cprOptionsLoading) return;
+    cprOptionsLoading = true;
+    cprOptionsError = '';
+
+    try {
+      const token = $authToken;
+      if (!token) {
+        throw new Error('Sessão expirada. Faça login novamente.');
+      }
+
+      const res = await fetch(cprHistoryEndpoint, {
+        method: 'POST',
+        headers: {
+          'content-type': 'application/json',
+          Authorization: `Bearer ${token}`
+        },
+        body: JSON.stringify({})
+      });
+
+      const raw = await res.text();
+      const body = safeParseJson(raw);
+
+      if (!res.ok) {
+        throw new Error(body?.message ?? body?.msg ?? `Falha ao carregar CPRs (HTTP ${res.status}).`);
+      }
+
+      const list = normalizeList(body);
+      cprOptions = list
+        .map((row) => {
+          const id = Number(row?.cpr_id ?? row?.id);
+          if (!Number.isInteger(id) || id <= 0) return null;
+          return {
+            id,
+            label: toCprOptionLabel({ ...row, cpr_id: id })
+          };
+        })
+        .filter(Boolean);
+
+      if (!requestedCprApplied && requestedCprId) {
+        const wantedId = Number(requestedCprId);
+        const exists = cprOptions.some((opt) => opt.id === wantedId);
+        if (exists) {
+          selectedCprId = String(wantedId);
+        }
+        requestedCprApplied = true;
+      }
+
+      if (!selectedCprId && cprOptions.length) {
+        selectedCprId = String(cprOptions[0].id);
+      }
+    } catch (err) {
+      console.error('[CPR Monitoring] Erro ao buscar CPRs:', err);
+      cprOptionsError = err?.message ?? 'Falha ao carregar CPRs.';
+      cprOptions = [];
+    } finally {
+      cprOptionsLoading = false;
+      cprOptionsLoadedOnce = true;
+    }
+  }
+
+  $: if (cprOptionsLoadedOnce && !cprOptionsLoading && selectedCprId) {
+    void fetchMonitoring();
+  }
+
+  function safeParseJson(raw) {
+    if (!raw) return null;
+    try {
+      return JSON.parse(raw);
+    } catch {
+      return null;
+    }
+  }
+
+  function normalizeList(body) {
+    if (Array.isArray(body)) return body;
+    if (Array.isArray(body?.data)) return body.data;
+    return [];
+  }
+
+  function mapMonitoringRow(item) {
+    const previewValue = item?.preview;
+    const previewLabel =
+      previewValue === true || previewValue === 1 || previewValue === 't' || previewValue === 'true' ? 'Sim' : 'Não';
+
+    return {
+      __raw: item,
+      id: item?.id ?? '-',
+      preview: previewLabel,
+      description: item?.description ?? '-',
+      link: item?.link ?? '-'
+    };
+  }
+
+  function resolveMonitoringId(row) {
+    return row?.__raw?.id ?? row?.id;
+  }
+
+  function openCreate() {
+    showCreate = true;
+    createError = '';
+    createLoading = false;
+    createPreview = false;
+    createDescription = '';
+    createLink = '';
+
+    if (!cprOptions.length) {
+      void fetchCprOptions();
+    }
+  }
+
+  function closeCreate() {
+    showCreate = false;
+    createLoading = false;
+    createError = '';
+    createPreview = false;
+    createDescription = '';
+    createLink = '';
+  }
+
+  function openEditFromRow(row) {
+    const raw = row?.__raw ?? row;
+    const id = Number(raw?.id);
+    if (!Number.isInteger(id) || id <= 0) {
+      loadError = 'Registro inválido para edição.';
+      return;
+    }
+
+    editId = id;
+    editPreview = Boolean(raw?.preview);
+    editDescription = String(raw?.description ?? '');
+    editLink = String(raw?.link ?? '');
+    editError = '';
+    editLoading = false;
+    showEdit = true;
+  }
+
+  function closeEdit() {
+    showEdit = false;
+    editLoading = false;
+    editError = '';
+    editId = null;
+    editPreview = false;
+    editDescription = '';
+    editLink = '';
+  }
+
+  async function fetchMonitoring() {
+    if (isLoading) return;
+
+    const cprId = Number(selectedCprId);
+    if (!Number.isInteger(cprId) || cprId <= 0) {
+      loadError = 'Selecione uma CPR válida para listar.';
+      data = [];
+      return;
+    }
+
+    isLoading = true;
+    loadError = '';
+    try {
+      const token = $authToken;
+      if (!token) {
+        throw new Error('Sessão expirada. Faça login novamente.');
+      }
+
+      const res = await fetch(`${apiUrl}/cpr/monitoring/list`, {
+        method: 'POST',
+        headers: {
+          'content-type': 'application/json',
+          Authorization: `Bearer ${token}`
+        },
+        body: JSON.stringify({ cpr_id: cprId })
+      });
+
+      if (res.status === 204) {
+        data = [];
+        return;
+      }
+
+      const raw = await res.text();
+      const body = safeParseJson(raw);
+
+      if (!res.ok || body?.status !== 'ok') {
+        throw new Error(body?.msg ?? body?.message ?? `Falha ao carregar registros (HTTP ${res.status}).`);
+      }
+
+      const list = normalizeList(body);
+      data = list.map(mapMonitoringRow);
+    } catch (err) {
+      console.error('[CPR Monitoring] Erro ao buscar registros:', err);
+      loadError = err?.message ?? 'Falha ao carregar registros.';
+      data = [];
+    } finally {
+      isLoading = false;
+    }
+  }
+
+  function handleEditRow(e) {
+    const { row } = e?.detail || {};
+    if (!row) return;
+    openEditFromRow(row);
+  }
+
+  function handleDeleteRow(e) {
+    const { row } = e?.detail || {};
+    if (!row) return;
+    rowToDelete = row;
+    deleteError = '';
+    deleteLoading = false;
+    showDeleteConfirm = true;
+  }
+
+  async function handleCreateSubmit() {
+    if (createLoading) return;
+    createError = '';
+
+    const cprId = Number(selectedCprId);
+    if (!Number.isInteger(cprId) || cprId <= 0) {
+      createError = 'Selecione uma CPR válida.';
+      return;
+    }
+
+    const description = String(createDescription ?? '').trim();
+    if (!description || description.length > 5000) {
+      createError = 'Informe uma descrição válida (1 a 5000 caracteres).';
+      return;
+    }
+
+    const link = String(createLink ?? '').trim();
+    if (!link || link.length > 2048) {
+      createError = 'Informe um link válido (1 a 2048 caracteres).';
+      return;
+    }
+
+    const token = $authToken;
+    if (!token) {
+      createError = 'Sessão expirada. Faça login novamente.';
+      return;
+    }
+
+    createLoading = true;
+    try {
+      const res = await fetch(`${apiUrl}/cpr/monitoring/create`, {
+        method: 'POST',
+        headers: {
+          'content-type': 'application/json',
+          Authorization: `Bearer ${token}`
+        },
+        body: JSON.stringify({
+          cpr_id: cprId,
+          preview: Boolean(createPreview),
+          description,
+          link
+        })
+      });
+
+      const raw = await res.text();
+      const body = safeParseJson(raw);
+
+      const isSuccess = res.ok && body?.status === 'ok' && body?.code === 'S_CREATED' && body?.data?.id != null;
+      if (!isSuccess) {
+        throw new Error(body?.msg ?? body?.message ?? `Falha ao criar registro (HTTP ${res.status}).`);
+      }
+
+      successMessage = body?.msg ?? body?.message ?? 'Registro criado com sucesso!';
+      closeCreate();
+      await fetchMonitoring();
+    } catch (err) {
+      console.error('[CPR Monitoring] Erro ao criar registro:', err);
+      createError = err?.message ?? 'Falha ao criar registro.';
+    } finally {
+      createLoading = false;
+    }
+  }
+
+  async function handleEditSubmit() {
+    if (editLoading) return;
+    editError = '';
+
+    const id = Number(editId);
+    if (!Number.isInteger(id) || id <= 0) {
+      editError = 'ID inválido.';
+      return;
+    }
+
+    const description = String(editDescription ?? '').trim();
+    if (!description || description.length > 5000) {
+      editError = 'Informe uma descrição válida (1 a 5000 caracteres).';
+      return;
+    }
+
+    const link = String(editLink ?? '').trim();
+    if (!link || link.length > 2048) {
+      editError = 'Informe um link válido (1 a 2048 caracteres).';
+      return;
+    }
+
+    const token = $authToken;
+    if (!token) {
+      editError = 'Sessão expirada. Faça login novamente.';
+      return;
+    }
+
+    editLoading = true;
+    try {
+      const res = await fetch(`${apiUrl}/cpr/monitoring/update`, {
+        method: 'POST',
+        headers: {
+          'content-type': 'application/json',
+          Authorization: `Bearer ${token}`
+        },
+        body: JSON.stringify({
+          id,
+          preview: Boolean(editPreview),
+          description,
+          link
+        })
+      });
+
+      if (res.status === 204) {
+        throw new Error('Registro não encontrado ou não atualizado.');
+      }
+
+      const raw = await res.text();
+      const body = safeParseJson(raw);
+
+      const isSuccess = res.ok && body?.status === 'ok' && body?.code === 'S_UPDATED' && body?.data?.id != null;
+      if (!isSuccess) {
+        throw new Error(body?.msg ?? body?.message ?? `Falha ao atualizar registro (HTTP ${res.status}).`);
+      }
+
+      successMessage = body?.msg ?? body?.message ?? 'Registro atualizado com sucesso!';
+      closeEdit();
+      await fetchMonitoring();
+    } catch (err) {
+      console.error('[CPR Monitoring] Erro ao atualizar registro:', err);
+      editError = err?.message ?? 'Falha ao atualizar registro.';
+    } finally {
+      editLoading = false;
+    }
+  }
+
+  async function confirmDelete() {
+    if (deleteLoading) return;
+    deleteError = '';
+
+    const idRaw = resolveMonitoringId(rowToDelete);
+    const id = Number(idRaw);
+    if (!Number.isInteger(id) || id <= 0) {
+      deleteError = 'ID inválido para exclusão.';
+      return;
+    }
+
+    const token = $authToken;
+    if (!token) {
+      deleteError = 'Sessão expirada. Faça login novamente.';
+      return;
+    }
+
+    deleteLoading = true;
+    let deleted = false;
+
+    try {
+      const res = await fetch(`${apiUrl}/cpr/monitoring/delete`, {
+        method: 'POST',
+        headers: {
+          'content-type': 'application/json',
+          Authorization: `Bearer ${token}`
+        },
+        body: JSON.stringify({ id })
+      });
+
+      if (res.status === 204) {
+        throw new Error('Registro não encontrado.');
+      }
+
+      const raw = await res.text();
+      const body = safeParseJson(raw);
+
+      const isSuccess = res.ok && body?.status === 'ok' && body?.code === 'S_DELETED' && body?.data?.deleted === true;
+      if (!isSuccess) {
+        throw new Error(body?.msg ?? body?.message ?? `Falha ao excluir registro (HTTP ${res.status}).`);
+      }
+
+      deleted = true;
+      successMessage = body?.msg ?? body?.message ?? 'Registro excluído com sucesso!';
+      await fetchMonitoring();
+    } catch (err) {
+      console.error('[CPR Monitoring] Erro ao excluir registro:', err);
+      deleteError = err?.message ?? 'Falha ao excluir registro.';
+    } finally {
+      deleteLoading = false;
+      if (deleted) {
+        showDeleteConfirm = false;
+        rowToDelete = null;
+      }
+    }
+  }
+
+  function cancelDelete() {
+    showDeleteConfirm = false;
+    rowToDelete = null;
+    deleteError = '';
+    deleteLoading = false;
+  }
+
+  onMount(() => {
+    loadError = '';
+    requestedCprId = String($page.url.searchParams.get('cpr_id') ?? '');
+    requestedCprApplied = false;
+    void fetchCprOptions();
+  });
+</script>
+
+<div>
+  <Header title="CPR - Monitoring" subtitle="Gestão de registros de monitoramento por CPR" breadcrumb={breadcrumb} />
+
+  <div class="p-4">
+    <div class="max-w-6xl mx-auto mt-4">
+      {#if loadError}
+        <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>
+      {/if}
+
+      {#if successMessage}
+        <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">
+          {successMessage}
+        </div>
+      {/if}
+
+      <section class="space-y-4">
+        <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
+          <div>
+            <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-50">Registros de monitoramento</h2>
+            <p class="text-sm text-gray-500 dark:text-gray-400">Liste, crie, edite ou remova registros vinculados a uma CPR.</p>
+          </div>
+
+          <div class="flex flex-col gap-2 sm:flex-row sm:items-center">
+            <select
+              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"
+              bind:value={selectedCprId}
+              disabled={isLoading || cprOptionsLoading}
+            >
+              <option value="" disabled>Selecione uma CPR</option>
+              {#each cprOptions as opt}
+                <option value={String(opt.id)}>{opt.label}</option>
+              {/each}
+            </select>
+
+            {#if cprOptionsError}
+              <div class="text-xs text-red-600 dark:text-red-400">{cprOptionsError}</div>
+            {/if}
+
+            <button
+              type="button"
+              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"
+              on:click={fetchMonitoring}
+              disabled={isLoading}
+            >
+              {isLoading ? 'Atualizando...' : 'Atualizar'}
+            </button>
+
+            <button
+              type="button"
+              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"
+              on:click={openCreate}
+              disabled={isLoading}
+            >
+              Novo registro
+            </button>
+          </div>
+        </div>
+
+        <Tables
+          title="Registros"
+          {columns}
+          {data}
+          on:editRow={handleEditRow}
+          on:deleteRow={handleDeleteRow}
+          showAdd={false}
+        />
+      </section>
+
+      {#if showCreate}
+        <div
+          class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
+          role="button"
+          tabindex="0"
+          on:click={(e) => {
+            if (e.target === e.currentTarget && !createLoading) closeCreate();
+          }}
+          on:keydown={(e) => {
+            if ((e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') && !createLoading) closeCreate();
+          }}
+        >
+          <div 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" role="dialog" aria-modal="true">
+            <div class="flex items-center justify-between mb-4">
+              <h4 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Criar registro</h4>
+              <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>
+            </div>
+
+            <div class="space-y-4">
+              <div>
+                <label class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">CPR</label>
+                <select
+                  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"
+                  bind:value={selectedCprId}
+                  disabled={createLoading || cprOptionsLoading}
+                >
+                  <option value="" disabled>Selecione uma CPR</option>
+                  {#each cprOptions as opt}
+                    <option value={String(opt.id)}>{opt.label}</option>
+                  {/each}
+                </select>
+                {#if cprOptionsError}
+                  <div class="mt-1 text-xs text-red-600 dark:text-red-400">{cprOptionsError}</div>
+                {/if}
+              </div>
+
+              <div class="flex items-center gap-2">
+                <input id="create-preview" type="checkbox" bind:checked={createPreview} disabled={createLoading} />
+                <label for="create-preview" class="text-sm text-gray-700 dark:text-gray-200">Preview</label>
+              </div>
+
+              <div>
+                <label class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Descrição</label>
+                <textarea
+                  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"
+                  rows="6"
+                  maxlength="5000"
+                  bind:value={createDescription}
+                  disabled={createLoading}
+                ></textarea>
+              </div>
+
+              <div>
+                <label class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Link</label>
+                <input
+                  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"
+                  type="text"
+                  maxlength="2048"
+                  bind:value={createLink}
+                  disabled={createLoading}
+                />
+              </div>
+
+              {#if createError}
+                <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">
+                  {createError}
+                </div>
+              {/if}
+
+              <div class="grid grid-cols-1 md:grid-cols-2 gap-2 pt-2">
+                <button
+                  type="button"
+                  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"
+                  on:click={closeCreate}
+                  disabled={createLoading}
+                >
+                  Cancelar
+                </button>
+                <button
+                  type="button"
+                  class="rounded bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 disabled:opacity-60"
+                  on:click={handleCreateSubmit}
+                  disabled={createLoading}
+                >
+                  {createLoading ? 'Criando...' : 'Criar'}
+                </button>
+              </div>
+            </div>
+          </div>
+        </div>
+      {/if}
+
+      {#if showEdit}
+        <div
+          class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
+          role="button"
+          tabindex="0"
+          on:click={(e) => {
+            if (e.target === e.currentTarget && !editLoading) closeEdit();
+          }}
+          on:keydown={(e) => {
+            if ((e.key === 'Escape' || e.key === 'Enter' || e.key === ' ') && !editLoading) closeEdit();
+          }}
+        >
+          <div 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" role="dialog" aria-modal="true">
+            <div class="flex items-center justify-between mb-4">
+              <h4 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Editar registro #{editId}</h4>
+              <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>
+            </div>
+
+            <div class="space-y-4">
+              <div class="flex items-center gap-2">
+                <input id="edit-preview" type="checkbox" bind:checked={editPreview} disabled={editLoading} />
+                <label for="edit-preview" class="text-sm text-gray-700 dark:text-gray-200">Preview</label>
+              </div>
+
+              <div>
+                <label class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Descrição</label>
+                <textarea
+                  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"
+                  rows="6"
+                  maxlength="5000"
+                  bind:value={editDescription}
+                  disabled={editLoading}
+                ></textarea>
+              </div>
+
+              <div>
+                <label class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Link</label>
+                <input
+                  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"
+                  type="text"
+                  maxlength="2048"
+                  bind:value={editLink}
+                  disabled={editLoading}
+                />
+              </div>
+
+              {#if editError}
+                <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">
+                  {editError}
+                </div>
+              {/if}
+
+              <div class="grid grid-cols-1 md:grid-cols-2 gap-2 pt-2">
+                <button
+                  type="button"
+                  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"
+                  on:click={closeEdit}
+                  disabled={editLoading}
+                >
+                  Cancelar
+                </button>
+                <button
+                  type="button"
+                  class="rounded bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 disabled:opacity-60"
+                  on:click={handleEditSubmit}
+                  disabled={editLoading}
+                >
+                  {editLoading ? 'Salvando...' : 'Salvar'}
+                </button>
+              </div>
+            </div>
+          </div>
+        </div>
+      {/if}
+
+      <ConfirmModal
+        visible={showDeleteConfirm}
+        title="Confirmar exclusão"
+        confirmText={deleteLoading ? 'Excluindo...' : 'Excluir'}
+        cancelText="Cancelar"
+        disableBackdropClose={deleteLoading}
+        confirmDisabled={deleteLoading}
+        on:confirm={confirmDelete}
+        on:cancel={cancelDelete}
+      >
+        <p>Tem certeza que deseja excluir o registro "{rowToDelete?.id}"?</p>
+        {#if deleteError}
+          <p class="mt-3 text-sm text-red-600 dark:text-red-400">{deleteError}</p>
+        {/if}
+      </ConfirmModal>
+    </div>
+  </div>
+</div>