Merge branch 'development' of https://git.aduanasoft.com/ADUANASOFT/anexo76 into fix/modulos-ingles

This commit is contained in:
2026-06-01 16:17:09 -05:00
13 changed files with 517 additions and 111 deletions

View File

@@ -598,8 +598,9 @@ export const invoicesApi = {
);
},
copyInvoice: (invoiceId: number, companyId: number) => {
copyInvoice: (invoiceId: number, companyId: number, newInvoiceNumber?: string) => {
const params = new URLSearchParams({ company_id: companyId.toString() });
if (newInvoiceNumber) params.set('new_invoice_number', newInvoiceNumber);
return api.post<Invoice>(`/v1/a76/invoices/${invoiceId}/copy?${params.toString()}`, {});
},
copyInvoiceHeader: (invoiceId: number, companyId: number) => {

View File

@@ -0,0 +1,148 @@
import type { InvoiceType } from '$lib/api/dashboard/reference_data/invoice_types';
export type InvoiceListFilters = {
operation_type?: string | null;
invoice_type?: string | null;
};
/** Claves de importación expuestas en el menú lateral. */
export const IMPORT_INVOICE_TYPE_KEYS = ['TEM', 'DEF', 'MEX', 'CR', 'REP'] as const;
/** Claves de exportación relevantes para configuración (Exportación y Reparación). */
export const EXPORT_INVOICE_TYPE_KEYS = ['EXDEF', 'REPAR'] as const;
/**
* Filtra tipos de factura por operación (imp/exp), incluyendo tipos marcados como `both`.
*/
export function filterInvoiceTypesByOperation(
invoiceTypes: InvoiceType[],
operationType: string
): InvoiceType[] {
if (!operationType) return [];
return invoiceTypes.filter(
(type) => !type.operation || type.operation === 'both' || type.operation === operationType
);
}
/**
* Tipos visibles en parámetros.
*
* - Para importación: se limitan a las claves expuestas en el menú lateral (TEM, DEF, MEX, CR, REP).
* - Para exportación: se permiten todos los tipos cuyo `operation` sea `exp` (catálogo completo).
*/
export function getInvoiceTypesForSettings(
invoiceTypes: InvoiceType[],
operationType: string
): InvoiceType[] {
const filtered = filterInvoiceTypesByOperation(invoiceTypes, operationType);
if (!operationType) return filtered;
// Importación: solo los tipos configurables desde el menú lateral
if (operationType === 'imp') {
const byKey = new Map(filtered.map((type) => [type.key, type]));
return IMPORT_INVOICE_TYPE_KEYS.map((key) => byKey.get(key)).filter(
(type): type is InvoiceType => !!type
);
}
if (operationType === 'exp') {
const byKey = new Map(filtered.map((type) => [type.key, type]));
const fromBackend = EXPORT_INVOICE_TYPE_KEYS.map((key) => byKey.get(key)).filter(
(type): type is InvoiceType => !!type
);
// Si el backend devolvió ambos tipos, usar sus descripciones reales.
if (fromBackend.length > 0) {
return fromBackend;
}
// Fallback mínimo para no dejar el combo vacío
const fallback: InvoiceType[] = [
{ key: 'EXDEF', description: 'EXPORTACIÓN DEFINITIVA', operation: 'exp' },
{ key: 'REPAR', description: 'REPARACIÓN', operation: 'exp' }
];
return fallback;
}
// Otra operación válida: devolvemos lo filtrado
return filtered;
}
/**
* Tipo de factura por defecto cuando la URL no lo incluye.
*/
export function getDefaultInvoiceTypeForOperation(
operationType: string,
invoiceTypes: InvoiceType[]
): string {
const options = getInvoiceTypesForSettings(invoiceTypes, operationType);
if (options.length === 0) return '';
if (operationType === 'imp') {
return options.find((type) => type.key === 'TEM')?.key ?? options[0].key;
}
if (operationType === 'exp') {
return (
options.find((type) => type.key === 'EXDEF')?.key ??
options.find((type) => type.key === 'REPAR')?.key ??
options[0].key
);
}
return options[0].key;
}
/**
* Construye la ruta del listado de facturas preservando query params y evitando
* navegar sin `operation_type` (el SSR del listado redirige al dashboard).
*/
export function buildInvoicesListPath(
searchParams: URLSearchParams,
fallbacks?: InvoiceListFilters
): string {
const params = new URLSearchParams(searchParams);
if (!params.has('operation_type') && fallbacks?.operation_type) {
params.set('operation_type', fallbacks.operation_type);
}
if (!params.has('invoice_type') && fallbacks?.invoice_type) {
params.set('invoice_type', fallbacks.invoice_type);
}
const operationType = params.get('operation_type');
if (!operationType) {
return '/dashboard';
}
const qs = params.toString();
return qs ? `/dashboard/invoices?${qs}` : `/dashboard/invoices?operation_type=${operationType}`;
}
/**
* Construye la ruta de parámetros de factura conservando el contexto del listado.
*/
export function buildInvoicesSettingsPath(searchParams: URLSearchParams): string {
const params = new URLSearchParams(searchParams);
const qs = params.toString();
return qs ? `/dashboard/invoices/settings?${qs}` : '/dashboard/invoices/settings';
}
/**
* Resuelve operación y tipo inicial para la pantalla de parámetros.
*/
export function resolveInvoiceSettingsContext(
searchParams: URLSearchParams,
invoiceTypes: InvoiceType[]
): { operationType: string; invoiceType: string } {
const operationType = searchParams.get('operation_type') ?? '';
let invoiceType = searchParams.get('invoice_type') ?? '';
if (operationType && !invoiceType) {
invoiceType = getDefaultInvoiceTypeForOperation(operationType, invoiceTypes);
}
return { operationType, invoiceType };
}

View File

@@ -38,14 +38,14 @@
{#snippet child({ props })}
<button
{...props}
class="inline-flex size-8 shrink-0 items-center justify-center rounded-md
class="inline-flex size-10 shrink-0 select-none items-center justify-center rounded-md
text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground
transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring
data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
title="Aplicaciones"
aria-label="Abrir selector de aplicaciones"
>
<LayoutGridIcon class="size-4" />
<LayoutGridIcon class="size-5" />
</button>
{/snippet}
</DropdownMenu.Trigger>

View File

@@ -84,6 +84,21 @@
inventory: BarChart3Icon,
};
const SYSTEM_COLORS: Record<SystemType, { border: string; bg: string; iconBg: string; iconText: string }> = {
fixed_asset: {
border: 'border-amber-500/40',
bg: 'bg-amber-500/5 dark:bg-amber-500/10',
iconBg: 'bg-amber-500/10',
iconText: 'text-amber-600 dark:text-amber-400',
},
inventory: {
border: 'border-emerald-500/40',
bg: 'bg-emerald-500/5 dark:bg-emerald-500/10',
iconBg: 'bg-emerald-500/10',
iconText: 'text-emerald-600 dark:text-emerald-400',
},
};
onMount(() => {
const expandForKeyboardNav = () => {
sidebar.setOpen(true);
@@ -104,15 +119,15 @@
{@const activeLabel = systemStore.activeLabel}
{@const activeSystem = systemStore.activeSystem}
{@const Icon = activeSystem ? ACTIVE_ICONS[activeSystem] : null}
{@const colors = activeSystem ? SYSTEM_COLORS[activeSystem] : null}
<div
class="flex items-center gap-2 rounded-lg border border-primary/40 bg-primary/5 px-3 py-2 text-xs shadow-sm
dark:bg-primary/10"
class="flex items-center gap-2 rounded-lg border {colors?.border} {colors?.bg} px-3 py-2 text-xs shadow-sm"
aria-label={`Aplicación activa: ${activeLabel.name}`}
title={`Aplicación activa: ${activeLabel.name}`}
>
{#if Icon}
<div class="flex size-8 items-center justify-center rounded-md bg-primary/10 text-primary">
<div class="flex size-8 items-center justify-center rounded-md {colors?.iconBg} {colors?.iconText}">
<Icon class="size-4" />
</div>
{/if}

View File

@@ -7,6 +7,7 @@
import FixedAssetClassForm from '$lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte';
import { Folder, Save, Plus, RefreshCw } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { friendlyApiErrorParts, type ApiResponse } from '$lib/api';
import { classesApi, type A76Class } from '$lib/api/dashboard/a76/classes';
import { faClassesApi, type FAClass } from '$lib/api/dashboard/a24/fa_classes';
import { companyStore } from '$lib/stores/company.svelte';
@@ -322,29 +323,33 @@
const idsToDelete = [...selectedClassIds];
let okCount = 0;
const errors: number[] = [];
const failedResponses: ApiResponse[] = [];
for (const id of idsToDelete) {
try {
// El backend elimina automáticamente la extensión FA si existe
await classesApi.delete(id, companyId);
// El backend elimina automáticamente la extensión FA si existe
const res = await classesApi.delete(id, companyId);
if (res.status >= 400 || res.error) {
console.error(`Error deleting class ${id}:`, res.error ?? res.status);
failedResponses.push(res);
} else {
okCount++;
} catch (error) {
console.error(`Error deleting class ${id}:`, error);
errors.push(id);
}
}
if (errors.length === 0) {
if (failedResponses.length === 0) {
toast.success(
okCount === 1
? 'Clase eliminada correctamente'
: `${okCount} clases eliminadas correctamente`
);
} else if (okCount === 0) {
toast.error(`Error al eliminar ${errors.length} clase(s)`);
const { title, description } = friendlyApiErrorParts(failedResponses[0]);
toast.error(title, { description });
} else {
toast.error(`${okCount} eliminada(s), ${errors.length} con error`);
const { title, description } = friendlyApiErrorParts(failedResponses[0]);
toast.error(`${okCount} eliminada(s), ${failedResponses.length} con error`, {
description: description || title
});
}
await loadClasses();

View File

@@ -13,6 +13,7 @@
import { reportsWinsaaiApi } from '$lib/api/dashboard/a76/reports/reports-winsaai';
import DataTable from '$lib/components/dashboard/invoices/data-table.svelte';
import { createColumns } from '$lib/components/dashboard/invoices/columns';
import { buildInvoicesSettingsPath } from '$lib/components/dashboard/invoices/invoice-list-navigation';
import * as Card from '$lib/components/ui/card';
import * as AlertDialog from '$lib/components/ui/alert-dialog';
import * as Dialog from '$lib/components/ui/dialog';
@@ -319,6 +320,30 @@
if (fromContextMenu) contextMenuOpen = false;
}
// --- Dialog: nombre para la copia de factura ---
let copyDialogOpen = $state(false);
let copyDialogInvoice = $state<Invoice | null>(null);
let copyDialogNumber = $state('');
function openCopyDialog(inv: Invoice) {
copyDialogInvoice = inv;
copyDialogNumber = `${inv.invoice_number ?? ''}-COPIA`;
copyDialogOpen = true;
}
function handleCopyConfirm() {
if (!copyDialogInvoice || !copyDialogNumber.trim()) return;
const inv = copyDialogInvoice;
const num = copyDialogNumber.trim();
copyDialogOpen = false;
invoicesApi.copyInvoice(inv.id, companyStore.activeCompany!.id, num)
.then((res) => {
if (res.error) { toast.error(res.error); return; }
toast.success('Factura copiada');
reloadData();
});
}
function openExportItemsDialog(inv: Invoice) {
exportItemsInvoice = inv;
exportItemsFormat = 'csv';
@@ -1629,7 +1654,13 @@
<p class="text-muted-foreground">{m.invoice_list_header_description()}</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button variant="outline" class="h-9" onclick={() => goto('/dashboard/invoices/settings')}>
<Button
variant="outline"
class="h-9"
onclick={() => {
void goto(buildInvoicesSettingsPath(new URLSearchParams(window.location.search)));
}}
>
<Settings class="mr-2" size={16} />
{m.invoice_list_actions_parameters()}
</Button>
@@ -1727,6 +1758,33 @@
<div class="h-20"></div>
<!-- Dialog: nombre para la copia de factura -->
<Dialog.Root bind:open={copyDialogOpen}>
<Dialog.Content class="sm:!max-w-md">
<Dialog.Header>
<Dialog.Title>Copiar Factura</Dialog.Title>
<Dialog.Description>
Indica el número que tendrá la factura copiada.
</Dialog.Description>
</Dialog.Header>
<div class="space-y-2 py-2">
<Label for="copy-invoice-number">Número de Factura Nuevo</Label>
<Input
id="copy-invoice-number"
bind:value={copyDialogNumber}
placeholder="Ej. 123123-COPIA"
onkeydown={(e) => { if (e.key === 'Enter') handleCopyConfirm(); }}
/>
</div>
<Dialog.Footer>
<Button variant="outline" onclick={() => (copyDialogOpen = false)}>Cancelar</Button>
<Button onclick={handleCopyConfirm} disabled={!copyDialogNumber.trim()}>
Proceso Copiar
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root bind:open={isCoveDialogOpen}>
<Dialog.Content class="sm:!max-w-2xl">
<Dialog.Header>
@@ -1940,12 +1998,8 @@
</button>
{/snippet}
{@render cmItem(Copy, 'Copiar Factura', () => {
invoicesApi.copyInvoice(contextMenuInvoice!.id, companyStore.activeCompany!.id)
.then((res) => {
if (res.error) { toast.error(res.error); return; }
toast.success('Factura copiada');
reloadData();
});
contextMenuOpen = false;
openCopyDialog(contextMenuInvoice!);
})}
{@render cmItem(Download, 'Exportar Partidas', () => {
openExportItemsDialog(contextMenuInvoice!);
@@ -2330,14 +2384,8 @@
<DropdownMenu.Item
onclick={() => {
if (!selectedInvoice) return;
const invId = selectedInvoice.id;
copiasInterfacesMenuOpen = false;
invoicesApi.copyInvoice(invId, companyStore.activeCompany!.id)
.then((res) => {
if (res.error) { toast.error(res.error); return; }
toast.success('Factura copiada');
reloadData();
});
openCopyDialog(selectedInvoice);
}}
>
<Copy class="mr-2 h-4 w-4" />

View File

@@ -15,6 +15,14 @@ export const load: PageServerLoad = async ({ cookies, fetch, url }) => {
throw error(400, 'No se encontró una compañía seleccionada');
}
const operationTypeParam = url.searchParams.get('operation_type');
const invoiceTypeParam = url.searchParams.get('invoice_type');
let parsedOperationType: string | null = null;
if (operationTypeParam && (operationTypeParam === 'exp' || operationTypeParam === 'imp')) {
parsedOperationType = operationTypeParam;
}
// Load necessary reference data for the settings form
// We need: InvoiceTypes, CustomsBrokers, Incoterms, etc. to populate the reusable forms
@@ -22,7 +30,7 @@ export const load: PageServerLoad = async ({ cookies, fetch, url }) => {
// Reuse the same calls as in edit/[id] to ensure we have data for the dropdowns
const invoiceTypesPromise = authenticatedFetch(
'v1/public/reference_data/invoice-types/?page=1&page_size=100',
'v1/public/reference_data/invoice-types?page=1&page_size=100',
{},
cookies,
fetch
@@ -237,7 +245,11 @@ export const load: PageServerLoad = async ({ cookies, fetch, url }) => {
seals: seals.items || [],
enclosure: enclosures.items || [],
pedimentos: pedimentos.items || [],
companyId
companyId,
filters: {
operation_type: parsedOperationType,
invoice_type: invoiceTypeParam || null
}
};
} catch (err) {

View File

@@ -28,13 +28,29 @@
import SsisdefSettingsForm from '$lib/components/dashboard/invoices/settings/ssisdef-settings-form.svelte';
import SsicmSettingsForm from '$lib/components/dashboard/invoices/settings/ssicm-settings-form.svelte';
import SscrSettingsForm from '$lib/components/dashboard/invoices/settings/sscr-settings-form.svelte';
import {
buildInvoicesListPath,
getDefaultInvoiceTypeForOperation,
getInvoiceTypesForSettings,
resolveInvoiceSettingsContext
} from '$lib/components/dashboard/invoices/invoice-list-navigation';
import { m } from '$lib/i18n/messages';
// Props
let { data } = $props();
const initialParams = new URLSearchParams();
if (data.filters?.operation_type) {
initialParams.set('operation_type', data.filters.operation_type);
}
if (data.filters?.invoice_type) {
initialParams.set('invoice_type', data.filters.invoice_type);
}
const initialContext = resolveInvoiceSettingsContext(initialParams, data.invoiceTypes || []);
// State
let selectedInvoiceType = $state<string>('');
let selectedOperationType = $state<string>('');
let selectedInvoiceType = $state<string>(initialContext.invoiceType);
let selectedOperationType = $state<string>(initialContext.operationType);
let isLoading = $state(false);
let isSaving = $state(false);
let activeTab = $state('general');
@@ -85,6 +101,13 @@
{ value: 'exp', label: 'Exportación' }
];
let invoiceTypesForOperation = $derived(
getInvoiceTypesForSettings(
data.invoiceTypes || [],
selectedOperationType || initialContext.operationType
)
);
onMount(async () => {
try {
const companyStoreModule = await import('$lib/stores/company.svelte');
@@ -415,8 +438,15 @@
return obj;
}
function invoicesListPath(): string {
return buildInvoicesListPath($page.url.searchParams, {
operation_type: selectedOperationType || null,
invoice_type: selectedInvoiceType || null
});
}
function handleBack() {
goto('/dashboard/invoices');
goto(invoicesListPath());
}
let operationTypeText = $derived.by(() => {
@@ -503,7 +533,6 @@
const cid = companyStore?.activeCompany?.id;
if (browser && cid && selectedOperationType && selectedInvoiceType && !lastLoadedKey && !isLoading) {
untrack(() => {
console.log('EFFECT: Initial load triggered');
loadSettings();
});
}
@@ -512,6 +541,12 @@
// Handle explicit changes
function handleOperationTypeChange(v: string) {
selectedOperationType = v;
const options = getInvoiceTypesForSettings(data.invoiceTypes || [], v);
const stillValid = options.some((type) => type.key === selectedInvoiceType);
if (!stillValid) {
selectedInvoiceType = getDefaultInvoiceTypeForOperation(v, data.invoiceTypes || []);
lastLoadedKey = '';
}
loadSettings();
}
@@ -540,7 +575,9 @@
{/if}
{#if selectedInvoiceType}
<Badge variant="secondary">
{data.invoiceTypes.find((t: any) => t.key === selectedInvoiceType)?.description || selectedInvoiceType}
{invoiceTypesForOperation.find((t: any) => t.key === selectedInvoiceType)?.description ||
data.invoiceTypes.find((t: any) => t.key === selectedInvoiceType)?.description ||
selectedInvoiceType}
</Badge>
{/if}
</div>
@@ -553,45 +590,32 @@
<Separator />
<div class="pb-48">
<Card.Root class="mb-8 border-dashed bg-muted/30">
<Card.Header class="py-4">
<Card.Title class="text-lg">Contexto de Configuración</Card.Title>
<Card.Description
>Selecciona el contexto para editar sus valores por defecto.</Card.Description
>
</Card.Header>
<Card.Content class="flex gap-6 py-2">
<div class="space-y-2">
<Label class="text-xs uppercase text-muted-foreground font-bold tracking-wider">Tipo de Operación</Label>
<Select type="single" value={selectedOperationType} onValueChange={handleOperationTypeChange}>
<SelectTrigger class="w-[200px] h-10 bg-background">
{operationTypes.find((t: any) => t.value === selectedOperationType)?.label ||
'Seleccionar...'}
</SelectTrigger>
<SelectContent>
{#each operationTypes as type}
<SelectItem value={type.value}>{type.label}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label class="text-xs uppercase text-muted-foreground font-bold tracking-wider">Tipo de Factura</Label>
<Select type="single" value={selectedInvoiceType} onValueChange={handleInvoiceTypeChange}>
<SelectTrigger class="w-[300px] h-10 bg-background">
{@const type = data.invoiceTypes.find((t: any) => t.key === selectedInvoiceType)}
{type ? `${type.key} - ${type.description}` : 'Seleccionar...'}
</SelectTrigger>
<SelectContent class="max-h-[300px]">
{#each data.invoiceTypes as type}
<SelectItem value={type.key}>{type.key} - {type.description}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</Card.Content>
</Card.Root>
{#if !selectedOperationType || selectedOperationType !== 'imp'}
<Card.Root class="mb-8 border-dashed bg-muted/30">
<Card.Header class="py-4">
<Card.Title class="text-lg">Contexto de Configuración</Card.Title>
<Card.Description
>Selecciona el contexto para editar sus valores por defecto.</Card.Description
>
</Card.Header>
<Card.Content class="flex gap-6 py-2">
<div class="space-y-2">
<Label class="text-xs uppercase text-muted-foreground font-bold tracking-wider">Tipo de Factura</Label>
<Select type="single" value={selectedInvoiceType} onValueChange={handleInvoiceTypeChange}>
<SelectTrigger class="w-[300px] h-10 bg-background">
{@const type = invoiceTypesForOperation.find((t: any) => t.key === selectedInvoiceType)}
{type ? `${type.key} - ${type.description}` : 'Seleccionar...'}
</SelectTrigger>
<SelectContent class="max-h-[300px]">
{#each invoiceTypesForOperation as type}
<SelectItem value={type.key}>{type.key} - {type.description}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</Card.Content>
</Card.Root>
{/if}
{#if selectedInvoiceType && selectedOperationType}
{#if isLoading}
@@ -615,7 +639,7 @@
<InvoiceTopFields
{invoice}
bind:formData={InvoiceTopFieldsFormData}
invoiceTypes={data.invoiceTypes || []}
invoiceTypes={invoiceTypesForOperation}
pedimentos={data.pedimentos || []}
defaultOperationType={selectedOperationType}
defaultInvoiceType={selectedInvoiceType}
@@ -628,7 +652,7 @@
<GeneralTabForm
{invoice}
bind:formData={generalFormData}
invoiceTypes={data.invoiceTypes || []}
invoiceTypes={invoiceTypesForOperation}
customsBrokers={data.customsBrokers || []}
clients={data.clients || []}
providers={data.providers || []}
@@ -817,6 +841,9 @@
{/if}
<div class="flex justify-end gap-3">
<Button variant="outline" onclick={handleBack} disabled={isSaving}>
{m.invoice_edit_page_cancel()}
</Button>
<Button variant="outline" onclick={resetForms} disabled={isSaving || !selectedInvoiceType}>
Restablecer
</Button>