Ver código fonte

add the discount page

gdias 1 mês atrás
pai
commit
8ae299d204

+ 78 - 0
src/lib/layout/SideBar.svelte

@@ -13,8 +13,14 @@
   import settingsIcon from '$lib/assets/icons/sidebar/settings.svg?raw';
   import logo from '$lib/assets/logo2.png';
   import { onMount } from 'svelte';
+  import { get } from 'svelte/store';
   import { easyCoinBalance, authToken } from '$lib/utils/stores.js';
 
+  const apiUrl = import.meta.env.VITE_API_URL;
+
+  let isSuperadmin = false;
+  let superadminLoading = false;
+
   const navItems = [
     { id: 'dashboard', href: '/dashboard', label: 'Início', icon: dashboardIcon },
     { id: 'cpr', href: '/cpr', label: 'CPR', icon: cprIcon },
@@ -59,14 +65,74 @@
   function handleLogout() {
     clearAllCookies();
     authToken.set(null);
+    isSuperadmin = false;
     closeSidebar();
     goto('/');
   }
 
+  function resolveJwtToken() {
+    const storeToken = get(authToken);
+    if (storeToken) return storeToken;
+    if (typeof document === 'undefined') return null;
+    const m = document.cookie.match(/(?:^|; )auth_token=([^;]+)/);
+    return m ? decodeURIComponent(m[1]) : null;
+  }
+
+  async function checkSuperadmin() {
+    if (!apiUrl) {
+      isSuperadmin = false;
+      return;
+    }
+    const token = resolveJwtToken();
+    if (!token) {
+      isSuperadmin = false;
+      return;
+    }
+    if (superadminLoading) return;
+
+    superadminLoading = true;
+    try {
+      const res = await fetch(`${apiUrl}/auth/superadmin`, {
+        method: 'POST',
+        headers: {
+          'content-type': 'application/json',
+          Authorization: `Bearer ${token}`
+        },
+        body: JSON.stringify({})
+      });
+
+      if (res.status === 401) {
+        isSuperadmin = false;
+        return;
+      }
+
+      const raw = await res.text();
+      let body = null;
+      if (raw) {
+        try {
+          body = JSON.parse(raw);
+        } catch (err) {
+          console.error('[Sidebar] Resposta inválida do endpoint /auth/superadmin:', err);
+        }
+      }
+
+      const superadmin = body?.status === 'ok'
+        && body?.code === 'S_SUPERADMIN'
+        && body?.data?.superadmin === true;
+      isSuperadmin = Boolean(superadmin);
+    } catch (err) {
+      console.error('[Sidebar] Falha ao checar superadmin:', err);
+      isSuperadmin = false;
+    } finally {
+      superadminLoading = false;
+    }
+  }
+
   onMount(() => {
     syncWithViewport();
     const onResize = () => syncWithViewport();
     window.addEventListener('resize', onResize);
+    void checkSuperadmin();
     return () => window.removeEventListener('resize', onResize);
   });
   function formatCoin(n) {
@@ -119,6 +185,18 @@
           </a>
         </li>
       {/each}
+      {#if isSuperadmin}
+        <li>
+          <a
+            href="/admin"
+            on:click={closeSidebar}
+            class="flex items-center w-full px-4 py-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 hover:text-gray-900 dark:hover:text-white font-medium border-l-4 {$page.url.pathname.startsWith('/admin') ? 'border-blue-500 bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-white' : 'border-transparent'}"
+          >
+            <span class="mr-3 [&>svg]:w-5 [&>svg]:h-5 {$page.url.pathname.startsWith('/admin') ? 'text-blue-500' : 'text-gray-500 dark:text-gray-400'}">{@html settingsIcon}</span>
+            Admin
+          </a>
+        </li>
+      {/if}
     </ul>
   </div>
 

+ 366 - 0
src/routes/admin/+page.svelte

@@ -0,0 +1,366 @@
+<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 { onMount } from 'svelte';
+
+  const apiUrl = import.meta.env.VITE_API_URL;
+  const breadcrumb = [{ label: 'Início' }, { label: 'Admin', active: true }];
+
+  const columns = [
+    { key: 'discount_id', label: 'ID' },
+    { key: 'discount_value', label: 'Valor' },
+    { key: 'discount_code', label: 'Código' }
+  ];
+
+  let data = [];
+  let loadError = '';
+  let successMessage = '';
+  let isLoading = false;
+
+  let showCreate = false;
+  let createLoading = false;
+  let createError = '';
+  let createDiscountValue = '';
+  let createDiscountCode = '';
+
+  let showDeleteConfirm = false;
+  let rowToDelete = null;
+  let deleteLoading = false;
+  let deleteError = '';
+
+  function formatBRLFromApiValue(value) {
+    const n = Number(value);
+    const normalized = Number.isFinite(n) ? n / 100 : 0;
+    return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(normalized);
+  }
+
+  function mapDiscountRow(item) {
+    return {
+      __raw: item,
+      discount_id: item?.discount_id ?? '-',
+      discount_value: formatBRLFromApiValue(item?.discount_value),
+      discount_code: item?.discount_code ?? '-'
+    };
+  }
+
+  function resolveDiscountId(row) {
+    return row?.__raw?.discount_id ?? row?.discount_id;
+  }
+
+  function openCreate() {
+    showCreate = true;
+    createError = '';
+    createDiscountValue = '';
+    createDiscountCode = '';
+  }
+
+  function closeCreate() {
+    showCreate = false;
+    createLoading = false;
+    createError = '';
+    createDiscountValue = '';
+    createDiscountCode = '';
+  }
+
+  function safeParseJson(raw) {
+    if (!raw) return null;
+    try {
+      return JSON.parse(raw);
+    } catch (err) {
+      return null;
+    }
+  }
+
+  async function fetchDiscounts() {
+    if (isLoading) 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}/discount/get`, {
+        method: 'POST',
+        headers: {
+          'content-type': 'application/json',
+          Authorization: `Bearer ${token}`
+        },
+        body: JSON.stringify({})
+      });
+
+      if (res.status === 204) {
+        data = [];
+        return;
+      }
+
+      const raw = await res.text();
+      const body = safeParseJson(raw);
+
+      if (!res.ok || body?.status !== 'ok' || body?.code !== 'S_DISCOUNTS') {
+        throw new Error(body?.msg ?? body?.message ?? 'Falha ao carregar descontos.');
+      }
+
+      const list = Array.isArray(body?.data) ? body.data : [];
+      data = list.map(mapDiscountRow);
+    } catch (err) {
+      console.error('[Admin] Erro ao buscar descontos:', err);
+      loadError = err?.message ?? 'Falha ao carregar descontos.';
+      data = [];
+    } finally {
+      isLoading = false;
+    }
+  }
+
+  function handleDeleteRow(e) {
+    const { row } = e?.detail || {};
+    if (!row) return;
+    rowToDelete = row;
+    deleteError = '';
+    deleteLoading = false;
+    showDeleteConfirm = true;
+  }
+
+  async function confirmDelete() {
+    if (deleteLoading) return;
+    deleteError = '';
+
+    const discountIdRaw = resolveDiscountId(rowToDelete);
+    const discountId = Number(discountIdRaw);
+    if (!Number.isInteger(discountId) || discountId <= 0) {
+      deleteError = 'discount_id inválido para exclusão.';
+      return;
+    }
+
+    const token = $authToken;
+    if (!token) {
+      deleteError = 'Token de autenticação não encontrado.';
+      return;
+    }
+
+    deleteLoading = true;
+    let deleted = false;
+
+    try {
+      const res = await fetch(`${apiUrl}/discount/delete`, {
+        method: 'POST',
+        headers: {
+          'content-type': 'application/json',
+          Authorization: `Bearer ${token}`
+        },
+        body: JSON.stringify({ discount_id: discountId })
+      });
+
+      if (res.status === 204) {
+        throw new Error('Desconto 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 desconto (HTTP ${res.status}).`);
+      }
+
+      deleted = true;
+      successMessage = body?.msg ?? body?.message ?? 'Desconto excluído com sucesso!';
+      await fetchDiscounts();
+    } catch (err) {
+      console.error('[Admin] Erro ao excluir desconto:', err);
+      deleteError = err?.message ?? 'Falha ao excluir desconto.';
+    } finally {
+      deleteLoading = false;
+      if (deleted) {
+        showDeleteConfirm = false;
+        rowToDelete = null;
+      }
+    }
+  }
+
+  function cancelDelete() {
+    showDeleteConfirm = false;
+    rowToDelete = null;
+    deleteError = '';
+    deleteLoading = false;
+  }
+
+  async function handleCreateSubmit() {
+    if (createLoading) return;
+    createError = '';
+
+    const discountValue = Number(createDiscountValue);
+    if (!Number.isInteger(discountValue) || discountValue <= 0) {
+      createError = 'Informe um valor inteiro positivo.';
+      return;
+    }
+
+    const discountCode = String(createDiscountCode ?? '').trim();
+    if (!discountCode || discountCode.length > 255) {
+      createError = 'Informe um código de desconto válido (1 a 255 caracteres).';
+      return;
+    }
+
+    const token = $authToken;
+    if (!token) {
+      createError = 'Sessão expirada. Faça login novamente.';
+      return;
+    }
+
+    createLoading = true;
+    try {
+      const res = await fetch(`${apiUrl}/discount/create`, {
+        method: 'POST',
+        headers: {
+          'content-type': 'application/json',
+          Authorization: `Bearer ${token}`
+        },
+        body: JSON.stringify({
+          discount_value: discountValue,
+          discount_code: discountCode
+        })
+      });
+
+      const raw = await res.text();
+      const body = safeParseJson(raw);
+
+      const isSuccess = res.ok && body?.status === 'ok' && body?.code === 'S_CREATED' && body?.data?.discount_id != null;
+      if (!isSuccess) {
+        throw new Error(body?.msg ?? body?.message ?? 'Falha ao criar desconto.');
+      }
+
+      successMessage = body?.msg ?? body?.message ?? 'Desconto criado com sucesso!';
+      closeCreate();
+      await fetchDiscounts();
+    } catch (err) {
+      console.error('[Admin] Erro ao criar desconto:', err);
+      createError = err?.message ?? 'Falha ao criar desconto.';
+    } finally {
+      createLoading = false;
+    }
+  }
+
+  onMount(fetchDiscounts);
+</script>
+
+<div>
+  <Header title="Admin" subtitle="Gestão de descontos" breadcrumb={breadcrumb} />
+
+  <div class="p-4">
+    <div class="max-w-6xl mx-auto mt-4 space-y-4">
+      {#if loadError}
+        <div class="rounded border border-red-300 bg-red-50 text-sm text-red-700 px-3 py-2 dark:border-red-700 dark:bg-red-900/30 dark:text-red-200">{loadError}</div>
+      {/if}
+
+      {#if isLoading}
+        <div class="rounded border border-gray-200 bg-gray-50 text-sm text-gray-700 px-3 py-2 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-200">Carregando descontos...</div>
+      {/if}
+
+      {#if successMessage}
+        <div class="rounded border border-green-300 bg-green-50 text-green-800 text-sm px-3 py-2 flex items-start justify-between gap-3 dark:border-green-700 dark:bg-green-900/20 dark:text-green-200">
+          <span>{successMessage}</span>
+          <button type="button" class="text-xs font-semibold uppercase tracking-wide" on:click={() => (successMessage = '')}>Fechar</button>
+        </div>
+      {/if}
+
+      <Tables
+        title="Descontos"
+        {columns}
+        {data}
+        on:addTop={openCreate}
+        on:deleteRow={handleDeleteRow}
+        showEdit={false}
+      />
+
+      {#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-lg 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 desconto</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">Valor</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="number"
+                  min="1"
+                  step="1"
+                  bind:value={createDiscountValue}
+                  disabled={createLoading}
+                />
+              </div>
+
+              <div>
+                <label class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Código</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="255"
+                  bind:value={createDiscountCode}
+                  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 desconto'}
+                </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 desconto "{rowToDelete?.discount_code}"?</p>
+        {#if deleteError}
+          <p class="mt-3 text-sm text-red-600 dark:text-red-400">{deleteError}</p>
+        {/if}
+      </ConfirmModal>
+    </div>
+  </div>
+</div>

+ 20 - 0
src/routes/cpr/+page.svelte

@@ -293,6 +293,7 @@
   let isCheckingPayment = false;
   let paymentSuccessOverlayVisible = false;
   let paymentSuccessMessage = '';
+  let emissionDiscountCode = '';
 
   const breadcrumb = [{ label: 'Início' }, { label: 'CPR', active: true }];
   let activeTab = 4;
@@ -379,6 +380,7 @@
   function handleCancel() {
     activeTab = 4;
     submitError = '';
+    emissionDiscountCode = '';
   }
 
   function ensureAuthContext() {
@@ -944,6 +946,7 @@
     }
 
     const payload = buildPayload();
+    payload.discount = typeof emissionDiscountCode === 'string' ? emissionDiscountCode.trim() : '';
     isSubmitting = true;
     paymentLoadingVisible = true;
 
@@ -983,6 +986,7 @@
       const paymentData = response?.data;
       cprForm = createInitialForm();
       repeatingGroups = createInitialRepeatingGroups();
+      emissionDiscountCode = '';
       if (paymentData?.payment_id && paymentData?.payment_code) {
         await openPaymentModalWithPayment(paymentData);
       }
@@ -1222,6 +1226,22 @@
           onRemoveRepeatingEntry={handleRemoveRepeatingEntry}
         />
       </div>
+      <div class="mt-4 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-sm">
+        <div class="p-4 space-y-1">
+          <label class="block text-sm font-medium text-gray-700 dark:text-gray-300" for="cpr-emission-discount-code">
+            Código de desconto de emissão
+          </label>
+          <input
+            id="cpr-emission-discount-code"
+            type="text"
+            class="w-full 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"
+            bind:value={emissionDiscountCode}
+            disabled={isSubmitting}
+            placeholder="Opcional"
+            autocomplete="off"
+          />
+        </div>
+      </div>
       <!-- Navigation Controls -->
       <div class="flex justify-between mt-6">
         <button