5 コミット 8de304bc96 ... 16d6c53a8b

作者 SHA1 メッセージ 日付
  Ranghetti 16d6c53a8b Configurações Docker 2 週間 前
  Ranghetti e61da27554 Ajustes no tamanho do modal de exibição do video 2 週間 前
  Ranghetti 302ca3e96e Rotina para listar o monitoramento a partir das ordens 1 ヶ月 前
  Ranghetti de65a43d7a Ajuste de persistencia do campo preview. 1 ヶ月 前
  Ranghetti 2cd53891e6 CRUD Monitoramento de CPR's 1 ヶ月 前

+ 16 - 0
.dockerignore

@@ -0,0 +1,16 @@
+node_modules
+npm-debug.log
+.git
+.gitignore
+.env
+.env.*
+**/.env
+**/.env.*
+
+dist
+build
+coverage
+
+.vscode
+.idea
+*.log

+ 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;
+  }
+}

+ 8 - 1
src/lib/components/trading/ModalBase.svelte

@@ -3,6 +3,10 @@
   export let title = '';
   export let visible = false;
   export let onClose = () => {};
+  export let maxWidthClass = 'max-w-lg';
+  export let maxHeightClass = 'max-h-[80vh]';
+  export let modalClass = '';
+  export let modalStyle = '';
   const dispatch = createEventDispatcher();
   function close() { try { onClose?.(); } catch {} dispatch('close'); }
   function overlayClick(e) { if (e.target === e.currentTarget) close(); }
@@ -22,7 +26,10 @@
       }
     }}
   ></div>
-  <div class="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4 max-h-[80vh] flex flex-col">
+  <div
+    class={`relative bg-white dark:bg-gray-800 rounded-lg shadow-xl border border-gray-200 dark:border-gray-700 w-full ${maxWidthClass} mx-4 ${maxHeightClass} flex flex-col ${modalClass}`.trim()}
+    style={modalStyle}
+  >
     <div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
       <h3 class="text-base font-semibold text-gray-900 dark:text-gray-100">{title}</h3>
       <button class="p-1 rounded text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" on:click={close}>✕</button>

+ 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"

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

@@ -0,0 +1,746 @@
+<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 createPayload = {
+        cpr_id: cprId,
+        preview: createPreview === true,
+        description,
+        link
+      };
+
+      const res = await fetch(`${apiUrl}/cpr/monitoring/create`, {
+        method: 'POST',
+        headers: {
+          'content-type': 'application/json',
+          Authorization: `Bearer ${token}`
+        },
+        body: JSON.stringify(createPayload)
+      });
+
+      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 editPayload = {
+        id,
+        preview: editPreview === true,
+        description,
+        link
+      };
+
+      const res = await fetch(`${apiUrl}/cpr/monitoring/update`, {
+        method: 'POST',
+        headers: {
+          'content-type': 'application/json',
+          Authorization: `Bearer ${token}`
+        },
+        body: JSON.stringify(editPayload)
+      });
+
+      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' && !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"
+            on:keydown|stopPropagation
+          >
+            <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' && !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"
+            on:keydown|stopPropagation
+          >
+            <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>

+ 199 - 0
src/routes/trading/+page.svelte

@@ -73,6 +73,15 @@
   let isCheckingTransfer = false;
   let orderPaymentBeforeUnloadHandler = null;
 
+  let orderMonitoringModalVisible = false;
+  let orderMonitoringLoading = false;
+  let orderMonitoringError = '';
+  let orderMonitoringRows = [];
+
+  let monitoringVideoModalVisible = false;
+  let monitoringVideoUrl = '';
+  let monitoringVideoError = '';
+
   async function parseResponse(res) {
     const raw = await res.text();
     return raw ? JSON.parse(raw) : null;
@@ -360,6 +369,113 @@
     orderCancelLoading = false;
   }
 
+  function closeOrderMonitoringModal() {
+    orderMonitoringModalVisible = false;
+    orderMonitoringRows = [];
+    orderMonitoringError = '';
+    orderMonitoringLoading = false;
+  }
+
+  function closeMonitoringVideoModal() {
+    monitoringVideoModalVisible = false;
+    monitoringVideoUrl = '';
+    monitoringVideoError = '';
+  }
+
+  function convertToEmbedUrl(url) {
+    if (!url) return url;
+
+    // Vimeo: https://vimeo.com/962745565 -> https://player.vimeo.com/video/962745565?autoplay=1&muted=1
+    const vimeoMatch = url.match(/vimeo\.com\/(\d+)/);
+    if (vimeoMatch) {
+      return `https://player.vimeo.com/video/${vimeoMatch[1]}?autoplay=1&muted=1`;
+    }
+
+    // YouTube: várias variações -> https://www.youtube.com/embed/VIDEO_ID?autoplay=1&mute=1
+    const youtubeMatch = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]+)/);
+    if (youtubeMatch) {
+      return `https://www.youtube.com/embed/${youtubeMatch[1]}?autoplay=1&mute=1`;
+    }
+
+    // Google Drive: https://drive.google.com/file/d/FILE_ID/view -> https://drive.google.com/file/d/FILE_ID/preview
+    const driveMatch = url.match(/drive\.google\.com\/file\/d\/([a-zA-Z0-9_-]+)/);
+    if (driveMatch) {
+      return `https://drive.google.com/file/d/${driveMatch[1]}/preview`;
+    }
+
+    // Retorna URL original se não for reconhecido
+    return url;
+  }
+
+  function openMonitoringVideoModal(link) {
+    const url = typeof link === 'string' ? link.trim() : '';
+    if (!url) {
+      monitoringVideoError = 'Link de vídeo inválido.';
+      monitoringVideoUrl = '';
+      monitoringVideoModalVisible = true;
+      return;
+    }
+
+    monitoringVideoError = '';
+    monitoringVideoUrl = convertToEmbedUrl(url);
+    monitoringVideoModalVisible = true;
+  }
+
+  async function openOrderMonitoringModal() {
+    if (!orderDetailSelected) return;
+    if (orderMonitoringLoading) return;
+    orderMonitoringError = '';
+    orderMonitoringRows = [];
+
+    const orderbookIdRaw = resolveOrderbookId(orderDetailSelected);
+    const orderbookId = Number(orderbookIdRaw);
+    if (!Number.isInteger(orderbookId) || orderbookId <= 0) {
+      orderMonitoringError = 'Ordem inválida para consultar monitoramento.';
+      orderMonitoringModalVisible = true;
+      return;
+    }
+
+    const token = get(authToken);
+    if (!token) {
+      orderMonitoringError = 'Sessão expirada. Faça login novamente.';
+      orderMonitoringModalVisible = true;
+      return;
+    }
+
+    orderMonitoringModalVisible = true;
+    orderMonitoringLoading = true;
+    try {
+      const res = await fetch(`${apiUrl}/orderbook/monitoring/list`, {
+        method: 'POST',
+        headers: {
+          'content-type': 'application/json',
+          Authorization: `Bearer ${token}`
+        },
+        body: JSON.stringify({ orderbook_id: orderbookId })
+      });
+
+      if (res.status === 204) {
+        orderMonitoringRows = [];
+        return;
+      }
+
+      const body = await parseResponse(res);
+      const isSuccess = res.ok && body?.status === 'ok';
+      if (!isSuccess) {
+        throw new Error(body?.msg ?? body?.message ?? 'Falha ao carregar monitoramento.');
+      }
+
+      const list = Array.isArray(body?.data) ? body.data : Array.isArray(body?.data?.data) ? body.data.data : body?.data;
+      orderMonitoringRows = Array.isArray(list) ? list : [];
+    } catch (err) {
+      console.error('[Trading] Falha ao carregar monitoramento da CPR:', err);
+      orderMonitoringError = err?.message ?? 'Não foi possível carregar o monitoramento.';
+      orderMonitoringRows = [];
+    } finally {
+      orderMonitoringLoading = false;
+    }
+  }
+
   async function cancelOrderbookOrder() {
     if (orderCancelLoading) return;
     orderCancelError = '';
@@ -1444,6 +1560,14 @@ $: displayPendingSells = pendingSells.map((o) => ({
         {/if}
 
         <div class="flex flex-col gap-2">
+          <button
+            type="button"
+            class="w-full 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-800 disabled:opacity-60"
+            on:click={openOrderMonitoringModal}
+            disabled={orderMonitoringLoading}
+          >
+            {orderMonitoringLoading ? 'Carregando monitoramento...' : 'Monitoramento'}
+          </button>
           <button
             class="w-full rounded bg-green-600 hover:bg-green-700 text-white font-semibold py-2 disabled:opacity-60"
             on:click={handleOrderDetailPurchase}
@@ -1467,6 +1591,81 @@ $: displayPendingSells = pendingSells.map((o) => ({
       <p class="text-sm text-gray-500">Selecione uma ordem para ver detalhes.</p>
     {/if}
   </ModalBase>
+  <ModalBase
+    title="Monitoramento"
+    visible={orderMonitoringModalVisible}
+    onClose={closeOrderMonitoringModal}
+  >
+    <div class="space-y-3 text-sm text-gray-800 dark:text-gray-100">
+      {#if orderMonitoringError}
+        <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">
+          {orderMonitoringError}
+        </div>
+      {/if}
+
+      {#if orderMonitoringLoading}
+        <p class="text-sm text-gray-500">Carregando...</p>
+      {:else if orderMonitoringRows.length === 0}
+        <p class="text-sm text-gray-500">Nenhum registro de monitoramento encontrado para esta CPR.</p>
+      {:else}
+        <ul class="space-y-2">
+          {#each orderMonitoringRows as row}
+            <li class="rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800/60 p-3">
+              <div class="flex items-start justify-between gap-3">
+                <div class="min-w-0">
+                  <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
+                    {row?.preview === true || row?.preview === 1 || row?.preview === 't' || row?.preview === 'true' ? 'Preview' : 'Registro'}
+                    #{row?.id ?? '—'}
+                  </div>
+                  <div class="mt-1 text-sm break-words">{row?.description ?? '—'}</div>
+                </div>
+                {#if row?.link}
+                  <button
+                    type="button"
+                    class="shrink-0 text-blue-600 hover:text-blue-500 underline text-sm"
+                    on:click={() => openMonitoringVideoModal(row.link)}
+                  >
+                    Abrir
+                  </button>
+                {/if}
+              </div>
+            </li>
+          {/each}
+        </ul>
+      {/if}
+    </div>
+  </ModalBase>
+  <ModalBase
+    title="Vídeo do monitoramento"
+    visible={monitoringVideoModalVisible}
+    onClose={closeMonitoringVideoModal}
+    maxWidthClass=""
+    maxHeightClass=""
+    modalStyle="width: 45vw; height: 85vh;"
+  >
+    <div class="space-y-3 text-sm text-gray-800 dark:text-gray-100">
+      {#if monitoringVideoError}
+        <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">
+          {monitoringVideoError}
+        </div>
+      {/if}
+
+      {#if monitoringVideoUrl}
+        <div class="w-full aspect-video rounded overflow-hidden border border-gray-200 dark:border-gray-700 bg-black">
+          <iframe
+            title="Vídeo do monitoramento"
+            class="w-full h-full"
+            src={monitoringVideoUrl}
+            frameborder="0"
+            allow="autoplay; fullscreen; picture-in-picture"
+            allowfullscreen
+          />
+        </div>
+      {:else if !monitoringVideoError}
+        <p class="text-sm text-gray-500">Nenhum link selecionado.</p>
+      {/if}
+    </div>
+  </ModalBase>
   <ModalBase
     title="Pagamento via Pix"
     visible={orderPaymentModalVisible}