Merge branch 'development' into feature/digitalizacion-api

This commit is contained in:
2026-04-17 16:00:43 -06:00
31 changed files with 2972 additions and 392 deletions

View File

@@ -38,6 +38,7 @@
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.623 0.214 259.815);
color-scheme: light;
}
.dark {
@@ -72,6 +73,7 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.488 0.243 264.376);
color-scheme: dark;
}
@@ -128,6 +130,19 @@
color: var(--color-foreground);
-webkit-text-fill-color: var(--color-foreground);
}
input[type="date"]::-webkit-calendar-picker-indicator,
input[type="datetime-local"]::-webkit-calendar-picker-indicator {
display: none;
opacity: 0;
}
.dark input[type="date"]::-webkit-calendar-picker-indicator,
.dark input[type="datetime-local"]::-webkit-calendar-picker-indicator {
cursor: pointer;
filter: invert(1) brightness(1.15);
opacity: 0.9;
}
}
@layer components {

View File

@@ -26,6 +26,65 @@ export function humanizeLineReferences(text: string): string {
return text.replace(/\bline\[(\d+)\]/gi, 'partida $1');
}
function humanizeFieldPath(field: string): string {
const rawField = (field || '').trim();
if (!rawField) return 'campo';
const lineMatch = rawField.match(/^line\[(\d+)\]\.(.+)$/i);
const fieldPath = lineMatch?.[2] || rawField;
const label = fieldPath
.replace(/^body\./i, '')
.replace(/\./g, ' → ')
.replace(/_/g, ' ');
if (lineMatch) {
return `Partida ${lineMatch[1]} - ${label}`;
}
return label;
}
function humanizeValidationMessage(message: string): string {
const rawMessage = (message || '').trim();
if (!rawMessage) return 'error de validación';
return rawMessage
.replace(/\b(field required|is required)\b/gi, 'es obligatorio')
.replace(/\b(value is not a valid decimal)\b/gi, 'debe ser un número válido')
.replace(/\b(value is not a valid integer)\b/gi, 'debe ser un número entero válido');
}
function formatValidationHint(field: string, message: string, code?: string): string {
const fieldLabel = humanizeFieldPath(field);
const normalizedMessage = humanizeValidationMessage(message);
if (code === 'REQUIRED' || code === 'REQUIRED_FIELD' || /es obligatorio|es requerido/i.test(normalizedMessage)) {
return `Completa ${fieldLabel}.`;
}
if (code === 'AMERICAN_FRACTION_NOT_FOUND') {
return 'La fracción americana seleccionada no existe. Elige una opción del catálogo.';
}
if (code === 'UNIT_OF_MEASURE_NOT_FOUND') {
return 'La unidad de medida seleccionada no existe. Elige una opción del catálogo.';
}
if (code === 'ORIGIN_COUNTRY_NOT_FOUND') {
return 'El país de origen seleccionado no existe. Elige una opción del catálogo.';
}
if (code === 'CLASS_NOT_FOUND') {
return 'La clase seleccionada no existe. Elige una opción del catálogo.';
}
if (code === 'FRACTION_TYPE_INVALID') {
return 'Selecciona un tipo de tarifa válido.';
}
return normalizedMessage;
}
/**
* Título y descripción listos para toasts / alertas a partir de ApiResponse.
* Prioriza los mensajes que ya envía el backend y evita duplicar rutas técnicas.
@@ -34,7 +93,7 @@ export function friendlyApiErrorParts(res: ApiResponse): { title: string; descri
const validationErrors = res.validationErrors;
if (validationErrors?.length) {
const blocks = validationErrors.map((e) => {
const base = humanizeLineReferences((e.message || '').trim() || e.field);
const base = formatValidationHint(e.field || '', e.message || '', e.code);
const hints = e.solution?.filter(Boolean).length
? '\n' + e.solution!.map((s) => `${humanizeLineReferences(s)}`).join('\n')
: '';
@@ -52,7 +111,7 @@ export function friendlyApiErrorParts(res: ApiResponse): { title: string; descri
}
if (res.error) {
const err = humanizeLineReferences(res.error.trim());
const err = humanizeValidationMessage(humanizeLineReferences(res.error.trim()));
if (err.startsWith('Error de validación:')) {
return {
title: 'Revisa los datos ingresados',
@@ -241,6 +300,7 @@ async function fetchApi<T = any>(
if (response.status === 422) {
// HTTPException(detail={ message, errors }) — catálogo / CSV parity
const det = data.detail;
const validationErrors = (errors: unknown[]) => errors as NonNullable<ApiResponse['validationErrors']>;
if (
det &&
typeof det === 'object' &&
@@ -250,7 +310,7 @@ async function fetchApi<T = any>(
const d = det as { message?: string; errors: unknown[] };
return {
error: d.message || 'Error de validación',
validationErrors: d.errors,
validationErrors: validationErrors(d.errors),
status: response.status
};
}
@@ -258,7 +318,7 @@ async function fetchApi<T = any>(
if (data.errors && Array.isArray(data.errors)) {
return {
error: data.message || 'Error de validación',
validationErrors: data.errors,
validationErrors: validationErrors(data.errors),
status: response.status
};
}
@@ -421,7 +481,7 @@ async function fetchApiFormDataPost<T = any>(
if (data.errors && Array.isArray(data.errors)) {
resolve({
error: data.message || 'Error de validación',
validationErrors: data.errors,
validationErrors: data.errors as NonNullable<ApiResponse['validationErrors']>,
status: 422
});
return;
@@ -431,8 +491,8 @@ async function fetchApiFormDataPost<T = any>(
if (Array.isArray(data.detail)) {
const errors = data.detail
.map((err: any) => {
const field = err.loc ? err.loc.join('.') : 'campo desconocido';
return `${field}: ${err.msg}`;
const field = err.loc ? err.loc.filter((loc: string) => loc !== 'body').join('.') : 'campo desconocido';
return `${humanizeFieldPath(field)}: ${humanizeValidationMessage(err.msg || 'error de validación')}`;
})
.join(', ');
errorMessage += errors;
@@ -519,6 +579,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

@@ -21,32 +21,55 @@
let items = $state<USTariffFraction[]>([]);
let loading = $state(false);
let searchTerm = $state("");
let loaded = $state(false);
let loadedForCompanyId = $state<number | null>(null);
const activeCompanyId = $derived(companyStore.activeCompany?.id);
function normalizeAmericanFractionCode(code: string) {
return (code || '').replace(/[.\s-]/g, '');
}
function isEligibleAmericanFraction(item: USTariffFraction) {
const normalizedCode = normalizeAmericanFractionCode(item.code || '');
return /^\d{8}$/.test(normalizedCode) || /^\d{10}$/.test(normalizedCode);
}
// Filtro local
let filteredItems = $derived(
items.filter(i =>
(i.code || "").includes(searchTerm) ||
isEligibleAmericanFraction(i) &&
((i.code || "").includes(searchTerm) ||
(i.description || "").toLowerCase().includes(searchTerm.toLowerCase())
)
)
);
// Cargar datos al abrir
$effect(() => {
if (open && !loaded && companyStore.activeCompany?.id) {
loadFractions();
if (!open) return;
if (!activeCompanyId) {
items = [];
loadedForCompanyId = null;
return;
}
if (loadedForCompanyId !== activeCompanyId) {
searchTerm = '';
items = [];
void loadFractions(activeCompanyId);
}
});
async function loadFractions() {
if (!companyStore.activeCompany?.id) {
async function loadFractions(companyId: number) {
if (!companyId) {
toast.error("No hay empresa seleccionada");
return;
}
loading = true;
try {
const response = await getUSTariffFractions(1, 1000, companyStore.activeCompany.id);
const response = await getUSTariffFractions(1, 1000, companyId);
if (response.error) {
console.error("Error al cargar fracciones americanas:", response.error);
@@ -55,8 +78,8 @@
}
if (response.data?.items) {
items = response.data.items;
loaded = true;
items = response.data.items.filter((item) => isEligibleAmericanFraction(item));
loadedForCompanyId = companyId;
} else {
console.warn("No se encontraron fracciones americanas:", response);
toast.info("No se encontraron fracciones americanas registradas");

View File

@@ -42,14 +42,9 @@
invoice_number: searchTerm || undefined,
status: status || 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.
console.log('🔍 [Modal] Buscando facturas...', { activeCompanyId, filters });

View File

@@ -75,6 +75,9 @@
if (editingItem.fa_data.discharge === undefined) {
editingItem.fa_data.discharge = false;
}
if ((editingItem.fa_data.movement_type_import === undefined || editingItem.fa_data.movement_type_import === '') && (showLinkToImportBlock || showRepairBlock)) {
editingItem.fa_data.movement_type_import = 'TEM';
}
}
});
@@ -196,10 +199,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);
@@ -215,7 +220,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
});
@@ -357,6 +362,7 @@
<div class="space-y-3 rounded-md border border-zinc-200 bg-white p-3 dark:border-zinc-800 dark:bg-zinc-900">
<div class="space-y-1.5">
<Label class="text-xs font-medium text-muted-foreground">Genera Descarga?</Label>
<p class="text-[10px] text-muted-foreground -mt-1">Los campos marcados con * son obligatorios.</p>
<RadioGroup
value={editingItem.fa_data?.discharge === false ? 'no' : 'si'}
onValueChange={(v) => {
@@ -483,7 +489,7 @@
<!-- Fila ligada: selector de factura (FK) + línea (FK) -->
<div class="flex flex-wrap items-end gap-3">
<div class="min-w-[100px] flex-1 space-y-1">
<Label class="text-xs">Tipo Importación</Label>
<Label class="text-xs">Tipo Importación: <span class="text-red-500">*</span></Label>
<Select.Root
type="single"
value={editingItem.fa_data?.movement_type_import || 'TEM'}

View File

@@ -331,6 +331,9 @@
<fieldset class="border rounded-md p-3">
<legend class="text-xs font-semibold px-2 bg-zinc-200 dark:bg-zinc-700">Main Data</legend>
<p class="mt-1 px-1 text-[10px] text-muted-foreground">
Los campos marcados con * son obligatorios.
</p>
<div class="grid grid-cols-2 gap-4">
<!-- Class - Full Width -->
@@ -448,7 +451,7 @@
</div>
<div class="space-y-1">
<Label for="fraccion" class="text-xs font-medium">Fracción:</Label>
<Label for="fraccion" class="text-xs font-medium">Fracción: <span class="text-red-500">*</span></Label>
<div class="flex gap-1">
<Input
id="fraccion"

View File

@@ -7,6 +7,7 @@
import type { Invoice } from '$lib/api/dashboard/a76/invoices';
import PackageDialog from './package-dialog.svelte';
import USFractionSelectorDialog from '$lib/components/dashboard/goods/modales/us-fraction-selector-dialog.svelte';
let {
item = $bindable(),
@@ -29,6 +30,7 @@
let packageDialogOpen = $state(false);
let americanFractionDialogOpen = $state(false);
let package_key = $state('');
let package_weight_unit = $state<number>(0);
let isLoadingPackage = $state(false);
@@ -114,19 +116,30 @@
package_weight_unit = pkg.weight_unit || 0;
quantities.package_description = pkg.description_es || pkg.description_en || pkg.key;
}
function handleAmericanFractionSelect(fraction: any) {
customs.american_fraction = fraction.code || '';
(customs as any).american_fraction_description = fraction.description || '';
if (fraction.ad_valorem !== null && fraction.ad_valorem !== undefined) {
customs.advalorem_american = fraction.ad_valorem;
}
}
</script>
<fieldset class="border rounded-md p-2 space-y-2">
<legend class="text-xs font-semibold px-2 bg-gray-200 dark:bg-gray-700">PACKAGES</legend>
<p class="text-[10px] text-muted-foreground px-1">
Los campos marcados con * son obligatorios.
</p>
<div class="grid grid-cols-12 gap-2 items-end">
<div class="col-span-2 space-y-1">
<Label for="cantidad_bultos" class="text-xs">Quantity:</Label>
<Label for="cantidad_bultos" class="text-xs">Quantity: <span class="text-red-500">*</span></Label>
<Input id="cantidad_bultos" type="number" step="1" min="0" bind:value={quantities.package_quantity} disabled={disabled} class="h-7 text-xs text-right" />
</div>
<div class="col-span-3 space-y-1">
<Label for="clave_bultos" class="text-xs">Package Code:</Label>
<Label for="clave_bultos" class="text-xs">Package Code: <span class="text-red-500">*</span></Label>
<div class="flex gap-1">
<Input
id="clave_bultos"
@@ -169,7 +182,7 @@
<div class="text-xs font-semibold mb-2">WEIGHTS</div>
<div class="grid grid-cols-6 gap-2 items-end">
<div class="col-span-2 space-y-1">
<Label for="peso_neto" class="text-xs">Net:</Label>
<Label for="peso_neto" class="text-xs">Net: <span class="text-red-500">*</span></Label>
<Input id="peso_neto" type="number" step="0.00000001" min="0" bind:value={quantities.net_weight} disabled={disabled} class="h-7 text-xs text-right" />
</div>
@@ -200,8 +213,28 @@
<div class="grid grid-cols-12 gap-2 items-end">
<div class="col-span-4 space-y-1">
<Label for="fraccion_americana" class="text-xs">American Fraction:</Label>
<Input id="fraccion_americana" bind:value={customs.american_fraction} disabled={disabled} class="h-7 text-xs" />
<Label for="fraccion_americana" class="text-xs">American Fraction: <span class="text-red-500">*</span></Label>
<div class="flex gap-1">
<Input
id="fraccion_americana"
value={customs.american_fraction || ''}
readonly
disabled={disabled}
class="h-7 text-xs flex-1 bg-muted cursor-pointer"
placeholder="Seleccionar..."
onclick={() => !disabled && (americanFractionDialogOpen = true)}
/>
<Button
type="button"
variant="outline"
size="icon"
class="h-7 w-7 shrink-0"
disabled={disabled}
onclick={() => !disabled && (americanFractionDialogOpen = true)}
>
<Folder class="h-3 w-3" />
</Button>
</div>
</div>
<div class="col-span-3 space-y-1">
@@ -230,3 +263,4 @@
</fieldset>
<PackageDialog bind:open={packageDialogOpen} onSelect={handlePackageSelect} />
<USFractionSelectorDialog bind:open={americanFractionDialogOpen} onSelect={handleAmericanFractionSelect} />

View File

@@ -225,6 +225,9 @@
{#if editingItem}
<div class="max-h-[calc(90vh-96px)] overflow-auto bg-slate-50/60 p-6 dark:bg-black">
<p class="mb-3 text-[10px] text-muted-foreground">
Los campos marcados con * son obligatorios.
</p>
<Tabs.Root bind:value={activeTab} class="mt-0">
<Tabs.List class="grid w-full grid-cols-4">
<Tabs.Trigger value="general">General</Tabs.Trigger>
@@ -272,7 +275,7 @@
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="space-y-2 md:col-span-2">
<Label for="class_code">Clase</Label>
<Label for="class_code">Clase <span class="text-red-500">*</span></Label>
<div class="flex gap-1">
<Input
id="class_code"
@@ -295,13 +298,13 @@
</div>
<div class="space-y-2">
<Label for="quantity_general">Cantidad</Label>
<Label for="quantity_general">Cantidad <span class="text-red-500">*</span></Label>
{#if editingItem?.quantity}
<Input id="quantity_general" type="number" step="0.00000001" min="0" bind:value={editingItem.quantity.quantity} />
{/if}
</div>
<div class="space-y-2">
<Label for="unit_general">U.M.</Label>
<Label for="unit_general">U.M. <span class="text-red-500">*</span></Label>
<div class="flex gap-1">
<Input
id="unit_general"
@@ -321,13 +324,13 @@
</div>
<div class="space-y-2">
<Label for="unit_cost_capture">Costo Unitario</Label>
<Label for="unit_cost_capture">Costo Unitario <span class="text-red-500">*</span></Label>
{#if editingItem?.financial}
<Input id="unit_cost_capture" type="number" step="0.00000001" min="0" bind:value={editingItem.financial.unit_cost_capture} />
{/if}
</div>
<div class="space-y-2">
<Label for="origin_country_general">País de Origen</Label>
<Label for="origin_country_general">País de Origen <span class="text-red-500">*</span></Label>
<div class="flex gap-1">
<Input
id="origin_country_general"
@@ -347,7 +350,7 @@
</div>
<div class="space-y-2">
<Label for="fraction_general">Fracción</Label>
<Label for="fraction_general">Fracción <span class="text-red-500">*</span></Label>
<div class="flex gap-1">
<Input
id="fraction_general"
@@ -366,7 +369,7 @@
</div>
</div>
<div class="space-y-2">
<Label for="fraction_type_general">Tipo de Tarifa</Label>
<Label for="fraction_type_general">Tipo de Tarifa <span class="text-red-500">*</span></Label>
{#if editingItem?.customs}
<select id="fraction_type_general" bind:value={editingItem.customs.fraction_type} class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<option value=""></option>
@@ -464,7 +467,7 @@
<Tabs.Content value="clasificacion" class="mt-4 space-y-4">
<div class="space-y-4">
<div class="space-y-2">
<Label for="tariff_fraction">Fracción Arancelaria</Label>
<Label for="tariff_fraction">Fracción Arancelaria <span class="text-red-500">*</span></Label>
{#if editingItem?.customs}
<Input
id="tariff_fraction"
@@ -504,7 +507,7 @@
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="country_origin">País de Origen</Label>
<Label for="country_origin">País de Origen <span class="text-red-500">*</span></Label>
{#if editingItem?.customs}
<Input id="country_origin" placeholder="Código del país" bind:value={editingItem.customs.origin_country} />
{/if}

View File

@@ -5,7 +5,6 @@
import type { Sector } from "./columns.js";
import CreateEditDialog from "./create-edit-dialog.svelte";
import DetailsDialog from "./details-dialog.svelte";
import DeleteDialog from "./delete-dialog.svelte";
let {
item,
@@ -17,7 +16,6 @@
let showDetailsDialog = $state(false);
let showEditDialog = $state(false);
let showDeleteDialog = $state(false);
function handleCopyId() {
navigator.clipboard.writeText(item.key.toString());
@@ -30,10 +28,6 @@
function handleEdit() {
showEditDialog = true;
}
function handleDelete() {
showDeleteDialog = true;
}
</script>
<DropdownMenu.Root>
@@ -55,12 +49,9 @@
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleViewDetails}>Ver detalles</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleEdit}>Editar</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-destructive" onclick={handleDelete}>Eliminar</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<!-- Dialogs -->
<DetailsDialog bind:open={showDetailsDialog} {item} />
<CreateEditDialog bind:open={showEditDialog} item={item} {onSuccess} />
<DeleteDialog bind:open={showDeleteDialog} {item} {onSuccess} />

View File

@@ -10,7 +10,7 @@ export type State = {
ame_key?: string | null;
};
export function createColumns(onSuccess?: () => void): ColumnDef<State>[] {
export function createColumns(onSuccess?: () => void, readOnly = false): ColumnDef<State>[] {
return [
{
accessorKey: "m3_key",
@@ -74,11 +74,11 @@ export function createColumns(onSuccess?: () => void): ColumnDef<State>[] {
{
id: "actions",
cell: ({ row }) => {
return renderComponent(DataTableActions, { item: row.original, onSuccess });
return renderComponent(DataTableActions, { item: row.original, onSuccess, readOnly });
}
}
];
}
// Mantener compatibilidad hacia atrás
export const columns = createColumns();
export const columns = createColumns(undefined, true);

View File

@@ -9,10 +9,12 @@
let {
item,
onSuccess
onSuccess,
readOnly = false
}: {
item: State;
onSuccess?: () => void;
readOnly?: boolean;
} = $props();
let showDetailsDialog = $state(false);
@@ -54,13 +56,17 @@
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={handleViewDetails}>Ver detalles</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleEdit}>Editar</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-destructive" onclick={handleDelete}>Eliminar</DropdownMenu.Item>
{#if !readOnly}
<DropdownMenu.Item onclick={handleEdit}>Editar</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-destructive" onclick={handleDelete}>Eliminar</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
<!-- Dialogs -->
<DetailsDialog bind:open={showDetailsDialog} {item} />
<CreateEditDialog bind:open={showEditDialog} item={item} {onSuccess} />
<DeleteDialog bind:open={showDeleteDialog} {item} {onSuccess} />
{#if !readOnly}
<CreateEditDialog bind:open={showEditDialog} item={item} {onSuccess} />
<DeleteDialog bind:open={showDeleteDialog} {item} {onSuccess} />
{/if}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import * as Tabs from '$lib/components/ui/tabs';
import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import * as Select from '$lib/components/ui/select';
@@ -28,34 +29,132 @@
let countries = $state<Country[]>([]);
let countriesLoading = $state(false);
let formData = $state<Driver & { lineStr?: string }>({
transporter_key: '',
line: 0,
driver_name: '',
license_number: '',
first_name: '',
last_name: '',
badge_number: '',
express_line_id: '',
ace_id: '',
birth_country: '',
hazardous_material_auth: '',
hazardous_material_state: '',
class_type: ''
});
// Convierte null/undefined a '' para evitar binding roto en inputs
function s(v: string | null | undefined): string {
return v ?? '';
}
// birth_date se guarda como entero YYYYMMDD; el formulario usa string YYYY-MM-DD para <input type="date">
function birthDateToInput(v: number | null | undefined): string {
if (!v) return '';
const s = String(v).padStart(8, '0');
return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`;
}
function inputToBirthDate(v: string): number | undefined {
if (!v) return undefined;
const d = v.replace(/-/g, '');
return d.length === 8 ? parseInt(d, 10) : undefined;
}
function emptyForm(): Driver & { lineStr: string; birthDateStr: string } {
return {
transporter_key: '', line: 0, lineStr: '', birthDateStr: '',
driver_name: '', license_number: '', first_name: '', last_name: '',
badge_number: '', unique_badge_number: '', express_line_id: '', ace_id: '',
gender: '', birth_country: '', birth_date: undefined,
hazardous_material_auth: '', hazardous_material_state: '',
class_type: '',
id_key1: '', id_number1: '', id_state1: '', id_country1: '',
id_key2: '', id_number2: '', id_state2: '', id_country2: ''
};
}
function fromItem(i: Driver): Driver & { lineStr: string; birthDateStr: string } {
return {
...i,
lineStr: String(i.line),
birthDateStr: birthDateToInput(i.birth_date),
driver_name: s(i.driver_name),
license_number: s(i.license_number),
first_name: s(i.first_name),
last_name: s(i.last_name),
badge_number: s(i.badge_number),
unique_badge_number: s(i.unique_badge_number),
express_line_id: s(i.express_line_id),
ace_id: s(i.ace_id),
gender: s(i.gender),
birth_country: s(i.birth_country),
hazardous_material_auth: s(i.hazardous_material_auth),
hazardous_material_state: s(i.hazardous_material_state),
class_type: s(i.class_type),
id_key1: s(i.id_key1), id_number1: s(i.id_number1),
id_state1: s(i.id_state1), id_country1: s(i.id_country1),
id_key2: s(i.id_key2), id_number2: s(i.id_number2),
id_state2: s(i.id_state2), id_country2: s(i.id_country2)
};
}
let formData = $state<Driver & { lineStr: string; birthDateStr: string }>(emptyForm());
let loading = $state(false);
let error = $state<string | null>(null);
// Cargar transportistas al abrir el diálogo en modo creación (backend max page_size=100)
// Mapeo de nombres de campo CSV → etiqueta legible para el usuario
const FIELD_LABEL: Record<string, string> = {
'TRANSPORTISTA': 'Transportista',
'CLAVE CONDUCTOR': 'Clave Conductor',
'LINEA': 'Línea',
'LICENCIA': 'Número de Licencia',
'PERMISO LINEA EXPRESS': 'Express Line ID',
'IDENTIFICACION ACE': 'ACE ID',
'PAIS NACIMIENTO': 'País de Nacimiento',
'TRANSPORTA MAT. PELIGROSO?': 'Mat. Peligroso',
'PERMISO MAT. PELIGROSO': 'Estado de Autorización',
'NOMBRE(S)': 'Nombre(s)',
'APELLIDO PATERNO': 'Apellido Paterno',
'SEXO': 'Género',
'FECHA NACIMIENTO': 'Fecha de Nacimiento',
'FORMA IDENTIFICACION 1': 'Tipo ID 1',
'NUM. IDENTIFICACION 1': 'Núm. ID 1',
'ESTADO': 'Estado ID 1',
'PAIS': 'País ID 1',
'FORMA IDENTIFICACION 2': 'Tipo ID 2',
'NUM. IDENTIFICACION 2': 'Núm. ID 2',
'ESTADO 2': 'Estado ID 2',
'PAIS 2': 'País ID 2'
};
// Claves válidas de forma de identificación (paridad Clarion)
const FORMA_ID_OPCIONES = [
{ value: 'ACW', label: 'ACW — Pasaporte' },
{ value: 'ALR', label: 'ALR — Residencia' },
{ value: 'BCP', label: 'BCP — Permiso Cruce' },
{ value: 'BCN', label: 'BCN — Acta Nacimiento' },
{ value: 'CDN', label: 'CDN — Ciudadanía' },
{ value: 'CON', label: 'CON — Cert. Naturalización' },
{ value: 'OTD', label: 'OTD — Otro' },
{ value: 'REP', label: 'REP — Pasaporte' },
{ value: 'RTP', label: 'RTP — Tarjeta de Paso' },
{ value: '5J', label: '5J' },
{ value: '5K', label: '5K' },
{ value: '30', label: '30' }
];
// Clase de licencia (String(1), Clarion muestra A,B,C,D,E)
const CLASE_OPCIONES = ['A', 'B', 'C', 'D', 'E'];
function humanizeValidationErrors(errors: Array<{ col?: string; msg?: string }>): string {
return errors
.map((e) => {
const label = (e.col && FIELD_LABEL[e.col]) ? FIELD_LABEL[e.col] : (e.col ?? 'Campo');
const msg = e.msg ?? 'error';
const humanMsg = msg === 'Requerido'
? 'es obligatorio'
: msg.startsWith('Maximo')
? msg.replace('Maximo', 'máximo').replace('caracteres', 'caracteres')
: msg;
return `${label}: ${humanMsg}`;
})
.join(' · ');
}
// Cargar transportistas al abrir el diálogo en modo creación
$effect(() => {
if (open && !item && companyStore.activeCompany) {
transportersLoading = true;
transportersApi
.list(companyStore.activeCompany.id, { page: 1, page_size: 100 })
.then((res) => {
if (res.data?.items) transporters = res.data.items;
else transporters = [];
transporters = res.data?.items ?? [];
})
.catch(() => (transporters = []))
.finally(() => (transportersLoading = false));
@@ -65,8 +164,7 @@
countriesApi
.list(1, 100)
.then((res) => {
if (res.data?.items) countries = res.data.items;
else countries = [];
countries = res.data?.items ?? [];
})
.catch(() => (countries = []))
.finally(() => (countriesLoading = false));
@@ -79,109 +177,95 @@
loading = false;
return;
}
if (item) {
formData = {
...item,
lineStr: String(item.line)
};
} else {
formData = {
transporter_key: '',
line: 0,
lineStr: '',
driver_name: '',
license_number: '',
first_name: '',
last_name: '',
badge_number: '',
express_line_id: '',
ace_id: '',
birth_country: '',
hazardous_material_auth: '',
hazardous_material_state: '',
class_type: ''
};
}
formData = item ? fromItem(item) : emptyForm();
});
async function handleSubmit() {
if (loading) return;
error = null;
loading = true;
// Validación client-side
if (!formData.transporter_key?.trim()) {
error = 'Selecciona un transportista de la lista';
return;
}
if (!formData.driver_name?.trim()) {
error = 'Nombre del Conductor es obligatorio';
return;
}
const lineNum = isEdit ? item!.line : parseInt(formData.lineStr, 10);
if (!isEdit && (Number.isNaN(lineNum) || lineNum < 1)) {
error = 'La línea debe ser un número entero mayor a 0';
return;
}
loading = true;
try {
const company = companyStore.activeCompany;
if (!company) {
throw new Error('No hay una compañía seleccionada');
}
if (!company) throw new Error('No hay una compañía seleccionada');
if (!formData.transporter_key?.trim()) {
throw new Error('Selecciona un transportista de la lista');
}
const lineNum = isEdit ? item!.line : parseInt(String(formData.lineStr ?? formData.line), 10);
if (!isEdit && (Number.isNaN(lineNum) || lineNum < 1)) {
throw new Error('La línea debe ser un número mayor a 0');
}
if (isEdit && item) {
const response = await driversApi.update(
item.transporter_key,
item.line,
{
const allFields = {
driver_name: formData.driver_name || undefined,
license_number: formData.license_number || undefined,
first_name: formData.first_name || undefined,
last_name: formData.last_name || undefined,
badge_number: formData.badge_number || undefined,
unique_badge_number: formData.unique_badge_number || undefined,
express_line_id: formData.express_line_id || undefined,
ace_id: formData.ace_id || undefined,
gender: formData.gender || undefined,
birth_date: inputToBirthDate(formData.birthDateStr),
birth_country: formData.birth_country || undefined,
hazardous_material_auth: formData.hazardous_material_auth || undefined,
hazardous_material_state: formData.hazardous_material_state || undefined,
class_type: formData.class_type || undefined
class_type: formData.class_type || undefined,
id_key1: formData.id_key1 || undefined,
id_number1: formData.id_number1 || undefined,
id_state1: formData.id_state1 || undefined,
id_country1: formData.id_country1 || undefined,
id_key2: formData.id_key2 || undefined,
id_number2: formData.id_number2 || undefined,
id_state2: formData.id_state2 || undefined,
id_country2: formData.id_country2 || undefined
};
if (isEdit && item) {
const response = await driversApi.update(
item.transporter_key,
item.line,
allFields,
company.id
);
if (response.error) {
const ve = response.validationErrors as Array<{ col?: string; msg?: string }> | undefined;
throw new Error(ve?.length ? humanizeValidationErrors(ve) : response.error);
}
} else {
const response = await driversApi.create(
{
transporter_key: formData.transporter_key.trim(),
line: lineNum,
...allFields,
company_id: company.id,
tenant_id: company.tenant_id
},
company.id
);
if (response.error) {
const ve = (response as { validationErrors?: { msg?: string }[] }).validationErrors;
if (ve?.length) throw new Error(ve.map((e) => e.msg).join(' · '));
throw new Error(response.error);
}
} else {
const payload = {
transporter_key: String(formData.transporter_key).trim(),
line: lineNum,
driver_name: formData.driver_name || undefined,
license_number: formData.license_number || undefined,
first_name: formData.first_name || undefined,
last_name: formData.last_name || undefined,
badge_number: formData.badge_number || undefined,
express_line_id: formData.express_line_id || undefined,
ace_id: formData.ace_id || undefined,
birth_country: formData.birth_country || undefined,
hazardous_material_auth: formData.hazardous_material_auth || undefined,
hazardous_material_state: formData.hazardous_material_state || undefined,
class_type: formData.class_type || undefined,
company_id: company.id,
tenant_id: company.tenant_id
};
const response = await driversApi.create(payload, company.id);
if (response.error) {
const ve = (response as { validationErrors?: { msg?: string }[] }).validationErrors;
if (ve?.length) throw new Error(ve.map((e) => e.msg).join(' · '));
throw new Error(response.error);
if (response.status === 409) {
throw new Error(
`Ya existe un conductor con línea ${lineNum} para el transportista "${formData.transporter_key}". Usa un número de línea diferente.`
);
}
const ve = response.validationErrors as Array<{ col?: string; msg?: string }> | undefined;
throw new Error(ve?.length ? humanizeValidationErrors(ve) : response.error);
}
}
open = false;
if (onSuccess) onSuccess();
} catch (e) {
if (e && typeof e === 'object' && 'message' in e) {
error = (e as { message: string }).message;
} else {
error = 'Error al guardar el conductor';
}
error = e instanceof Error ? e.message : 'Error al guardar el conductor';
} finally {
loading = false;
}
@@ -204,165 +288,275 @@
</script>
<Dialog.Root bind:open>
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<Dialog.Content class="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<Dialog.Header>
<Dialog.Title>{title}</Dialog.Title>
<Dialog.Description>
{isEdit
? 'Modifica los datos del conductor'
: 'Completa los datos para crear un nuevo conductor'}
{isEdit ? 'Modifica los datos del conductor' : 'Completa los datos para crear un nuevo conductor'}
</Dialog.Description>
</Dialog.Header>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="space-y-6"
>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="space-y-4">
{#if error}
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">
{error}
</div>
<div class="rounded-md bg-destructive/15 p-3 text-sm text-destructive">{error}</div>
{/if}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="grid gap-2">
<Label for="transporter_key"
>Transportista <span class="text-destructive">*</span></Label
>
{#if isEdit}
<Input
id="transporter_key"
value={formData.transporter_key}
disabled
class="bg-muted"
/>
{:else}
<Select.Root
type="single"
bind:value={formData.transporter_key}
disabled={transportersLoading}
>
<Select.Trigger class="w-full">
{transportersLoading
? 'Cargando transportistas...'
: transporters.length === 0
? 'No hay transportistas'
: transporters.find((t) => t.transporter_key === formData.transporter_key)
? `${formData.transporter_key} - ${transporters.find((t) => t.transporter_key === formData.transporter_key)?.name || ''}`
: 'Seleccionar transportista'}
</Select.Trigger>
<Select.Content>
{#each transporters as t}
<Select.Item value={t.transporter_key} label={t.transporter_key}>
{t.transporter_key} — {t.name || t.short_name || 'Sin nombre'}
</Select.Item>
{/each}
{#if !transportersLoading && transporters.length === 0}
<div class="px-2 py-3 text-sm text-muted-foreground">
No hay transportistas. Crea uno en el catálogo Transportistas.
</div>
{/if}
</Select.Content>
</Select.Root>
{/if}
</div>
<Tabs.Root value="generales">
<Tabs.List class="w-full">
<Tabs.Trigger value="generales" class="flex-1">1) Generales</Tabs.Trigger>
<Tabs.Trigger value="identificaciones" class="flex-1">2) Identificaciones</Tabs.Trigger>
</Tabs.List>
<div class="grid gap-2">
<Label for="line">Línea <span class="text-destructive">*</span></Label>
<Input
id="line"
type="text"
inputmode="numeric"
pattern="[0-9]*"
bind:value={formData.lineStr}
disabled={isEdit}
required
placeholder="Ej: 1"
/>
</div>
<!-- Tab 1: Generales -->
<Tabs.Content value="generales" class="mt-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="grid gap-2 md:col-span-2">
<Label for="driver_name">Nombre del Conductor</Label>
<Input id="driver_name" bind:value={formData.driver_name} maxlength={80} />
</div>
<!-- Transportista -->
<div class="grid gap-2">
<Label for="transporter_key">Transportista <span class="text-destructive">*</span></Label>
{#if isEdit}
<Input id="transporter_key" value={formData.transporter_key} disabled class="bg-muted" />
{:else}
<Select.Root type="single" bind:value={formData.transporter_key} disabled={transportersLoading}>
<Select.Trigger class="w-full">
{transportersLoading
? 'Cargando...'
: transporters.find((t) => t.transporter_key === formData.transporter_key)
? `${formData.transporter_key} ${transporters.find((t) => t.transporter_key === formData.transporter_key)?.name || ''}`
: 'Seleccionar transportista'}
</Select.Trigger>
<Select.Content class="max-h-60">
{#each transporters as t}
<Select.Item value={t.transporter_key} label={t.transporter_key}>
{t.transporter_key} — {t.name || t.short_name || 'Sin nombre'}
</Select.Item>
{/each}
{#if !transportersLoading && transporters.length === 0}
<div class="px-2 py-3 text-sm text-muted-foreground">No hay transportistas. Crea uno primero.</div>
{/if}
</Select.Content>
</Select.Root>
{/if}
</div>
<div class="grid gap-2">
<Label for="first_name">Nombre</Label>
<Input id="first_name" bind:value={formData.first_name} maxlength={20} />
</div>
<!-- Línea -->
<div class="grid gap-2">
<Label for="line">Línea <span class="text-destructive">*</span></Label>
<Input id="line" type="text" inputmode="numeric" pattern="[0-9]*" bind:value={formData.lineStr} disabled={isEdit} placeholder="Ej: 1" />
</div>
<div class="grid gap-2">
<Label for="last_name">Apellido</Label>
<Input id="last_name" bind:value={formData.last_name} maxlength={20} />
</div>
<!-- Clave Conductor (driver_name) - full width -->
<div class="grid gap-2 md:col-span-2">
<Label for="driver_name">* Clave Conductor <span class="text-destructive">*</span></Label>
<Input id="driver_name" bind:value={formData.driver_name} maxlength={80} />
</div>
<div class="grid gap-2">
<Label for="license_number">Número de Licencia</Label>
<Input id="license_number" bind:value={formData.license_number} maxlength={29} />
</div>
<!-- Número de Licencia + Clase -->
<div class="grid gap-2">
<Label for="license_number">Número de Licencia</Label>
<Input id="license_number" bind:value={formData.license_number} maxlength={29} />
</div>
<div class="grid gap-2">
<Label for="class_type">Clase</Label>
<Select.Root type="single" bind:value={formData.class_type}>
<Select.Trigger class="w-full" id="class_type">
{formData.class_type || '— Opcional —'}
</Select.Trigger>
<Select.Content>
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
{#each CLASE_OPCIONES as c}
<Select.Item value={c} label={c}>{c}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="badge_number">Número de Placa/Insignia</Label>
<Input id="badge_number" bind:value={formData.badge_number} maxlength={20} />
</div>
<!-- Núm. Gafete -->
<div class="grid gap-2">
<Label for="badge_number">Núm. Gafete</Label>
<Input id="badge_number" bind:value={formData.badge_number} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="express_line_id">Express Line ID</Label>
<Input id="express_line_id" bind:value={formData.express_line_id} maxlength={17} />
</div>
<!-- Núm. Gafete Único -->
<div class="grid gap-2">
<Label for="unique_badge_number">Núm. Gafete Único</Label>
<Input id="unique_badge_number" bind:value={formData.unique_badge_number} maxlength={100} />
</div>
<div class="grid gap-2">
<Label for="ace_id">ACE ID</Label>
<Input id="ace_id" bind:value={formData.ace_id} maxlength={20} />
</div>
<!-- Nombre(s) -->
<div class="grid gap-2">
<Label for="first_name">Nombre(s)</Label>
<Input id="first_name" bind:value={formData.first_name} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="birth_country">País de Nacimiento (clave americana)</Label>
<Select.Root type="single" bind:value={formData.birth_country} disabled={countriesLoading}>
<Select.Trigger class="w-full" id="birth_country">
{countriesLoading
? 'Cargando países...'
: formData.birth_country
? `${formData.birth_country} ${countries.find((c) => c.ame_key === formData.birth_country)?.description_es ?? ''}`
: '— Opcional —'}
</Select.Trigger>
<Select.Content class="max-h-60">
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
{#each countries as c}
<Select.Item value={c.ame_key} label={c.ame_key}>
{c.ame_key}{c.description_es}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Apellido Paterno -->
<div class="grid gap-2">
<Label for="last_name">Apellido Paterno</Label>
<Input id="last_name" bind:value={formData.last_name} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="hazardous_material_auth">Auth. Material Peligroso</Label>
<Input id="hazardous_material_auth" bind:value={formData.hazardous_material_auth} maxlength={2} />
</div>
<!-- Fecha Nacimiento + Género -->
<div class="grid gap-2">
<Label for="birthDateStr">Fecha de Nacimiento</Label>
<Input id="birthDateStr" type="date" bind:value={formData.birthDateStr} />
</div>
<div class="grid gap-2">
<Label for="gender">Género (F o M)</Label>
<Select.Root type="single" bind:value={formData.gender}>
<Select.Trigger class="w-full" id="gender">
{formData.gender || '— Opcional —'}
</Select.Trigger>
<Select.Content>
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
<Select.Item value="M" label="M">M — Masculino</Select.Item>
<Select.Item value="F" label="F">F — Femenino</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="hazardous_material_state">Estado Material Peligroso</Label>
<Input id="hazardous_material_state" bind:value={formData.hazardous_material_state} maxlength={30} />
</div>
<!-- País Nacimiento -->
<div class="grid gap-2">
<Label for="birth_country">País Nacimiento</Label>
<Select.Root type="single" bind:value={formData.birth_country} disabled={countriesLoading}>
<Select.Trigger class="w-full" id="birth_country">
{countriesLoading ? 'Cargando...' : formData.birth_country ? `${formData.birth_country} ${countries.find((c) => c.ame_key === formData.birth_country)?.description_es ?? ''}` : '— Opcional —'}
</Select.Trigger>
<Select.Content class="max-h-60">
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
{#each countries as c}
<Select.Item value={c.ame_key} label={c.ame_key}>{c.ame_key} — {c.description_es}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="class_type">Tipo de Clase</Label>
<Input id="class_type" bind:value={formData.class_type} maxlength={1} />
</div>
</div>
<!-- Mat. Peligroso + Estado -->
<div class="grid gap-2">
<Label for="hazardous_material_auth">¿Autorizado Mat. Peligroso?</Label>
<Select.Root type="single" bind:value={formData.hazardous_material_auth}>
<Select.Trigger class="w-full" id="hazardous_material_auth">
{formData.hazardous_material_auth || '— Opcional —'}
</Select.Trigger>
<Select.Content>
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
<Select.Item value="SI" label="SI">SI</Select.Item>
<Select.Item value="NO" label="NO">NO</Select.Item>
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="hazardous_material_state">Estado de Autorización</Label>
<Input id="hazardous_material_state" bind:value={formData.hazardous_material_state} maxlength={30} />
</div>
</div>
</Tabs.Content>
<!-- Tab 2: Identificaciones -->
<Tabs.Content value="identificaciones" class="mt-4">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Express Line ID + ACE ID -->
<div class="grid gap-2">
<Label for="express_line_id">Permiso Línea Express</Label>
<Input id="express_line_id" bind:value={formData.express_line_id} maxlength={17} />
</div>
<div class="grid gap-2">
<Label for="ace_id">Identificación ACE</Label>
<Input id="ace_id" bind:value={formData.ace_id} maxlength={20} />
</div>
<!-- Separador Identificación 1 -->
<div class="md:col-span-2 border-t pt-2">
<p class="text-sm font-medium text-muted-foreground">Primera Identificación</p>
</div>
<div class="grid gap-2">
<Label for="id_key1">Forma de Identificación 1</Label>
<Select.Root type="single" bind:value={formData.id_key1}>
<Select.Trigger class="w-full" id="id_key1">
{FORMA_ID_OPCIONES.find((o) => o.value === formData.id_key1)?.label || '— Opcional —'}
</Select.Trigger>
<Select.Content class="max-h-60">
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
{#each FORMA_ID_OPCIONES as o}
<Select.Item value={o.value} label={o.value}>{o.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="id_number1">Núm. Identificación 1</Label>
<Input id="id_number1" bind:value={formData.id_number1} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="id_state1">Estado ID 1</Label>
<Input id="id_state1" bind:value={formData.id_state1} maxlength={30} />
</div>
<div class="grid gap-2">
<Label for="id_country1">País ID 1</Label>
<Select.Root type="single" bind:value={formData.id_country1} disabled={countriesLoading}>
<Select.Trigger class="w-full" id="id_country1">
{formData.id_country1 ? `${formData.id_country1} ${countries.find((c) => c.ame_key === formData.id_country1)?.description_es ?? ''}` : '— Opcional —'}
</Select.Trigger>
<Select.Content class="max-h-60">
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
{#each countries as c}
<Select.Item value={c.ame_key} label={c.ame_key}>{c.ame_key} — {c.description_es}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<!-- Separador Identificación 2 -->
<div class="md:col-span-2 border-t pt-2">
<p class="text-sm font-medium text-muted-foreground">Segunda Identificación</p>
</div>
<div class="grid gap-2">
<Label for="id_key2">Forma de Identificación 2</Label>
<Select.Root type="single" bind:value={formData.id_key2}>
<Select.Trigger class="w-full" id="id_key2">
{FORMA_ID_OPCIONES.find((o) => o.value === formData.id_key2)?.label || '— Opcional —'}
</Select.Trigger>
<Select.Content class="max-h-60">
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
{#each FORMA_ID_OPCIONES as o}
<Select.Item value={o.value} label={o.value}>{o.label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<div class="grid gap-2">
<Label for="id_number2">Núm. Identificación 2</Label>
<Input id="id_number2" bind:value={formData.id_number2} maxlength={20} />
</div>
<div class="grid gap-2">
<Label for="id_state2">Estado ID 2</Label>
<Input id="id_state2" bind:value={formData.id_state2} maxlength={30} />
</div>
<div class="grid gap-2">
<Label for="id_country2">País ID 2</Label>
<Select.Root type="single" bind:value={formData.id_country2} disabled={countriesLoading}>
<Select.Trigger class="w-full" id="id_country2">
{formData.id_country2 ? `${formData.id_country2} ${countries.find((c) => c.ame_key === formData.id_country2)?.description_es ?? ''}` : '— Opcional —'}
</Select.Trigger>
<Select.Content class="max-h-60">
<Select.Item value="" label="Vacío">— Vacío —</Select.Item>
{#each countries as c}
<Select.Item value={c.ame_key} label={c.ame_key}>{c.ame_key}{c.description_es}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
</div>
</Tabs.Content>
</Tabs.Root>
<Dialog.Footer>
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>
Cancelar
</Button>
<Button type="submit" disabled={loading}>
{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}
</Button>
<Button type="button" variant="outline" onclick={handleCancel} disabled={loading}>Cancelar</Button>
<Button type="submit" disabled={loading}>{loading ? 'Guardando...' : isEdit ? 'Actualizar' : 'Crear'}</Button>
</Dialog.Footer>
</form>
</Dialog.Content>

View File

@@ -213,7 +213,7 @@
bind:value={formData.transporter_key}
disabled={isEdit}
required
maxlength={23}
maxlength={30}
/>
</div>
@@ -234,12 +234,12 @@
<div class="grid gap-2">
<Label for="rfc">RFC</Label>
<Input id="rfc" bind:value={formData.rfc} />
<Input id="rfc" bind:value={formData.rfc} maxlength={30} />
</div>
<div class="grid gap-2">
<Label for="responsible">Responsable</Label>
<Input id="responsible" bind:value={formData.responsible} />
<Input id="responsible" bind:value={formData.responsible} maxlength={100} />
</div>
</section>
@@ -249,7 +249,7 @@
<div class="grid gap-2">
<Label for="caat_code">Código CAAT</Label>
<Input id="caat_code" bind:value={formData.caat_code} />
<Input id="caat_code" bind:value={formData.caat_code} maxlength={49} />
</div>
<div class="grid gap-2">
@@ -293,7 +293,7 @@
<div class="grid gap-2">
<Label for="streets">Calle y Número</Label>
<Input id="streets" bind:value={formData.streets} />
<Input id="streets" bind:value={formData.streets} maxlength={100} />
</div>
<div class="grid grid-cols-2 gap-4">
@@ -345,7 +345,7 @@
</div>
<div class="grid gap-2">
<Label for="postal_code">C.P.</Label>
<Input id="postal_code" bind:value={formData.postal_code} />
<Input id="postal_code" bind:value={formData.postal_code} maxlength={15} />
</div>
</div>
</section>
@@ -356,23 +356,23 @@
<div class="grid gap-2">
<Label for="ftp_server">Servidor FTP</Label>
<Input id="ftp_server" bind:value={formData.ftp_server} />
<Input id="ftp_server" bind:value={formData.ftp_server} maxlength={200} />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="grid gap-2">
<Label for="ftp_user">Usuario</Label>
<Input id="ftp_user" bind:value={formData.ftp_user} />
<Input id="ftp_user" bind:value={formData.ftp_user} maxlength={200} />
</div>
<div class="grid gap-2">
<Label for="ftp_password">Contraseña</Label>
<Input id="ftp_password" type="password" bind:value={formData.ftp_password} />
<Input id="ftp_password" type="password" bind:value={formData.ftp_password} maxlength={100} />
</div>
</div>
<div class="grid gap-2">
<Label for="ftp_directory">Directorio</Label>
<Input id="ftp_directory" bind:value={formData.ftp_directory} />
<Input id="ftp_directory" bind:value={formData.ftp_directory} maxlength={1000} />
</div>
</section>
</div>

View File

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

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { Calendar } from 'lucide-svelte';
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
import { cn, type WithElementRef } from "$lib/utils.js";
@@ -18,6 +19,19 @@
"data-slot": dataSlot = "input",
...restProps
}: Props = $props();
const isDateInput = $derived(type === 'date' || type === 'datetime-local');
function openDatePicker() {
if (!ref) return;
if ('showPicker' in ref && typeof ref.showPicker === 'function') {
ref.showPicker();
return;
}
ref.click();
}
</script>
{#if type === "file"}
@@ -35,6 +49,31 @@
bind:value
{...restProps}
/>
{:else if isDateInput}
<div class="relative w-full">
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
"border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 pr-11 text-base text-foreground outline-none transition-[color,box-shadow] appearance-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className,
"[&::-webkit-calendar-picker-indicator]:absolute [&::-webkit-calendar-picker-indicator]:inset-0 [&::-webkit-calendar-picker-indicator]:h-full [&::-webkit-calendar-picker-indicator]:w-full [&::-webkit-calendar-picker-indicator]:cursor-pointer [&::-webkit-calendar-picker-indicator]:opacity-0"
)}
type={type}
bind:value
{...restProps}
/>
<button
type="button"
aria-label="Abrir selector de fecha"
onclick={openDatePicker}
class="absolute top-1/2 right-1 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none"
>
<Calendar aria-hidden="true" class="h-4 w-4" />
</button>
</div>
{:else}
<input
bind:this={ref}

View File

@@ -259,12 +259,103 @@ const FIELD_MAP: Record<string, string> = {
'financial.unit_cost_capture': 'Costo Unitario',
'customs.fraction': 'Fracción Arancelaria',
'customs.origin_country': 'País de Origen',
'customs.american_fraction': 'Fracción Americana',
'fa_data.search_invoice': 'Factura de Referencia',
'fa_data.search_line': 'Línea de Referencia',
'fa_data.search_type': 'Tipo de Búsqueda',
'fa_data.movement_type_import': 'Tipo de Importación'
'fa_data.movement_type_import': 'Tipo de Importación',
'fa_data.is_subitem': 'Es Subpartida',
'fa_data.subitem_number': 'Número de Partida Principal'
};
const FIELD_GUIDANCE: Record<string, string> = {
class_id: 'Selecciona una clase.',
unit_of_measure: 'Selecciona una unidad de medida.',
'quantity.quantity': 'Captura una cantidad válida mayor a cero.',
'quantity.net_weight': 'Captura un peso neto válido mayor a cero.',
'customs.fraction': 'Selecciona una fracción arancelaria válida.',
'customs.origin_country': 'Selecciona un país de origen válido.',
'customs.fraction_type': 'Selecciona un tipo de tarifa.',
'customs.american_fraction': 'Selecciona una fracción americana válida.',
'description.description_spanish': 'Captura la descripción en español.',
'description.description_english': 'Captura la descripción en inglés.',
'financial.unit_cost_capture': 'Captura un costo unitario válido.',
'fa_data.search_invoice': 'Selecciona una factura de referencia.',
'fa_data.search_line': 'Selecciona una línea de referencia.',
'fa_data.search_type': 'Selecciona un tipo de búsqueda.',
'fa_data.movement_type_import': 'Selecciona TEM o DEF.',
'fa_data.subitem_number': 'Captura el número de la partida principal.'
};
function humanizeFieldPath(field: string): string {
const rawField = (field || '').trim();
if (!rawField) return 'campo';
const lineMatch = rawField.match(/^line\[(\d+)\]\.(.+)$/i);
const fieldPath = lineMatch?.[2] || rawField;
const mappedPath = fieldPath.replace(/^body\./i, '');
const fieldLabel = FIELD_MAP[mappedPath] || mappedPath.replace(/\./g, ' → ');
if (lineMatch) {
return `Partida ${lineMatch[1]} - ${fieldLabel}`;
}
return fieldLabel;
}
function humanizeValidationMessage(message: string): string {
const rawMessage = (message || '').trim();
if (!rawMessage) return 'error de validación';
return rawMessage
.replace(/line\[(\d+)\]\.(\w+(?:\.\w+)*)/gi, (_match, lineNumber, fieldPath) => {
return `Partida ${lineNumber} - ${humanizeFieldPath(fieldPath)}`;
})
.replace(/\b(field required|is required)\b/gi, 'es obligatorio')
.replace(/\b(value is not a valid decimal)\b/gi, 'debe ser un número válido')
.replace(/\b(value is not a valid integer)\b/gi, 'debe ser un número entero válido');
}
function formatFriendlyFieldMessage(fieldName: string, message: string, code?: string): string {
const cleanFieldName = fieldName.replace(/^Partida \d+ - /, '');
const guidance = FIELD_GUIDANCE[cleanFieldName] || FIELD_GUIDANCE[fieldName];
const normalizedMessage = humanizeValidationMessage(message);
if (code === 'REQUIRED' || code === 'REQUIRED_FIELD' || /es requerido|es obligatorio/i.test(normalizedMessage)) {
return guidance || `Completa ${fieldName}.`;
}
if (code === 'AMERICAN_FRACTION_NOT_FOUND') {
return `La fracción americana seleccionada no existe. Elige una opción del catálogo.`;
}
if (code === 'FRACTION_TYPE_INVALID') {
return 'Selecciona un tipo de tarifa válido.';
}
if (code === 'UNIT_OF_MEASURE_NOT_FOUND') {
return 'La unidad de medida seleccionada no existe. Elige una opción del catálogo.';
}
if (code === 'ORIGIN_COUNTRY_NOT_FOUND') {
return 'El país de origen seleccionado no existe. Elige una opción del catálogo.';
}
if (code === 'CLASS_NOT_FOUND') {
return 'La clase seleccionada no existe. Elige una opción del catálogo.';
}
if (code === 'PACKAGE_NOT_FOUND' || code === 'PACKAGE_ID_REQUIRED') {
return 'El paquete seleccionado no es válido. Elige una opción del catálogo.';
}
if (code === 'MOVEMENT_TYPE_IMPORT_INVALID') {
return 'Selecciona TEM o DEF para el tipo de importación.';
}
return normalizedMessage;
}
/**
* Formats a backend error into a human-readable Spanish message.
* Handles 422 (Validation), 403 (Forbidden), 404 (Not Found), and 500 (Server Error).
@@ -285,12 +376,8 @@ export function formatItemError(error: any): string {
// New structure (ApiResponse.validationErrors)
if (status === 422 && Array.isArray(validationErrors)) {
const errors = validationErrors.map((err: any) => {
const field = err.field || '';
const fieldName = FIELD_MAP[field] || field || 'campo';
let msg = err.message || 'error de validación';
if (msg.includes('field required')) msg = 'es obligatorio';
if (msg.includes('value is not a valid decimal')) msg = 'debe ser un número válido';
const fieldName = humanizeFieldPath(err.field || '');
const msg = formatFriendlyFieldMessage(fieldName, err.message || 'error de validación', err.code);
return `${fieldName}: ${msg}`;
});
@@ -305,11 +392,8 @@ export function formatItemError(error: any): string {
.filter((l: string) => l !== 'body')
.join('.');
const fieldName = FIELD_MAP[locPath] || locPath || 'campo';
let msg = err.msg || 'error de validación';
if (msg.includes('field required')) msg = 'es obligatorio';
if (msg.includes('value is not a valid decimal')) msg = 'debe ser un número válido';
const fieldName = humanizeFieldPath(locPath);
const msg = formatFriendlyFieldMessage(fieldName, err.msg || 'error de validación', err.type);
return `${fieldName}: ${msg}`;
});
@@ -322,7 +406,7 @@ export function formatItemError(error: any): string {
if (d.includes('Access denied')) return 'No tienes permisos para realizar esta acción.';
if (d.includes('not found')) return 'El registro no existe o fue eliminado.';
if (d.includes('Class mismatch')) return 'Error de validación: ' + d;
return d;
return humanizeValidationMessage(d);
}
// 4. Fallbacks by status code

View File

@@ -1,6 +1,4 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
@@ -12,7 +10,6 @@
import { driversApi, type Driver } from '$lib/api/dashboard/a76/drivers';
import { companyStore } from '$lib/stores/company.svelte';
import { browser } from '$app/environment';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosCatalogoSimple } from '$lib/config/shortcuts/dashboard/general_catalogs/common/factory';
@@ -21,34 +18,31 @@
let loading = $state(false);
let currentPage = $state(1);
let pageSize = $state(50);
let hasMore = $derived(data.length < totalItems);
let createDialogOpen = $state(false);
let searchTransporterKey = $state(page.url.searchParams.get('transporter_key') || '');
let searchDriverName = $state(page.url.searchParams.get('driver_name') || '');
let searchTimeout: ReturnType<typeof setTimeout>;
let searchTransporterKey = $state('');
let searchDriverName = $state('');
// Solo reacciona a cambios de URL (p. ej. atrás/adelante); no leer los campos de búsqueda aquí para no pisar lo que escribe el usuario.
$effect(() => {
const u = page.url;
searchTransporterKey = u.searchParams.get('transporter_key') || '';
searchDriverName = u.searchParams.get('driver_name') || '';
});
let filteredData = $derived(
data.filter((d) => {
const tk = searchTransporterKey.trim().toLowerCase();
const dn = searchDriverName.trim().toLowerCase();
if (tk && !(d.transporter_key ?? '').toLowerCase().includes(tk)) return false;
if (dn && !(d.driver_name ?? '').toLowerCase().includes(dn)) return false;
return true;
})
);
let hasMore = $derived(data.length < totalItems);
async function loadData() {
if (!companyStore.activeCompany) return;
loading = true;
try {
const params: Record<string, string | number> = {
const response = await driversApi.list(companyStore.activeCompany.id, {
page: 1,
page_size: pageSize
};
// Filtros: el backend aún no los soporta; se mantienen en URL para futura implementación
// if (searchTransporterKey) params.transporter_key = searchTransporterKey;
// if (searchDriverName) params.driver_name = searchDriverName;
const response = await driversApi.list(companyStore.activeCompany.id, params);
});
if (response.data) {
data = response.data.items;
currentPage = 1;
@@ -65,11 +59,10 @@
if (loading || !hasMore || !companyStore.activeCompany) return;
loading = true;
try {
const params: Record<string, string | number> = {
const response = await driversApi.list(companyStore.activeCompany.id, {
page: currentPage + 1,
page_size: pageSize
};
const response = await driversApi.list(companyStore.activeCompany.id, params);
});
if (response.data?.items) {
data = [...data, ...response.data.items];
currentPage += 1;
@@ -82,21 +75,8 @@
}
}
function handleSearch() {
if (!browser) return;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const url = new URL(page.url);
if (searchTransporterKey) url.searchParams.set('transporter_key', searchTransporterKey);
else url.searchParams.delete('transporter_key');
if (searchDriverName) url.searchParams.set('driver_name', searchDriverName);
else url.searchParams.delete('driver_name');
goto(url, { keepFocus: true, noScroll: true });
}, 500);
}
$effect(() => {
const _ = { p: page.url.href, c: companyStore.activeCompany?.id };
const _c = companyStore.activeCompany?.id;
loadData();
});
@@ -130,11 +110,11 @@
</div>
<Card.Root class="border bg-background flex flex-col">
<Card.Header><div class="flex flex-wrap items-center justify-between gap-3"><Card.Title>Listado de Conductores</Card.Title><div class="flex flex-wrap items-center gap-2"><Input placeholder="Clave transportista" class="h-9 w-44 bg-card lg:w-56" bind:value={searchTransporterKey} oninput={handleSearch} /><Input placeholder="Nombre" class="h-9 w-44 bg-card lg:w-56" bind:value={searchDriverName} oninput={handleSearch} /></div></div></Card.Header>
<Card.Content class="p-0">{#if loading && data.length === 0}<div class="flex h-64 items-center justify-center text-muted-foreground">Cargando conductores...</div>{:else}<div class="rounded-md border bg-background overflow-hidden"><InfiniteDataTable {data} {columns} {loading} {hasMore} {loadMore} /></div>{/if}</Card.Content>
<Card.Header><div class="flex flex-wrap items-center justify-between gap-3"><Card.Title>Listado de Conductores</Card.Title><div class="flex flex-wrap items-center gap-2"><Input placeholder="Clave transportista" class="h-9 w-44 bg-card lg:w-56" bind:value={searchTransporterKey} /><Input placeholder="Nombre" class="h-9 w-44 bg-card lg:w-56" bind:value={searchDriverName} /></div></div></Card.Header>
<Card.Content class="p-0">{#if loading && data.length === 0}<div class="flex h-64 items-center justify-center text-muted-foreground">Cargando conductores...</div>{:else}<div class="rounded-md border bg-background overflow-hidden"><InfiniteDataTable data={filteredData} {columns} {loading} {hasMore} {loadMore} /></div>{/if}</Card.Content>
</Card.Root>
<div class="flex-none text-sm text-muted-foreground">Mostrando {data.length} de {totalItems} registros</div>
<div class="flex-none text-sm text-muted-foreground">Mostrando {filteredData.length} de {totalItems} registros</div>
<CreateEditDialog bind:open={createDialogOpen} onSuccess={loadData} />
</div>

View File

@@ -140,7 +140,7 @@
}
// Crear columnas con el callback onSuccess
const columns = createColumns(handleSuccess);
const columns = createColumns(handleSuccess, true);
</script>
<div class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden">

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>