feat: Enhance pedimento save flow with detailed error handling, unique constraint checks for soft-deleted items, and multi-row selection in data table.

This commit is contained in:
2026-03-04 11:40:48 -07:00
parent 311d1c7259
commit 5c677b9987
6 changed files with 130 additions and 85 deletions

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
export interface ApiResponse<T = any> {
data?: T;
error?: string;
details?: any;
validationErrors?: Array<{
field: string;
message: string;
@@ -271,7 +272,8 @@ async function fetchApi<T = any>(
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
};
}

View File

@@ -1,14 +1,11 @@
<script lang="ts" generics="TData, TValue">
import { onMount } from 'svelte';
import {
type ColumnDef,
getCoreRowModel,
type RowSelectionState
} from "@tanstack/table-core";
import { createSvelteTable, FlexRender } from "$lib/components/ui/data-table/index.js";
import * as Table from "$lib/components/ui/table/index.js";
import { Button } from "$lib/components/ui/button";
import { Edit } from "lucide-svelte";
import { type ColumnDef, getCoreRowModel, type RowSelectionState } from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
import * as Table from '$lib/components/ui/table/index.js';
import { Button } from '$lib/components/ui/button';
import { Edit } from 'lucide-svelte';
import { goto } from '$app/navigation';
type DataTableProps<TData, TValue> = {
columns: ColumnDef<TData, TValue>[];
@@ -16,7 +13,7 @@
loading: boolean;
hasMore: boolean;
loadMore: () => void;
selectedId?: number | null;
selectedIds?: number[];
onRowClick?: (row: TData) => void;
};
@@ -26,7 +23,7 @@
loading,
hasMore,
loadMore,
selectedId = null,
selectedIds = [],
onRowClick
}: DataTableProps<TData, TValue> = $props();
@@ -39,11 +36,15 @@
getRowId: (row: any) => row.id?.toString(),
state: {
get rowSelection() {
return selectedId ? { [selectedId]: true } : {};
const selection: RowSelectionState = {};
selectedIds.forEach((id) => {
selection[id.toString()] = true;
});
return selection;
}
},
enableRowSelection: true,
enableMultiRowSelection: false
enableMultiRowSelection: true
});
let scrollContainer = $state<HTMLDivElement>();
@@ -53,7 +54,7 @@
function handleRowDoubleClick(row: any) {
const pedimento = row.original;
if (pedimento?.id) {
window.location.href = `/dashboard/pedimentos/edit/${pedimento.id}`;
goto(`/dashboard/pedimentos/edit/${pedimento.id}`);
}
}
@@ -83,9 +84,9 @@
</script>
<div class="w-full">
<div class="rounded-md border max-h-[600px] overflow-auto" bind:this={scrollContainer}>
<div class="max-h-[600px] overflow-auto rounded-md border" bind:this={scrollContainer}>
<Table.Root class="w-full">
<Table.Header class="bg-background sticky top-0 z-10">
<Table.Header class="sticky top-0 z-10 bg-background">
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
@@ -103,22 +104,21 @@
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row
data-state={row.getIsSelected() && "selected"}
<Table.Row
data-state={row.getIsSelected() && 'selected'}
onclick={() => {
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)}
<Table.Cell class="whitespace-nowrap {cell.column.columnDef.meta?.className || ''}">
<FlexRender
content={cell.column.columnDef.cell}
context={cell.getContext()}
/>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</Table.Cell>
{/each}
</Table.Row>
@@ -129,7 +129,7 @@
</Table.Cell>
</Table.Row>
{/each}
<!-- Loading Trigger - Se activa cuando es visible -->
{#if hasMore}
<Table.Row>
@@ -137,13 +137,13 @@
<div bind:this={loadingTrigger}>
{#if loading}
<div class="flex items-center justify-center gap-2">
<div class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
<span class="text-muted-foreground text-sm">Cargando más...</span>
<div
class="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"
></div>
<span class="text-sm text-muted-foreground">Cargando más...</span>
</div>
{:else}
<div class="text-muted-foreground text-sm">
Desplázate para cargar más
</div>
<div class="text-sm text-muted-foreground">Desplázate para cargar más</div>
{/if}
</div>
</Table.Cell>

View File

@@ -79,30 +79,34 @@
let error = $state<string | null>(data.error || null);
// Estado para selección de filas
let selectedId = $state<number | null>(null);
let hasSelection = $derived(selectedId !== null);
let selectedIds = $state<number[]>([]);
let hasSelection = $derived(selectedIds.length > 0);
let showDeleteDialog = $state(false);
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) {
@@ -111,22 +115,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';
}
}
@@ -174,7 +182,7 @@
window.location.reload();
}, 2000);
} else {
error = response.error;
error = response.error ?? null;
}
return;
}
@@ -223,7 +231,7 @@
window.location.reload();
}, 2000);
} else {
error = response.error;
error = response.error ?? null;
}
return;
}
@@ -281,7 +289,7 @@
window.location.reload();
}, 2000);
} else {
error = response.error;
error = response.error ?? null;
}
return;
}
@@ -302,7 +310,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() {
@@ -430,7 +438,7 @@
{loading}
{hasMore}
{loadMore}
{selectedId}
{selectedIds}
onRowClick={handleRowClick}
/>
</Card.Content>
@@ -443,17 +451,21 @@
<div class="mx-auto max-w-[1400px] px-4 py-4">
<!-- Botones de acción -->
<div class="flex justify-end gap-2">
<Button size="sm" onclick={handleCreateClick}>
<Plus class="mr-1 h-4 w-4" />
Insertar
</Button>
<Button variant="outline" size="sm" onclick={handleEditSelected} disabled={!hasSelection}>
<Button
variant="outline"
size="sm"
onclick={handleEditSelected}
disabled={selectedIds.length !== 1}
>
<Edit size={16} class="mr-1" />
Editar
</Button>
<Button variant="outline" size="sm" onclick={handleDelete} disabled={!hasSelection}>
<Trash2 size={16} class="mr-1" />
Borrar
{#if selectedIds.length > 1}
({selectedIds.length})
{/if}
</Button>
</div>
</div>
@@ -464,9 +476,19 @@
<Dialog.Root bind:open={showDeleteDialog}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>¿Eliminar pedimento?</Dialog.Title>
<Dialog.Title>
{#if selectedIds.length > 1}
¿Eliminar {selectedIds.length} pedimentos?
{:else}
¿Eliminar pedimento?
{/if}
</Dialog.Title>
<Dialog.Description>
Esta acción no se puede deshacer. El pedimento será eliminado permanentemente.
Esta acción no se puede deshacer. {#if selectedIds.length > 1}
Los pedimentos seleccionados serán eliminados
{:else}
El pedimento será eliminado
{/if} permanentemente.
</Dialog.Description>
</Dialog.Header>
<div class="mt-4 flex justify-end gap-3">

View File

@@ -503,9 +503,9 @@
if (hasTransportValue) {
payload.pedimento_transport_means = {
destination: generalFormData.pedimento_transport_means.destination || null,
entry_exit: generalFormData.pedimento_transport_means.entry_exit || null,
arrival: generalFormData.pedimento_transport_means.arrival || null,
departure: generalFormData.pedimento_transport_means.departure || null
entry_exit: generalFormData.pedimento_transport_means.entry_exit || '',
arrival: generalFormData.pedimento_transport_means.arrival || '',
departure: generalFormData.pedimento_transport_means.departure || ''
};
}
}
@@ -945,16 +945,17 @@
companyStore.activeCompany?.id
);
if (response.error) {
// Si hay un error detallado en la respuesta JSON, adjuntarlo
const errorData =
response.details || (typeof response.error === 'object' ? response.error : null);
console.error('Create error details:', errorData);
const errorMsg =
typeof response.error === 'string' ? response.error : 'Error al crear el pedimento';
throw new Error(errorMsg);
}
if (!response.data?.id) throw new Error('No se recibió el ID del pedimento creado');
newPedimentoId = response.data.id;
// Redirigir a la página de edición
await goto(`/dashboard/pedimentos/edit/${newPedimentoId}`);
return;
} else {
// Actualizar pedimento existente con todos sus sub-recursos
const response = await pedimentosApi.update(
@@ -962,17 +963,9 @@
cleanPayload as UpdatePedimentoData,
companyStore.activeCompany?.id
);
if (response.error) throw new Error(response.error);
// Recargar los datos del pedimento desde el servidor
try {
await invalidateAll();
// Forzar recarga de datos esperando un tick
await new Promise((resolve) => setTimeout(resolve, 100));
} catch (invalidateError) {
console.error('❌ Error en invalidateAll:', invalidateError);
// No lanzar el error, solo loguearlo
// El pedimento ya se guardó exitosamente en el backend
if (response.error) {
console.error('Update error details:', response.details || response.error);
throw new Error(response.error);
}
}
@@ -981,7 +974,13 @@
? 'Pedimento creado exitosamente'
: 'Todos los cambios se guardaron correctamente'
);
// Pequeña espera para que el usuario pueda ver el toast antes de redirigir
setTimeout(() => {
goto('/dashboard/pedimentos');
}, 500);
} catch (e) {
console.error('Detailed error saving all:', e);
if (e instanceof Error) {
if (e.message.includes('401')) {
toast.error('Sesión expirada. Recargando página...');
@@ -1026,9 +1025,23 @@
// Si parece JSON, intentar formatearlo un poco o mostrar mensaje genérico
try {
const errObj = JSON.parse(errorStr);
// Si es del formato {"field": ["msg"]}
const values = Object.values(errObj).flat();
displayError = values.join(', ');
// Priorizar el campo 'message' o 'detail' que suelen enviar FastAPI/mis manejadores
if (errObj.message) {
displayError = errObj.message;
} else if (errObj.detail) {
if (Array.isArray(errObj.detail)) {
displayError = errObj.detail
.map((d: any) => d.msg || JSON.stringify(d))
.join(', ');
} else {
displayError = String(errObj.detail);
}
} else {
// Si es del formato {"field": ["msg"]}
const values = Object.values(errObj).flat();
displayError = values.join(', ');
}
} catch {
displayError = 'Error al guardar (ver consola)';
}