feature/reporte-saldos-vencimiento

This commit is contained in:
2026-04-21 09:56:27 -06:00
parent 1cd81ebc03
commit 0a4cfb554b
10 changed files with 964 additions and 0 deletions

View File

@@ -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);
}
};

View File

@@ -516,6 +516,10 @@ export function getSidebarData(): SidebarData {
title: "Partes descargadas",
url: "/dashboard/reports/partes-descargadas",
},
{
title: "Reporte de Vencimiento",
url: "/dashboard/reports/vencimiento",
},
],
},
{

View File

@@ -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'
};
};

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