diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py b/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py index 92f4d00f..702988b0 100644 --- a/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py @@ -207,13 +207,16 @@ class PedimentosService: Pedimentos.year == pedimento_data.year, Pedimentos.customs_office == pedimento_data.customs_office, Pedimentos.license == pedimento_data.license, - Pedimentos.pedimento_number == pedimento_data.pedimento_number, - Pedimentos.deleted_at.is_(None) + Pedimentos.pedimento_number == pedimento_data.pedimento_number ).first() if existing: + if existing.deleted_at: + raise ValueError( + f"Ya existe un pedimento con estos datos ({pedimento_data.year}-{pedimento_data.customs_office}-{pedimento_data.license}-{pedimento_data.pedimento_number}) pero está en la papelera. Debes restaurarlo o usar otro número." + ) raise ValueError( - f"Ya existe un pedimento con estos datos: {pedimento_data.year}-{pedimento_data.customs_office}-{pedimento_data.license}-{pedimento_data.pedimento_number}" + f"Ya existe un pedimento registrado con estos datos: {pedimento_data.year}-{pedimento_data.customs_office}-{pedimento_data.license}-{pedimento_data.pedimento_number}" ) # Extraer datos de tablas relacionadas diff --git a/backend/core/error_handlers.py b/backend/core/error_handlers.py index c3c4c90c..dd3e49db 100644 --- a/backend/core/error_handlers.py +++ b/backend/core/error_handlers.py @@ -112,7 +112,7 @@ async def integrity_error_handler( orig_msg = str(exc.orig).lower() # Check for unique/duplicate key violations (English and Spanish) - if any(kw in orig_msg for kw in ["unique constraint", "duplicate key", "duplicada", "unicidad", "ya existe"]): + if any(kw in orig_msg for kw in ["unique constraint", "duplicate key", "duplicada", "unicidad", "ya existe", "llave duplicada", "pedimentos_unique_key"]): error_message = "El registro ya existe. Verifica los campos únicos (Año, Aduana, Patente, Número, etc.)." # Check for foreign key violations (English and Spanish) elif any(kw in orig_msg for kw in ["foreign key", "foránea", "referencia"]): @@ -123,13 +123,18 @@ async def integrity_error_handler( else: error_message = "Error de integridad en la base de datos" + content = { + "error": "DATABASE_INTEGRITY_ERROR", + "message": error_message, + "status_code": status.HTTP_409_CONFLICT, + } + + if settings.DEBUG: + content["debug_detail"] = str(exc.orig) + response = JSONResponse( status_code=status.HTTP_409_CONFLICT, - content={ - "error": "DATABASE_INTEGRITY_ERROR", - "message": error_message, - "status_code": status.HTTP_409_CONFLICT, - }, + content=content, ) for k, v in _cors_headers(request).items(): response.headers[k] = v diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a525d200..daed15c2 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -11,6 +11,7 @@ const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, ''); export interface ApiResponse { data?: T; error?: string; + details?: any; validationErrors?: Array<{ field: string; message: string; @@ -226,7 +227,8 @@ async function fetchApi( return { error: data.message || (typeof data.detail === 'string' ? data.detail : JSON.stringify(data.detail)) || 'Error en la petición', - status: response.status + status: response.status, + details: data }; } diff --git a/frontend/src/lib/components/dashboard/customs_brokers/data-table-actions.svelte b/frontend/src/lib/components/dashboard/customs_brokers/data-table-actions.svelte index 5e203e5b..847763b7 100644 --- a/frontend/src/lib/components/dashboard/customs_brokers/data-table-actions.svelte +++ b/frontend/src/lib/components/dashboard/customs_brokers/data-table-actions.svelte @@ -39,13 +39,15 @@ - {#snippet child({ props })} - - {/snippet} - + + Acciones diff --git a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte index d5578a0c..606853ba 100644 --- a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte @@ -76,6 +76,8 @@ material_description: '', unit_of_measure_description: '', unit_measure_key: '', + fraction_description: '', + us_fraction_description: '', fraction_umt: '', fraction_uma_key: '', us_fraction_ad_valorem: '', @@ -108,7 +110,8 @@ formData.import_tariff_code = snap.import_tariff_code ?? ''; formData.import_tariff_type = snap.import_tariff_type ?? ''; formData.export_tariff_code = snap.export_tariff_code ?? ''; - formData.export_tariff_type = snap.export_tariff_type ?? ''; + // Si tenemos initialData con claves cargadas en modo edición, deberíamos resolver sus descripciones si no las tenemos. + // (Opcional si el backend ya manda la descripción) } else { // Reset form when initialData is null (new class) formData.class_code = ''; @@ -160,6 +163,7 @@ function selectFraction(fraction: TariffFraction) { formData.fraction = fraction.fraction; + formData.fraction_description = fraction.description || ''; formData.fraction_umt = (fraction.umt ?? '') as string; formData.fraction_uma_key = (fraction.nico ?? '') as string; // Actualizar tarifa de importación @@ -317,6 +321,7 @@ function selectUSFraction(fraction: USTariffFraction) { formData.us_fraction = fraction.code; + formData.us_fraction_description = fraction.description || ''; formData.us_fraction_ad_valorem = fraction.ad_valorem?.toString() || '0.00'; formData.us_fraction_fixed_rate = fraction.fixed_cost?.toString() || '0.00000000'; // Actualizar tarifa de exportación @@ -449,6 +454,137 @@ return Object.keys(errors).length === 0; } + // Auto-fill callbacks + function handleMaterialKeyBlur() { + const keyUpper = formData.material_key?.trim().toUpperCase() || ''; + formData.material_key = keyUpper; + + if (!keyUpper) { + formData.material_description = ''; + validateField('material_key'); + return; + } + + if (materialTypes.length === 0) { + const companyId = companyStore.activeCompany?.id; + if (companyId) { + materialTypesApi + .list(1, 100, 'ACTIVO FIJO') + .then((response) => { + if (response.data) { + materialTypes = response.data.items; + const mat = materialTypes.find((m) => m.key.toUpperCase() === keyUpper); + formData.material_description = mat ? mat.description : ''; + } + }) + .catch(console.error); + } + } else { + const mat = materialTypes.find((m) => m.key.toUpperCase() === keyUpper); + formData.material_description = mat ? mat.description : ''; + } + + validateField('material_key'); + } + + function handleUnitOfMeasureBlur() { + const umUpper = formData.unit_of_measure?.trim().toUpperCase() || ''; + formData.unit_of_measure = umUpper; + + if (!umUpper) { + formData.unit_of_measure_description = ''; + formData.unit_measure_key = ''; + validateField('unit_of_measure'); + return; + } + + if (unitsOfMeasureData.length === 0) { + loadUnitsOfMeasure() + .then(() => { + const um = unitsOfMeasureData.find((u) => u.code.toUpperCase() === umUpper); + if (um) { + formData.unit_of_measure_description = um.description; + formData.unit_measure_key = um.claveMexicana; + } else { + formData.unit_of_measure_description = ''; + formData.unit_measure_key = ''; + } + }) + .catch(console.error); + } else { + const um = unitsOfMeasureData.find((u) => u.code.toUpperCase() === umUpper); + if (um) { + formData.unit_of_measure_description = um.description; + formData.unit_measure_key = um.claveMexicana; + } else { + formData.unit_of_measure_description = ''; + formData.unit_measure_key = ''; + } + } + + validateField('unit_of_measure'); + } + + function handleFractionBlur() { + const val = formData.fraction?.trim() || ''; + formData.fraction = val; + + if (!val) { + formData.fraction_description = ''; + formData.fraction_umt = ''; + validateField('fraction'); + return; + } + + if (companyStore.activeCompany?.id) { + getTariffFractions(1, 10, companyStore.activeCompany.id, { fraction: val }) + .then((response) => { + if (response.data && response.data.items.length > 0) { + // Intentar encontrar coincidencia exacta primero + const item = + response.data.items.find((i) => i.fraction === val) || response.data.items[0]; + formData.fraction_description = item.description || ''; + formData.fraction_umt = item.umt || ''; + } else { + formData.fraction_description = ''; + formData.fraction_umt = ''; + } + }) + .catch(console.error); + } + validateField('fraction'); + } + + function handleUSFractionBlur() { + const val = formData.us_fraction?.trim() || ''; + formData.us_fraction = val; + + if (!val) { + formData.us_fraction_description = ''; + formData.us_fraction_ad_valorem = ''; + formData.us_fraction_fixed_rate = ''; + // No tiene validación mandatoria según el código actual, pero llamamos por si acaso + return; + } + + if (companyStore.activeCompany?.id) { + getUSTariffFractions(1, 10, companyStore.activeCompany.id, { code: val }) + .then((response) => { + if (response.data && response.data.items.length > 0) { + const item = response.data.items.find((i) => i.code === val) || response.data.items[0]; + formData.us_fraction_description = item.description || ''; + formData.us_fraction_ad_valorem = item.ad_valorem?.toString() || ''; + formData.us_fraction_fixed_rate = item.fixed_cost?.toString() || ''; + } else { + formData.us_fraction_description = ''; + formData.us_fraction_ad_valorem = ''; + formData.us_fraction_fixed_rate = ''; + } + }) + .catch(console.error); + } + } + // Validar campo individual (para validación en blur) function validateField(fieldName: string) { if (!showErrors) return; // Solo validar si ya se intentó guardar @@ -522,14 +658,19 @@ } // Escuchar el evento de guardado del padre - if (typeof document !== 'undefined') { - document.addEventListener('save-form', handleSave); - } + $effect(() => { + if (typeof document !== 'undefined') { + document.addEventListener('save-form', handleSave); + return () => { + document.removeEventListener('save-form', handleSave); + }; + } + });
-
+
- {#if validationErrors.material_key} -

{validationErrors.material_key}

- {/if} +
+ {#if validationErrors.material_key} +

{validationErrors.material_key}

+ {:else if formData.material_description} + + {formData.material_description} + + {/if} +
@@ -629,18 +773,21 @@ ? 'border-red-500 focus-visible:ring-red-500' : ''}" maxlength={5} - onblur={() => validateField('unit_of_measure')} + onblur={handleUnitOfMeasureBlur} /> - - {formData.unit_of_measure_description || ''} -
- {#if validationErrors.unit_of_measure} -

{validationErrors.unit_of_measure}

- {/if} +
+ {#if validationErrors.unit_of_measure} +

{validationErrors.unit_of_measure}

+ {:else if formData.unit_of_measure_description} + + {formData.unit_of_measure_description} + + {/if} +
@@ -656,15 +803,21 @@ ? 'border-red-500 focus-visible:ring-red-500' : ''}" maxlength={10} - onblur={() => validateField('fraction')} + onblur={handleFractionBlur} />
- {#if validationErrors.fraction} -

{validationErrors.fraction}

- {/if} +
+ {#if validationErrors.fraction} +

{validationErrors.fraction}

+ {:else if formData.fraction_description} + + {formData.fraction_description} + + {/if} +
@@ -677,13 +830,21 @@ id="us_fraction" bind:value={formData.us_fraction} placeholder="Fracción americana" - class="flex-1" - maxlength={16} + class="flex-1 uppercase" + maxlength={10} + onblur={handleUSFractionBlur} /> +
+ {#if formData.us_fraction_description} + + {formData.us_fraction_description} + + {/if} +
diff --git a/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte b/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte index 8f4bfe0d..06faf211 100644 --- a/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte +++ b/frontend/src/lib/components/dashboard/pedimentos/data-table.svelte @@ -1,14 +1,11 @@
-
+
- + {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} {#each headerGroup.headers as header (header.id)} @@ -103,22 +104,21 @@ {#each table.getRowModel().rows as row (row.id)} - { if (onRowClick) { onRowClick(row.original); } }} ondblclick={() => handleRowDoubleClick(row)} - class="cursor-pointer hover:bg-muted/50 transition-colors {row.getIsSelected() ? 'bg-primary/10' : ''}" + class="cursor-pointer transition-colors hover:bg-muted/50 {row.getIsSelected() + ? 'bg-primary/10' + : ''}" > {#each row.getVisibleCells() as cell (cell.id)} - + {/each} @@ -129,7 +129,7 @@ {/each} - + {#if hasMore} @@ -137,13 +137,13 @@
{#if loading}
-
- Cargando más... +
+ Cargando más...
{:else} -
- Desplázate para cargar más -
+
Desplázate para cargar más
{/if}
diff --git a/frontend/src/lib/components/help/HelpDrawer.svelte b/frontend/src/lib/components/help/HelpDrawer.svelte index 2fb4719f..729508f8 100644 --- a/frontend/src/lib/components/help/HelpDrawer.svelte +++ b/frontend/src/lib/components/help/HelpDrawer.svelte @@ -123,13 +123,16 @@ - - + + {#snippet child({ props })} + + {/snippet} diff --git a/frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte b/frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte index b59e08d1..ab016a41 100644 --- a/frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte +++ b/frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte @@ -49,8 +49,9 @@ role="dialog" aria-modal="true" > +
Global Navigation (Alt) +
No specific actions for this view.

{:else} +
{#each items as item (item.title)} {#if item.items && item.items.length > 0} + {#if sidebar.state === 'collapsed'} {#if sidebar.state === 'collapsed'} @@ -88,14 +103,21 @@ open={activeTitle === item.title} onOpenChange={(v) => onOpenChange(v, item.title)} > + {#snippet child({ props })}
handleTriggerEnter(item.title)} onpointerleave={(e) => handleTriggerLeave(e, item.title)} > + handleContentEnter(item.title)} onpointerleave={(e) => handleContentLeave(e, item.title)} @@ -126,6 +149,9 @@ Posicionado con right-full para estar exactamente donde el trigger termina (offset 0). Usamos w-8 h-8 para coincidir con un botón de tamaño estándar de sidebar. --> +
@@ -139,29 +165,36 @@ +
+
{item.title}
+ {#each item.items as subItem (subItem.title)} - + {#snippet child({ props })} {subItem.title} {/snippet} - + {/each}
diff --git a/frontend/src/lib/components/ui/dialog/index.ts b/frontend/src/lib/components/ui/dialog/index.ts index dce1d9dc..818aae6d 100644 --- a/frontend/src/lib/components/ui/dialog/index.ts +++ b/frontend/src/lib/components/ui/dialog/index.ts @@ -9,8 +9,8 @@ import Description from "./dialog-description.svelte"; import Trigger from "./dialog-trigger.svelte"; import Close from "./dialog-close.svelte"; -const Root = DialogPrimitive.Root; -const Portal = DialogPrimitive.Portal; +const Root = Dialog?.Root ?? (class { } as any); +const Portal = Dialog?.Portal ?? (class { } as any); export { Root, diff --git a/frontend/src/routes/dashboard/pedimentos/+page.svelte b/frontend/src/routes/dashboard/pedimentos/+page.svelte index cb07499a..27b6b83f 100644 --- a/frontend/src/routes/dashboard/pedimentos/+page.svelte +++ b/frontend/src/routes/dashboard/pedimentos/+page.svelte @@ -83,8 +83,8 @@ let error = $state(data.error || null); // Estado para selección de filas - let selectedId = $state(null); - let hasSelection = $derived(selectedId !== null); + let selectedIds = $state([]); + let hasSelection = $derived(selectedIds.length > 0); let showDeleteDialog = $state(false); let isWinsaiiConfirmOpen = $state(false); let isWinsaiiByClass = $state(false); @@ -99,24 +99,28 @@ function handleRowClick(pedimento: Pedimento) { // Toggle: si ya está seleccionado, deseleccionar; si no, seleccionar - selectedId = selectedId === pedimento.id ? null : pedimento.id; + if (selectedIds.includes(pedimento.id)) { + selectedIds = selectedIds.filter((id) => id !== pedimento.id); + } else { + selectedIds = [...selectedIds, pedimento.id]; + } } function handleEditSelected() { - if (selectedId) { - window.location.href = `/dashboard/pedimentos/edit/${selectedId}`; + if (selectedIds.length === 1) { + goto(`/dashboard/pedimentos/edit/${selectedIds[0]}`); } } function handleDelete() { - if (!selectedId) { + if (selectedIds.length === 0) { return; } showDeleteDialog = true; } async function confirmDelete() { - if (!selectedId) return; + if (selectedIds.length === 0) return; const companyId = companyStore.activeCompany?.id; if (!companyId) { @@ -125,22 +129,26 @@ } try { - const response = await pedimentosApi.delete(selectedId, companyId); + // Eliminar pedimentos uno por uno (o podrías implementar un delete masivo en el API si existe) + // Basado en pedimentosApi.delete(id, companyId), lo haremos secuencialmente o en paralelo + const deletePromises = selectedIds.map((id) => pedimentosApi.delete(id, companyId)); + const results = await Promise.all(deletePromises); - if (response.error) { - console.error('🗑️ [Pedimentos] Error al eliminar:', response.error); - error = response.error; + const firstError = results.find((r) => r.error); + if (firstError) { + console.error('🗑️ [Pedimentos] Error al eliminar:', firstError.error); + error = firstError.error ?? null; return; } // Recargar datos await reloadData(); - selectedId = null; + selectedIds = []; showDeleteDialog = false; } catch (e) { console.error('🗑️ [Pedimentos] Error deleting:', e); - error = 'Error al eliminar el pedimento'; + error = 'Error al eliminar los pedimentos seleccionados'; } } @@ -263,7 +271,7 @@ window.location.reload(); }, 2000); } else { - error = response.error; + error = response.error ?? null; } return; } @@ -312,7 +320,7 @@ window.location.reload(); }, 2000); } else { - error = response.error; + error = response.error ?? null; } return; } @@ -370,7 +378,7 @@ window.location.reload(); }, 2000); } else { - error = response.error; + error = response.error ?? null; } return; } @@ -391,7 +399,7 @@ function handleCreateClick() { // Redirigir a la página de creación (reusa la página de edición con ID "new") - window.location.href = '/dashboard/pedimentos/edit/new'; + goto('/dashboard/pedimentos/edit/new'); } function handleSuccess() { @@ -519,7 +527,7 @@ {loading} {hasMore} {loadMore} - {selectedId} + {selectedIds} onRowClick={handleRowClick} /> @@ -532,17 +540,21 @@
- -