feature/reporte-saldos-vencimiento
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* API Client for Reporte de Vencimiento (synchronous CSV download)
|
||||
*/
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export interface VencimientoFilter {
|
||||
days_ahead: number;
|
||||
currency: 'foreign' | 'national';
|
||||
client_id?: number | null;
|
||||
min_balance: number;
|
||||
conforme_anexo_31: boolean;
|
||||
usar_fecha_corte: boolean;
|
||||
fecha_corte?: string | null;
|
||||
send_email: boolean;
|
||||
julian_date: boolean;
|
||||
}
|
||||
|
||||
export const vencimientoReportApi = {
|
||||
/** Generate and download CSV synchronously */
|
||||
generate: async (companyId: number, filters: VencimientoFilter): Promise<void> => {
|
||||
const blob = await api.postBlob(
|
||||
`/v1/a76/reports/movements/vencimiento/generate?company_id=${companyId}`,
|
||||
filters
|
||||
);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `vencimiento_${today}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
};
|
||||
@@ -516,6 +516,10 @@ export function getSidebarData(): SidebarData {
|
||||
title: "Partes descargadas",
|
||||
url: "/dashboard/reports/partes-descargadas",
|
||||
},
|
||||
{
|
||||
title: "Reporte de Vencimiento",
|
||||
url: "/dashboard/reports/vencimiento",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { getAuthTokens } from '$lib/server/api';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
const { accessToken } = getAuthTokens(cookies);
|
||||
|
||||
if (!accessToken) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Reporte de Vencimiento'
|
||||
};
|
||||
};
|
||||
312
frontend/src/routes/dashboard/reports/vencimiento/+page.svelte
Normal file
312
frontend/src/routes/dashboard/reports/vencimiento/+page.svelte
Normal file
@@ -0,0 +1,312 @@
|
||||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import {
|
||||
CalendarClock,
|
||||
Printer,
|
||||
X,
|
||||
BadgeDollarSign,
|
||||
Users,
|
||||
Filter,
|
||||
Mail,
|
||||
Calendar,
|
||||
AlertTriangle
|
||||
} from 'lucide-svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import * as Select from '$lib/components/ui/select';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { clientsProvidersApi, type ClientProvider } from '$lib/api/dashboard/a76/clients-providers';
|
||||
import { vencimientoReportApi } from '$lib/api/dashboard/a76/reports/reports-vencimiento';
|
||||
|
||||
const CLEAR_SELECT_VALUE = '__clear__';
|
||||
|
||||
let isGenerating = $state(false);
|
||||
let isCatalogLoading = $state(false);
|
||||
let lastCompanyId = $state<number | null>(null);
|
||||
let clients = $state<ClientProvider[]>([]);
|
||||
|
||||
// ── Form state ────────────────────────────────────────────────────────────
|
||||
let daysAhead = $state(0);
|
||||
let currency = $state<'foreign' | 'national'>('foreign');
|
||||
let clientId = $state('');
|
||||
let minBalance = $state('0');
|
||||
let conformeAnexo31 = $state(false);
|
||||
let usarFechaCorte = $state(false);
|
||||
let fechaCorte = $state('');
|
||||
let sendEmail = $state(false);
|
||||
let julianDate = $state(false);
|
||||
|
||||
// ── Derived ───────────────────────────────────────────────────────────────
|
||||
let clientOptions = $derived.by(() =>
|
||||
clients.filter((c) => c.client_or_provider === 'client' || c.client_or_provider === 'both')
|
||||
);
|
||||
|
||||
function normalizeSelectValue(value?: string) {
|
||||
return value === CLEAR_SELECT_VALUE ? '' : (value ?? '');
|
||||
}
|
||||
|
||||
function selectPlaceholder() {
|
||||
return isCatalogLoading ? 'Cargando...' : 'Selecciona...';
|
||||
}
|
||||
|
||||
// ── Catalog loading ───────────────────────────────────────────────────────
|
||||
async function loadCatalogs(companyId: number) {
|
||||
isCatalogLoading = true;
|
||||
try {
|
||||
const res = await clientsProvidersApi.list(companyId, 1, 1000);
|
||||
clients = res.data?.items ?? [];
|
||||
} catch {
|
||||
toast.error('No se pudieron cargar los catálogos');
|
||||
} finally {
|
||||
isCatalogLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const companyId = companyStore.activeCompany?.id ?? null;
|
||||
if (!companyId) {
|
||||
lastCompanyId = null;
|
||||
clients = [];
|
||||
return;
|
||||
}
|
||||
if (companyId === lastCompanyId) return;
|
||||
lastCompanyId = companyId;
|
||||
void loadCatalogs(companyId);
|
||||
});
|
||||
|
||||
// ── Generate ──────────────────────────────────────────────────────────────
|
||||
async function runReport() {
|
||||
const companyId = companyStore.activeCompany?.id;
|
||||
if (!companyId) {
|
||||
toast.error('No hay empresa activa seleccionada');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedDays = parseInt(String(daysAhead), 10);
|
||||
if (isNaN(parsedDays) || parsedDays < 0) {
|
||||
toast.error('El número de días debe ser un entero positivo');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedBalance = parseFloat(minBalance);
|
||||
if (isNaN(parsedBalance) || parsedBalance < 0) {
|
||||
toast.error('El balance mínimo debe ser un número positivo');
|
||||
return;
|
||||
}
|
||||
|
||||
isGenerating = true;
|
||||
try {
|
||||
await vencimientoReportApi.generate(companyId, {
|
||||
days_ahead: parsedDays,
|
||||
currency,
|
||||
client_id: clientId ? parseInt(clientId, 10) : null,
|
||||
min_balance: parsedBalance,
|
||||
conforme_anexo_31: conformeAnexo31,
|
||||
usar_fecha_corte: usarFechaCorte,
|
||||
fecha_corte: usarFechaCorte && fechaCorte ? fechaCorte : null,
|
||||
send_email: sendEmail,
|
||||
julian_date: julianDate
|
||||
});
|
||||
toast.success('Reporte descargado exitosamente');
|
||||
} catch (err: any) {
|
||||
toast.error(err?.message || 'Error al generar el reporte');
|
||||
} finally {
|
||||
isGenerating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="animate-in fade-in slide-in-from-bottom-4 flex min-h-full flex-col gap-2 pb-4 duration-500">
|
||||
<!-- Header -->
|
||||
<div class="flex shrink-0 items-center justify-between px-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="flex items-center gap-2 text-xl font-bold tracking-tight text-foreground">
|
||||
<CalendarClock class="h-6 w-6 text-primary" />
|
||||
Reporte de Vencimiento
|
||||
</h1>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">Reportes de Control Fiscal</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div class="grid min-h-0 flex-1 grid-cols-1 content-start items-start gap-3 text-foreground xl:grid-cols-3">
|
||||
|
||||
<!-- ── Card 1: Parámetro principal ───────────────────────────────────── -->
|
||||
<Card.Root class="flex h-full flex-col gap-0 py-0">
|
||||
<Card.Header class="shrink-0 border-b bg-muted/20 px-3 py-2">
|
||||
<Card.Title class="flex items-center gap-2 text-sm font-semibold text-primary">
|
||||
<AlertTriangle class="h-4 w-4" /> Vencimiento
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex-1 space-y-4 p-3">
|
||||
<!-- Días a vencer -->
|
||||
<div class="space-y-2 rounded-md border p-3">
|
||||
<p class="text-xs font-bold uppercase text-muted-foreground">
|
||||
Facturas próximas a vencer dentro de los
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
class="h-8 w-24 text-center"
|
||||
bind:value={daysAhead}
|
||||
/>
|
||||
<span class="text-sm text-muted-foreground">días.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tipo de moneda -->
|
||||
<div class="space-y-2 rounded-md border p-3">
|
||||
<p class="flex items-center gap-2 text-xs font-bold uppercase text-muted-foreground">
|
||||
<BadgeDollarSign class="h-3.5 w-3.5" /> Tipo de Moneda
|
||||
</p>
|
||||
<RadioGroup.Root bind:value={currency} class="flex flex-col gap-2">
|
||||
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
|
||||
<RadioGroup.Item value="foreign" id="currency-foreign" class="h-4 w-4" />
|
||||
<Label for="currency-foreign" class="cursor-pointer text-sm">Extranjera (USD)</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
|
||||
<RadioGroup.Item value="national" id="currency-national" class="h-4 w-4" />
|
||||
<Label for="currency-national" class="cursor-pointer text-sm">Nacional (MXP)</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- ── Card 2: Filtros ────────────────────────────────────────────────── -->
|
||||
<Card.Root class="flex h-full flex-col gap-0 py-0">
|
||||
<Card.Header class="shrink-0 border-b bg-muted/20 px-3 py-2">
|
||||
<Card.Title class="flex items-center gap-2 text-sm font-semibold text-primary">
|
||||
<Filter class="h-4 w-4" /> Filtrar por
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex-1 space-y-4 p-3">
|
||||
<!-- Filtrar por cliente -->
|
||||
<div class="space-y-2 rounded-md border p-3">
|
||||
<p class="flex items-center gap-2 text-xs font-bold uppercase text-muted-foreground">
|
||||
<Users class="h-3.5 w-3.5" /> Cliente
|
||||
</p>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={clientId}
|
||||
onValueChange={(v) => (clientId = normalizeSelectValue(v))}
|
||||
>
|
||||
<Select.Trigger class="h-8 w-full text-xs">
|
||||
<span class="truncate">
|
||||
{#if clientId}
|
||||
{@const selected = clientOptions.find((c) => String(c.id) === clientId)}
|
||||
{selected?.name ?? selectPlaceholder()}
|
||||
{:else}
|
||||
{selectPlaceholder()}
|
||||
{/if}
|
||||
</span>
|
||||
</Select.Trigger>
|
||||
<Select.Content class="max-h-[300px]">
|
||||
<Select.Item value={CLEAR_SELECT_VALUE}>Todos</Select.Item>
|
||||
{#if clientOptions.length}
|
||||
{#each clientOptions as item}
|
||||
<Select.Item value={String(item.id)}>{item.name}</Select.Item>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="px-3 py-2 text-xs text-muted-foreground">Sin opciones</div>
|
||||
{/if}
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</div>
|
||||
|
||||
<!-- Omitir cantidades -->
|
||||
<div class="space-y-2 rounded-md border p-3">
|
||||
<p class="text-xs font-bold uppercase text-muted-foreground">
|
||||
Omitir cantidades con balance menor a
|
||||
</p>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.0001"
|
||||
class="h-8 w-36"
|
||||
bind:value={minBalance}
|
||||
/>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- ── Card 3: Opciones ───────────────────────────────────────────────── -->
|
||||
<Card.Root class="flex h-full flex-col gap-0 py-0">
|
||||
<Card.Header class="shrink-0 border-b bg-muted/20 px-3 py-2">
|
||||
<Card.Title class="flex items-center gap-2 text-sm font-semibold text-primary">
|
||||
<Calendar class="h-4 w-4" /> Filtro Opcional
|
||||
</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex-1 space-y-2 p-3">
|
||||
<div class="space-y-2 rounded-md border p-3">
|
||||
<div class="flex items-center space-x-2 rounded-md border px-3 py-2">
|
||||
<Checkbox id="opt-anexo31" bind:checked={conformeAnexo31} class="h-4 w-4" />
|
||||
<Label for="opt-anexo31" class="cursor-pointer text-sm">Conforme al Anexo 31</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2 rounded-md border px-3 py-2">
|
||||
<Checkbox id="opt-fecha-corte" bind:checked={usarFechaCorte} class="h-4 w-4" />
|
||||
<Label for="opt-fecha-corte" class="cursor-pointer text-sm">Usar Fecha de Corte</Label>
|
||||
</div>
|
||||
{#if usarFechaCorte}
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs text-muted-foreground">Fecha de Corte</Label>
|
||||
<Input
|
||||
type="date"
|
||||
class="h-8 w-full"
|
||||
bind:value={fechaCorte}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- ── Bottom bar: extra opciones + acciones ──────────────────────────── -->
|
||||
<Card.Root class="gap-0 py-0 xl:col-span-3">
|
||||
<Card.Content class="p-3">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox id="opt-email" bind:checked={sendEmail} class="h-4 w-4" />
|
||||
<Label for="opt-email" class="flex cursor-pointer items-center gap-1.5 text-sm">
|
||||
<Mail class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Enviar por correo electrónico.
|
||||
</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox id="opt-julian" bind:checked={julianDate} class="h-4 w-4" />
|
||||
<Label for="opt-julian" class="flex cursor-pointer items-center gap-1.5 text-sm">
|
||||
<Calendar class="h-3.5 w-3.5 text-muted-foreground" />
|
||||
Usar Fecha Juliana en Reporte de Excel.
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer class="gap-2 border-t bg-muted/10 p-2.5">
|
||||
<Button
|
||||
class="h-9 flex-1 text-sm shadow-sm"
|
||||
size="default"
|
||||
onclick={runReport}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<Printer class="mr-2 h-3.5 w-3.5" />
|
||||
{isGenerating ? 'Generando...' : 'Imprimir'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-9 w-9 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onclick={() => history.back()}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user