feature/reportes

This commit is contained in:
2026-04-16 15:59:18 -06:00
parent 11db840a11
commit de8f944a35
16 changed files with 2321 additions and 45 deletions

View File

@@ -519,6 +519,12 @@ async function fetchBlob(endpoint: string, options: RequestInit = {}): Promise<B
export const api = {
get: <T = any>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
getBlob: (endpoint: string) => fetchBlob(endpoint, { method: 'GET' }),
postBlob: (endpoint: string, body: any) =>
fetchBlob(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}),
post: <T = any>(endpoint: string, body: any, options: RequestInit = {}) =>
fetchApi<T>(endpoint, {

View File

@@ -0,0 +1,76 @@
import { api } from '$lib/api';
export interface DownloadedPartsReportSection {
id: string;
title: string;
description: string;
}
export interface DownloadedPartsReportBootstrap {
report_key: string;
title: string;
description: string;
company_id: number;
tenant_id: number;
status: string;
available_filters: string[];
next_steps: string[];
sections: DownloadedPartsReportSection[];
}
export interface DownloadedPartsReportRequest {
date_from: string;
date_to: string;
class_from?: string;
class_to?: string;
print_class_mode: 'exported' | 'downloaded';
exchange_rate_mode: 'invoice' | 'pedimento_payment';
currency_mode: 'dollars' | 'pesos' | 'both';
temporality_mode: 'temporales' | 'definitivos' | 'ambos';
weight_type_mode: 'kilos' | 'libras' | 'ambos';
operation_mode: 'importacion' | 'exportacion';
material_type?: string;
invoice_type?: string;
parts?: string[];
pedimento_key?: string;
provider_id?: number;
sold_to_id?: number;
shipped_to_id?: number;
destination_customs?: string;
include_series: boolean;
print_class_total: boolean;
include_totals_by_fraction: boolean;
julian_date: boolean;
show_item_description: boolean;
include_exempt_fraction: boolean;
show_export_fraction: boolean;
include_rule_octava: boolean;
include_american_fraction_and_country: boolean;
respect_import_invoice_value_in_pesos: boolean;
show_all_temporary_balances: boolean;
}
export const downloadedPartsReportsApi = {
getBootstrap: (companyId: number) =>
api.get<DownloadedPartsReportBootstrap>(
`/v1/a76/reports/exportacion/partes-descargadas/bootstrap?company_id=${companyId}`
),
generate: async (
companyId: number,
params: DownloadedPartsReportRequest
): Promise<void> => {
const blob = await api.postBlob(
`/v1/a76/reports/exportacion/partes-descargadas/generate?company_id=${companyId}`,
params
);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `partes_descargadas_${params.date_from}_${params.date_to}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
};

View File

@@ -3,13 +3,15 @@
import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { FolderSearch, Scale } from 'lucide-svelte';
import UnitMeasureSelectorDialog from '$lib/components/dashboard/goods/modales/unit-measure-dialog.svelte';
import { companyStore } from '$lib/stores/company.svelte';
import { Scale } from 'lucide-svelte';
import {
createEquivalencyItem,
updateEquivalencyItem,
type EquivalencyItem
} from '$lib/api/dashboard/a76/general_catalogs/equivalencies';
import type { UnitOfMeasure } from '$lib/api/dashboard/a76/general_catalogs/units-of-measure';
let {
open = $bindable(false),
@@ -37,25 +39,33 @@
let loading = $state(false);
let error = $state<string | null>(null);
let showOriginalModal = $state(false);
let showExternalModal = $state(false);
$effect(() => {
if (!open) return;
if (item) {
formData = {
original_field: item.original_field || '',
external_field: item.external_field || ''
};
formData.original_field = item.original_field || '';
formData.external_field = item.external_field || '';
} else {
formData = {
original_field: defaultOriginalField ?? '',
external_field: ''
};
formData.original_field = defaultOriginalField ?? '';
formData.external_field = '';
}
error = null;
});
function handleSelectOriginal(unit: UnitOfMeasure) {
formData.original_field = unit.code;
showOriginalModal = false;
}
function handleSelectExternal(unit: UnitOfMeasure) {
formData.external_field = unit.code;
showExternalModal = false;
}
async function handleSubmit() {
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
@@ -113,40 +123,64 @@
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label for="from_unit_code">
<Label for="original_field">
Campo Original <span class="text-destructive">*</span>
</Label>
<div class="flex gap-2">
<div class="relative w-full">
<Scale class="absolute top-2.5 left-3 h-4 w-4 text-muted-foreground" />
<Input
id="from_unit_code"
bind:value={formData.original_field}
class="pl-9 font-mono"
placeholder="Ej: PZA, KGM..."
disabled={loading}
required
/>
<Input
id="original_field"
bind:value={formData.original_field}
readonly
onclick={() => (showOriginalModal = true)}
class="cursor-pointer pl-9 font-mono"
placeholder="Seleccione..."
disabled={loading}
required
/>
</div>
<Button
variant="outline"
size="icon"
type="button"
onclick={() => (showOriginalModal = true)}
disabled={loading}
class="shrink-0"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
<div class="grid gap-2">
<Label for="to_unit_code">
<Label for="external_field">
Campo Exterior <span class="text-destructive">*</span>
</Label>
<div class="flex gap-2">
<div class="relative w-full">
<Scale class="absolute top-2.5 left-3 h-4 w-4 text-muted-foreground" />
<Input
id="to_unit_code"
bind:value={formData.external_field}
class="pl-9 font-mono"
placeholder="Ej: PIEZAS, KGS..."
disabled={loading}
required
/>
<Input
id="external_field"
bind:value={formData.external_field}
readonly
onclick={() => (showExternalModal = true)}
class="cursor-pointer pl-9 font-mono"
placeholder="Seleccione..."
disabled={loading}
required
/>
</div>
<Button
variant="outline"
size="icon"
type="button"
onclick={() => (showExternalModal = true)}
disabled={loading}
class="shrink-0"
>
<FolderSearch class="h-4 w-4" />
</Button>
</div>
</div>
</div>
@@ -160,3 +194,6 @@
</form>
</Dialog.Content>
</Dialog.Root>
<UnitMeasureSelectorDialog bind:open={showOriginalModal} onSelect={handleSelectOriginal} />
<UnitMeasureSelectorDialog bind:open={showExternalModal} onSelect={handleSelectExternal} />

View File

@@ -33,13 +33,9 @@
operation_type: operationType,
invoice_number: searchTerm || undefined
};
if (operationType === 'imp' && regimen) {
if (regimen === 'Temporal' || regimen === 'TEMPORAL SCAF') {
filters.invoice_type = 'TEM';
} else if (regimen === 'Definitiva' || regimen === 'DEFINITIVO SCAF') {
filters.invoice_type = 'DEF';
}
}
// Do NOT filter by invoice_type here: restricting to TEM or DEF based on
// the current movement_type_import value would hide valid invoices of the
// other type. Let the user search freely and pick the right one.
const res = await invoicesApi.list(companyStore.activeCompany.id, 1, 50, filters);
if (res.data) {
@@ -134,7 +130,8 @@
{invoice.invoice_number}
</td>
<td class="max-w-[400px] truncate p-3 text-muted-foreground italic">
{invoice.compliance_mx?.pedimento_r1 ||
{invoice.compliance_mx?.pedimento?.pedimento_number ||
invoice.compliance_mx?.pedimento_r1 ||
invoice.compliance_mx?.pedimento_id ||
'-'}
</td>

View File

@@ -164,10 +164,12 @@
if (showLinkToImportBlock && num && !selectedImportInvoiceId && !loadingImportLines) {
(async () => {
try {
const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 5, {
// Do NOT filter by invoice_type: if movement_type_import is null or
// mismatched the invoice won't be found, leaving the line picker
// permanently disabled. Exact match is enforced by .find() below.
const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 50, {
operation_type: 'imp',
invoice_number: num,
invoice_type: movementType === 'DEF' ? 'DEF' : 'TEM'
invoice_number: num
});
const items = res.data?.items ?? [];
const inv = items.find((i: Invoice) => i.invoice_number === num);
@@ -183,7 +185,7 @@
if (showRepairBlock && num && !selectedExportInvoiceId && !loadingExportLines) {
(async () => {
try {
const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 5, {
const res = await invoicesApi.list(companyStore.activeCompany!.id, 1, 50, {
operation_type: 'exp',
invoice_number: num
});
@@ -735,7 +737,7 @@
<Dialog.Header>
<Dialog.Title class="text-sm">Seleccionar línea de importación</Dialog.Title>
<p class="text-xs text-muted-foreground mt-0.5">
Solo se muestran líneas con saldo disponible
Líneas sin saldo disponible se muestran en gris.
</p>
</Dialog.Header>
<!-- overflow-x on a wrapper that does NOT also do overflow-y.
@@ -743,9 +745,9 @@
independently from the horizontal scrollbar. -->
<div class="overflow-x-auto">
<div class="overflow-y-auto max-h-[500px]">
{#if importInvoiceLines.every(l => !l.has_balance)}
{#if importInvoiceLines.length === 0}
<p class="px-3 py-8 text-xs text-muted-foreground text-center">
No hay líneas con saldo disponible en esta factura.
No hay líneas en esta factura.
</p>
{:else}
<table class="text-xs border-collapse" style="min-width: max-content; width: 100%;">
@@ -768,9 +770,8 @@
</thead>
<tbody class="divide-y divide-border">
{#each importInvoiceLines as lineItem}
{#if lineItem.has_balance}
<tr
class="hover:bg-muted/50 cursor-pointer transition-colors group"
class="{lineItem.has_balance ? 'hover:bg-muted/50 cursor-pointer' : 'opacity-50 cursor-default'} transition-colors group"
onclick={() => {
editingItem.fa_data = editingItem.fa_data || {};
editingItem.fa_data.search_line = lineItem.line_number;
@@ -830,7 +831,6 @@
{/if}
</td>
</tr>
{/if}
{/each}
</tbody>
</table>

View File

@@ -507,6 +507,10 @@ export function getSidebarData(): SidebarData {
title: "Facturas Impo/Expo",
url: "/dashboard/reports/invoices",
},
{
title: "Partes descargadas",
url: "/dashboard/reports/partes-descargadas",
},
],
},
{

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: 'Partes descargadas'
};
};

View File

@@ -0,0 +1,865 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import {
FileSearch,
Filter,
Boxes,
Search,
Printer,
X,
Package,
Scale,
BadgeDollarSign,
Clock3
} 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 { partsApi, type Part } from '$lib/api/dashboard/a76/parts';
import { classesApi, type A76Class } from '$lib/api/dashboard/a76/classes';
import { invoiceTypesApi, type InvoiceType } from '$lib/api/dashboard/reference_data/invoice_types';
import { pedimentoCodesApi, type PedimentoCode } from '$lib/api/dashboard/reference_data/pedimento_codes';
import { materialTypesApi, type MaterialType } from '$lib/api/dashboard/reference_data/material_types';
import { customsSectionsApi, type CustomsSection } from '$lib/api/dashboard/reference_data/customs_sections';
import {
downloadedPartsReportsApi,
type DownloadedPartsReportBootstrap,
type DownloadedPartsReportRequest
} from '$lib/api/dashboard/a76/reports/reports-partes-descargadas';
type FilterFieldKey =
| 'materialType'
| 'invoiceType'
| 'parts'
| 'pedimentoKey'
| 'provider'
| 'destinationCustoms'
| 'soldTo'
| 'shippedTo';
type OptionKey =
| 'sendEmailSelection'
| 'respectImportInvoiceValueInPesos'
| 'showAllTemporaryBalances'
| 'shelterOption'
| 'showExportFraction'
| 'includeRuleOctava'
| 'includeAmericanFractionAndCountry'
| 'includeSeries'
| 'printClassTotal'
| 'includeTotalsByFraction'
| 'julianDate'
| 'showItemDescription'
| 'includeExemptFraction';
let bootstrap = $state<DownloadedPartsReportBootstrap | null>(null);
let isLoading = $state(false);
let isGenerating = $state(false);
let isCatalogLoading = $state(false);
let lastCompanyId = $state<number | null>(null);
let clientsProviders = $state<ClientProvider[]>([]);
let invoiceTypes = $state<InvoiceType[]>([]);
let pedimentoCodes = $state<PedimentoCode[]>([]);
let materialTypes = $state<MaterialType[]>([]);
let customsSections = $state<CustomsSection[]>([]);
let partsCatalog = $state<Part[]>([]);
let classesCatalog = $state<A76Class[]>([]);
let dates = $state({ from: '', to: '' });
let classRange = $state({ from: '', to: '' });
let filters = $state<Record<FilterFieldKey, string>>({
materialType: '',
invoiceType: '',
parts: '',
pedimentoKey: '',
provider: '',
destinationCustoms: '',
soldTo: '',
shippedTo: ''
});
let printClassMode = $state<'exported' | 'downloaded'>('downloaded');
let currencyMode = $state<'dollars' | 'pesos' | 'both'>('both');
let exchangeRateMode = $state<'invoice' | 'pedimento_payment'>('invoice');
let temporalityMode = $state<'temporales' | 'definitivos' | 'ambos'>('temporales');
let weightTypeMode = $state<'kilos' | 'libras' | 'ambos'>('kilos');
let operationMode = $state<'importacion' | 'exportacion'>('importacion');
let options = $state<Record<OptionKey, boolean>>({
sendEmailSelection: false,
respectImportInvoiceValueInPesos: false,
showAllTemporaryBalances: false,
shelterOption: false,
showExportFraction: false,
includeRuleOctava: false,
includeAmericanFractionAndCountry: false,
includeSeries: false,
printClassTotal: false,
includeTotalsByFraction: false,
julianDate: false,
showItemDescription: true,
includeExemptFraction: false
});
const leftOptions: Array<{ id: string; label: string; key: OptionKey }> = [
{
id: 'email-selection',
label: 'Seleccione para enviar correo electrónico',
key: 'sendEmailSelection'
},
{
id: 'respect-pesos',
label: 'Respetar Valor en Pesos Facturas de Importación',
key: 'respectImportInvoiceValueInPesos'
},
{
id: 'show-temp-balances',
label: 'Mostrar todos los saldos temporales',
key: 'showAllTemporaryBalances'
},
{ id: 'shelter-option', label: 'Opción Shelter', key: 'shelterOption' },
{
id: 'show-export-fraction',
label: 'Mostrar Fraccion de Exportación',
key: 'showExportFraction'
},
{ id: 'include-rule-8', label: 'Incluir Regla Octava', key: 'includeRuleOctava' },
{
id: 'include-american-fraction',
label: 'Incluir Fracción Americana y País de Origen',
key: 'includeAmericanFractionAndCountry'
}
];
const rightOptions: Array<{ id: string; label: string; key: OptionKey }> = [
{ id: 'include-series', label: 'Incluir Series', key: 'includeSeries' },
{ id: 'print-total-class', label: 'Imprimir Total Clase', key: 'printClassTotal' },
{
id: 'include-totals-fraction',
label: 'Incluir Totales x Fracción',
key: 'includeTotalsByFraction'
},
{ id: 'julian-date', label: 'Fecha Juliana', key: 'julianDate' },
{
id: 'show-item-description',
label: 'Mostrar Descripcion de Partida',
key: 'showItemDescription'
},
{
id: 'include-exempt-fraction',
label: 'Incluir Fraccion Exenta',
key: 'includeExemptFraction'
}
];
const allOptions = [...leftOptions, ...rightOptions];
const CLEAR_SELECT_VALUE = '__clear__';
async function loadBootstrap(companyId?: number) {
const activeCompanyId = companyId ?? companyStore.activeCompany?.id;
if (!activeCompanyId) {
bootstrap = null;
return;
}
isLoading = true;
const response = await downloadedPartsReportsApi.getBootstrap(activeCompanyId);
if (response.data) {
bootstrap = response.data;
} else {
toast.error(response.error || 'No se pudo cargar la base del reporte');
}
isLoading = false;
}
async function loadCatalogs(companyId: number) {
isCatalogLoading = true;
try {
const [
clientsProvidersResponse,
invoiceTypesResponse,
pedimentoCodesResponse,
materialTypesResponse,
customsSectionsResponse,
partsResponse,
classesResponse
] = await Promise.all([
clientsProvidersApi.list(companyId, 1, 1000),
invoiceTypesApi.list(1, 1000),
pedimentoCodesApi.list(1, 1000),
materialTypesApi.list(1, 1000),
customsSectionsApi.list(1, 1000),
partsApi.list({ company_id: companyId, page: 1, page_size: 1000 }),
classesApi.getWithFAData({ company_id: companyId, page: 1, page_size: 1000 })
]);
clientsProviders = clientsProvidersResponse.data?.items || [];
invoiceTypes = invoiceTypesResponse.data?.items || [];
pedimentoCodes = pedimentoCodesResponse.data?.items || [];
materialTypes = materialTypesResponse.data?.items || [];
customsSections = customsSectionsResponse.data?.items || [];
partsCatalog = partsResponse.data?.items || [];
classesCatalog = classesResponse.data || [];
} catch (error) {
console.error('Error cargando catálogos para Partes descargadas:', error);
toast.error('No se pudieron cargar algunos catálogos');
} finally {
isCatalogLoading = false;
}
}
let providerOptions = $derived.by(() =>
clientsProviders.filter(
(item) => item.client_or_provider === 'provider' || item.client_or_provider === 'both'
)
);
let soldToOptions = $derived.by(() =>
clientsProviders.filter(
(item) => item.client_or_provider === 'client' || item.client_or_provider === 'both'
)
);
let shippedToOptions = $derived.by(() => {
const unique = new Map<number, ClientProvider>();
clientsProviders.forEach((item) => unique.set(item.id, item));
return Array.from(unique.values());
});
let filteredInvoiceTypes = $derived.by(() => {
const op = operationMode === 'importacion' ? 'imp' : 'exp';
return invoiceTypes.filter((item) => !item.operation || item.operation === 'both' || item.operation === op);
});
$effect(() => {
const companyId = companyStore.activeCompany?.id ?? null;
if (!companyId) {
lastCompanyId = null;
bootstrap = null;
clientsProviders = [];
invoiceTypes = [];
pedimentoCodes = [];
materialTypes = [];
customsSections = [];
partsCatalog = [];
classesCatalog = [];
return;
}
if (companyId === lastCompanyId) {
return;
}
lastCompanyId = companyId;
void loadBootstrap(companyId);
void loadCatalogs(companyId);
});
$effect(() => {
if (!filters.invoiceType) {
return;
}
const stillExists = filteredInvoiceTypes.some((item) => item.key === filters.invoiceType);
if (!stillExists) {
filters.invoiceType = '';
}
});
function selectPlaceholder() {
return isCatalogLoading ? 'Cargando...' : 'Selecciona...';
}
function normalizeSelectValue(value?: string) {
return value === CLEAR_SELECT_VALUE ? '' : value ?? '';
}
async function runReport() {
if (!dates.from) {
toast.error('Es necesario asignar la fecha inicial para generar el reporte');
return;
}
if (!dates.to) {
toast.error('Es necesario asignar la fecha final para generar el reporte');
return;
}
if (dates.to < dates.from) {
toast.error('La fecha inicial no puede ser superior a la final');
return;
}
if (classRange.from && !classRange.to) {
toast.error('Es necesario asignar la clase final para generar el reporte');
return;
}
if (!classRange.from && classRange.to) {
toast.error('Es necesario asignar la clase inicial para generar el reporte');
return;
}
const companyId = companyStore.activeCompany?.id;
if (!companyId) {
toast.error('No hay empresa activa seleccionada');
return;
}
const params: DownloadedPartsReportRequest = {
date_from: dates.from,
date_to: dates.to,
class_from: classRange.from || undefined,
class_to: classRange.to || undefined,
print_class_mode: printClassMode,
exchange_rate_mode: exchangeRateMode,
currency_mode: currencyMode,
temporality_mode: temporalityMode,
weight_type_mode: weightTypeMode,
operation_mode: operationMode,
material_type: filters.materialType || undefined,
invoice_type: filters.invoiceType || undefined,
parts: filters.parts ? [filters.parts] : undefined,
pedimento_key: filters.pedimentoKey || undefined,
provider_id: filters.provider ? Number(filters.provider) : undefined,
sold_to_id: filters.soldTo ? Number(filters.soldTo) : undefined,
shipped_to_id: filters.shippedTo ? Number(filters.shippedTo) : undefined,
destination_customs: filters.destinationCustoms || undefined,
include_series: options.includeSeries,
print_class_total: options.printClassTotal,
include_totals_by_fraction: options.includeTotalsByFraction,
julian_date: options.julianDate,
show_item_description: options.showItemDescription,
include_exempt_fraction: options.includeExemptFraction,
show_export_fraction: options.showExportFraction,
include_rule_octava: options.includeRuleOctava,
include_american_fraction_and_country: options.includeAmericanFractionAndCountry,
respect_import_invoice_value_in_pesos: options.respectImportInvoiceValueInPesos,
show_all_temporary_balances: options.showAllTemporaryBalances
};
isGenerating = true;
try {
await downloadedPartsReportsApi.generate(companyId, params);
} catch (err: any) {
const msg: string = err?.message || '';
try {
const parsed = JSON.parse(msg);
if (parsed?.detail?.missing_dates?.length) {
toast.error(
'Faltan tipos de cambio para: ' + parsed.detail.missing_dates.join(', ')
);
return;
}
} catch {
// not JSON
}
toast.error(msg || 'Error al generar el reporte');
} finally {
isGenerating = false;
}
}
function closeView() {
toast.info('Acción cancelar pendiente');
}
</script>
<div
class="animate-in fade-in slide-in-from-bottom-4 flex min-h-full flex-col gap-2 pb-4 duration-500"
>
<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">
<Boxes class="h-6 w-6 text-primary" />
Reporte de clases exportadas / descargadas
</h1>
<span
class="rounded-full border bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground"
>
{bootstrap ? 'CONECTADO' : 'BASE'}
</span>
</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.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">
<FileSearch class="h-4 w-4" /> Rango de Fechas y Clases
</Card.Title>
</Card.Header>
<Card.Content class="flex-1 space-y-3 p-3">
<div class="space-y-2 rounded-md border p-3">
<p class="text-xs font-bold text-muted-foreground uppercase">Rango de fechas</p>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Del</Label>
<Input
type="date"
class="h-8 cursor-pointer"
bind:value={dates.from}
onclick={(e) => {
const input = e.currentTarget;
if (input && typeof input.showPicker === 'function') input.showPicker();
}}
/>
</div>
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Al</Label>
<Input
type="date"
class="h-8 cursor-pointer"
bind:value={dates.to}
onclick={(e) => {
const input = e.currentTarget;
if (input && typeof input.showPicker === 'function') input.showPicker();
}}
/>
</div>
</div>
</div>
<div class="space-y-2 rounded-md border p-3">
<p class="text-xs font-bold text-muted-foreground uppercase">Rango de clases</p>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">De la</Label>
<Select.Root type="single" value={classRange.from} onValueChange={(v) => (classRange.from = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if classRange.from}
{@const selectedClass = classesCatalog.find((item) => item.class_code === classRange.from)}
{selectedClass ? `${selectedClass.class_code} - ${selectedClass.description_es || ''}` : classRange.from}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Sin límite</Select.Item>
{#if classesCatalog.length}
{#each classesCatalog as item}
<Select.Item value={item.class_code}>
{item.class_code} - {item.description_es || item.description_en || ''}
</Select.Item>
{/each}
{:else}
<div class="px-3 py-2 text-xs text-muted-foreground">Sin opciones</div>
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">A la</Label>
<Select.Root type="single" value={classRange.to} onValueChange={(v) => (classRange.to = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if classRange.to}
{@const selectedClass = classesCatalog.find((item) => item.class_code === classRange.to)}
{selectedClass ? `${selectedClass.class_code} - ${selectedClass.description_es || ''}` : classRange.to}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Sin límite</Select.Item>
{#if classesCatalog.length}
{#each classesCatalog as item}
<Select.Item value={item.class_code}>
{item.class_code} - {item.description_es || item.description_en || ''}
</Select.Item>
{/each}
{:else}
<div class="px-3 py-2 text-xs text-muted-foreground">Sin opciones</div>
{/if}
</Select.Content>
</Select.Root>
</div>
</div>
</div>
</Card.Content>
</Card.Root>
<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-3 p-3">
<div class="grid gap-2 md:grid-cols-3">
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Tipo de material</Label>
<Select.Root type="single" value={filters.materialType} onValueChange={(v) => (filters.materialType = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if filters.materialType}
{@const selectedMaterial = materialTypes.find((item) => item.key === filters.materialType)}
{selectedMaterial ? `${selectedMaterial.key} - ${selectedMaterial.description}` : filters.materialType}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Todos</Select.Item>
{#if materialTypes.length}
{#each materialTypes as item}
<Select.Item value={item.key}>{item.key} - {item.description}</Select.Item>
{/each}
{:else}
<div class="px-3 py-2 text-xs text-muted-foreground">Sin opciones</div>
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Clave pedimento</Label>
<Select.Root type="single" value={filters.pedimentoKey} onValueChange={(v) => (filters.pedimentoKey = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if filters.pedimentoKey}
{@const selectedCode = pedimentoCodes.find((item) => item.code === filters.pedimentoKey)}
{selectedCode ? `${selectedCode.code} - ${selectedCode.description}` : filters.pedimentoKey}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Todos</Select.Item>
{#if pedimentoCodes.length}
{#each pedimentoCodes as item}
<Select.Item value={item.code}>{item.code} - {item.description}</Select.Item>
{/each}
{:else}
<div class="px-3 py-2 text-xs text-muted-foreground">Sin opciones</div>
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Vendido a</Label>
<Select.Root type="single" value={filters.soldTo} onValueChange={(v) => (filters.soldTo = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if filters.soldTo}
{@const selectedClient = soldToOptions.find((item) => String(item.id) === filters.soldTo)}
{selectedClient?.name || selectPlaceholder()}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Todos</Select.Item>
{#if soldToOptions.length}
{#each soldToOptions 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>
</div>
<div class="grid gap-2 md:grid-cols-3">
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Tipo de factura</Label>
<Select.Root type="single" value={filters.invoiceType} onValueChange={(v) => (filters.invoiceType = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if filters.invoiceType}
{@const selectedType = filteredInvoiceTypes.find((item) => item.key === filters.invoiceType)}
{selectedType ? `${selectedType.key} - ${selectedType.description}` : filters.invoiceType}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Todos</Select.Item>
{#if filteredInvoiceTypes.length}
{#each filteredInvoiceTypes as item}
<Select.Item value={item.key}>{item.key} - {item.description}</Select.Item>
{/each}
{:else}
<div class="px-3 py-2 text-xs text-muted-foreground">Sin opciones</div>
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Proveedor</Label>
<Select.Root type="single" value={filters.provider} onValueChange={(v) => (filters.provider = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if filters.provider}
{@const selectedProvider = providerOptions.find((item) => String(item.id) === filters.provider)}
{selectedProvider?.name || selectPlaceholder()}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Todos</Select.Item>
{#if providerOptions.length}
{#each providerOptions 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>
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Enviado a</Label>
<Select.Root type="single" value={filters.shippedTo} onValueChange={(v) => (filters.shippedTo = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if filters.shippedTo}
{@const selectedShippedTo = shippedToOptions.find((item) => String(item.id) === filters.shippedTo)}
{selectedShippedTo?.name || selectPlaceholder()}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Todos</Select.Item>
{#if shippedToOptions.length}
{#each shippedToOptions 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>
</div>
<div class="grid gap-2 md:grid-cols-2">
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Partes</Label>
<Select.Root type="single" value={filters.parts} onValueChange={(v) => (filters.parts = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if filters.parts}
{@const selectedPart = partsCatalog.find((item) => item.part_number === filters.parts)}
{selectedPart ? `${selectedPart.part_number} - ${selectedPart.description_spanish || ''}` : filters.parts}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Todos</Select.Item>
{#if partsCatalog.length}
{#each partsCatalog as item}
<Select.Item value={item.part_number}>{item.part_number} - {item.description_spanish || item.description_english || ''}</Select.Item>
{/each}
{:else}
<div class="px-3 py-2 text-xs text-muted-foreground">Sin opciones</div>
{/if}
</Select.Content>
</Select.Root>
</div>
<div class="space-y-1">
<Label class="text-xs font-bold text-muted-foreground uppercase">Destino aduanero</Label>
<Select.Root type="single" value={filters.destinationCustoms} onValueChange={(v) => (filters.destinationCustoms = normalizeSelectValue(v))}>
<Select.Trigger class="h-8 w-full text-xs">
<span class="truncate">
{#if filters.destinationCustoms}
{@const selectedSection = customsSections.find((item) => item.customs_code === filters.destinationCustoms)}
{selectedSection ? `${selectedSection.customs_code} - ${selectedSection.section_name}` : filters.destinationCustoms}
{:else}
{selectPlaceholder()}
{/if}
</span>
</Select.Trigger>
<Select.Content class="max-h-[300px]">
<Select.Item value={CLEAR_SELECT_VALUE}>Todos</Select.Item>
{#if customsSections.length}
{#each customsSections as item}
<Select.Item value={item.customs_code}>{item.customs_code} - {item.section_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>
</div>
<Separator />
<div class="space-y-3">
<div>
<Label class="mb-2 block text-xs font-bold text-muted-foreground uppercase">Imprimir clases</Label>
<RadioGroup.Root bind:value={printClassMode} class="grid grid-cols-2 gap-2">
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
<RadioGroup.Item value="exported" id="print-exported" class="h-4 w-4" />
<Label for="print-exported" class="cursor-pointer text-sm">Exportadas</Label>
</div>
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
<RadioGroup.Item value="downloaded" id="print-downloaded" class="h-4 w-4" />
<Label for="print-downloaded" class="cursor-pointer text-sm">Descargadas</Label>
</div>
</RadioGroup.Root>
</div>
<div>
<Label class="mb-2 block text-xs font-bold text-muted-foreground uppercase">Temporalidad</Label>
<RadioGroup.Root bind:value={temporalityMode} class="grid grid-cols-3 gap-2">
{#each [
{ value: 'temporales', label: 'Temporales' },
{ value: 'definitivos', label: 'Definitivos' },
{ value: 'ambos', label: 'Ambos' }
] as item}
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
<RadioGroup.Item value={item.value} id={`temporality-${item.value}`} class="h-4 w-4" />
<Label for={`temporality-${item.value}`} class="cursor-pointer text-sm">{item.label}</Label>
</div>
{/each}
</RadioGroup.Root>
</div>
</div>
</Card.Content>
</Card.Root>
<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">
<Search class="h-4 w-4" /> Configuración y Salida
</Card.Title>
</Card.Header>
<Card.Content class="space-y-3 p-3">
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="space-y-2">
<Label class="flex items-center gap-2 text-xs font-bold text-muted-foreground uppercase">
<BadgeDollarSign class="h-3.5 w-3.5" /> Tipo Moneda
</Label>
<RadioGroup.Root bind:value={currencyMode} class="flex flex-col gap-2">
{#each [
{ value: 'dollars', label: 'Dólares' },
{ value: 'pesos', label: 'Pesos' },
{ value: 'both', label: 'Ambos' }
] as item}
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
<RadioGroup.Item value={item.value} id={`currency-${item.value}`} class="h-4 w-4" />
<Label for={`currency-${item.value}`} class="cursor-pointer text-sm">{item.label}</Label>
</div>
{/each}
</RadioGroup.Root>
</div>
<div class="space-y-2">
<Label class="flex items-center gap-2 text-xs font-bold text-muted-foreground uppercase">
<Scale class="h-3.5 w-3.5" /> Tipo Cambio
</Label>
<RadioGroup.Root bind:value={exchangeRateMode} 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="invoice" id="exchange-invoice" class="h-4 w-4" />
<Label for="exchange-invoice" class="cursor-pointer text-sm">Factura</Label>
</div>
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
<RadioGroup.Item value="pedimento_payment" id="exchange-payment" class="h-4 w-4" />
<Label for="exchange-payment" class="cursor-pointer text-sm">Pago de Pedimento</Label>
</div>
</RadioGroup.Root>
</div>
<div class="space-y-2">
<Label class="flex items-center gap-2 text-xs font-bold text-muted-foreground uppercase">
<Package class="h-3.5 w-3.5" /> Tipo Peso
</Label>
<RadioGroup.Root bind:value={weightTypeMode} class="flex flex-col gap-2">
{#each [
{ value: 'kilos', label: 'Kilos' },
{ value: 'libras', label: 'Libras' },
{ value: 'ambos', label: 'Ambos' }
] as item}
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
<RadioGroup.Item value={item.value} id={`weight-${item.value}`} class="h-4 w-4" />
<Label for={`weight-${item.value}`} class="cursor-pointer text-sm">{item.label}</Label>
</div>
{/each}
</RadioGroup.Root>
</div>
<div class="space-y-2">
<Label class="flex items-center gap-2 text-xs font-bold text-muted-foreground uppercase">
<Clock3 class="h-3.5 w-3.5" /> Operación
</Label>
<RadioGroup.Root bind:value={operationMode} 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="importacion" id="operation-import" class="h-4 w-4" />
<Label for="operation-import" class="cursor-pointer text-sm">Importación</Label>
</div>
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
<RadioGroup.Item value="exportacion" id="operation-export" class="h-4 w-4" />
<Label for="operation-export" class="cursor-pointer text-sm">Exportación</Label>
</div>
</RadioGroup.Root>
</div>
</div>
</Card.Content>
</Card.Root>
<Card.Root class="gap-0 py-0 xl:col-span-3">
<Card.Header class="shrink-0 border-b bg-muted/20 px-3 py-2">
<Card.Title class="text-sm font-semibold text-primary">Opciones</Card.Title>
</Card.Header>
<Card.Content class="p-3">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-3">
{#each allOptions as option}
<div class="flex items-center space-x-2 rounded-md border px-3 py-1.5">
<Checkbox id={option.id} bind:checked={options[option.key]} class="h-4 w-4" />
<Label for={option.id} class="cursor-pointer text-sm">{option.label}</Label>
</div>
{/each}
</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={closeView}
>
<X class="h-4 w-4" />
</Button>
</Card.Footer>
</Card.Root>
</div>
</div>