|
@@ -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>
|