Merge pull request 'fix/modulo-pedimento' (#186) from fix/modulo-pedimento into development
Reviewed-on: ADUANASOFT/anexo76#186
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -226,7 +227,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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -39,13 +39,15 @@
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="ghost" size="icon" class="relative h-8 w-8 p-0">
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="relative h-8 w-8 p-0"
|
||||
>
|
||||
<span class="sr-only">Abrir menú</span>
|
||||
<EllipsisIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Group>
|
||||
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<!-- Clase y Requiere Revisión Física -->
|
||||
<div class="flex items-end gap-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="flex-1 space-y-2">
|
||||
<Label for="class_code" class="font-bold">
|
||||
Clase: <span class="text-red-500">*</span>
|
||||
@@ -569,18 +710,21 @@
|
||||
? 'border-red-500 focus-visible:ring-red-500'
|
||||
: ''}"
|
||||
maxlength={10}
|
||||
onblur={() => validateField('material_key')}
|
||||
onblur={handleMaterialKeyBlur}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="icon" onclick={openMaterialSearch}>
|
||||
<Folder class="h-4 w-4" />
|
||||
</Button>
|
||||
<span class="cursor-pointer text-sm text-blue-600 hover:underline">
|
||||
{formData.material_description || ''}
|
||||
</span>
|
||||
</div>
|
||||
{#if validationErrors.material_key}
|
||||
<p class="mt-1 text-sm text-red-500">{validationErrors.material_key}</p>
|
||||
{/if}
|
||||
<div class="min-h-[20px]">
|
||||
{#if validationErrors.material_key}
|
||||
<p class="text-sm text-red-500">{validationErrors.material_key}</p>
|
||||
{:else if formData.material_description}
|
||||
<span class="block truncate text-sm text-blue-600">
|
||||
{formData.material_description}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -629,18 +773,21 @@
|
||||
? 'border-red-500 focus-visible:ring-red-500'
|
||||
: ''}"
|
||||
maxlength={5}
|
||||
onblur={() => validateField('unit_of_measure')}
|
||||
onblur={handleUnitOfMeasureBlur}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="icon" onclick={openUnitOfMeasureSearch}>
|
||||
<Folder class="h-4 w-4" />
|
||||
</Button>
|
||||
<span class="cursor-pointer text-sm text-blue-600 hover:underline">
|
||||
{formData.unit_of_measure_description || ''}
|
||||
</span>
|
||||
</div>
|
||||
{#if validationErrors.unit_of_measure}
|
||||
<p class="mt-1 text-sm text-red-500">{validationErrors.unit_of_measure}</p>
|
||||
{/if}
|
||||
<div class="min-h-[20px]">
|
||||
{#if validationErrors.unit_of_measure}
|
||||
<p class="text-sm text-red-500">{validationErrors.unit_of_measure}</p>
|
||||
{:else if formData.unit_of_measure_description}
|
||||
<span class="block truncate text-sm text-blue-600">
|
||||
{formData.unit_of_measure_description}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
@@ -656,15 +803,21 @@
|
||||
? 'border-red-500 focus-visible:ring-red-500'
|
||||
: ''}"
|
||||
maxlength={10}
|
||||
onblur={() => validateField('fraction')}
|
||||
onblur={handleFractionBlur}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="icon" onclick={openFractionSearch}>
|
||||
<Folder class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{#if validationErrors.fraction}
|
||||
<p class="mt-1 text-sm text-red-500">{validationErrors.fraction}</p>
|
||||
{/if}
|
||||
<div class="min-h-[20px]">
|
||||
{#if validationErrors.fraction}
|
||||
<p class="text-sm text-red-500">{validationErrors.fraction}</p>
|
||||
{:else if formData.fraction_description}
|
||||
<span class="block truncate text-sm text-blue-600">
|
||||
{formData.fraction_description}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
<Button type="button" variant="outline" size="icon" onclick={openUSFractionSearch}>
|
||||
<Folder class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="min-h-[20px]">
|
||||
{#if formData.us_fraction_description}
|
||||
<span class="block truncate text-sm text-blue-600">
|
||||
{formData.us_fraction_description}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -123,13 +123,16 @@
|
||||
</script>
|
||||
|
||||
<Sheet.Root bind:open={helpStore.isOpen}>
|
||||
<Sheet.Trigger asChild>
|
||||
<button
|
||||
class="fixed right-6 bottom-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95"
|
||||
aria-label="Ayuda"
|
||||
>
|
||||
<HelpCircle size={28} />
|
||||
</button>
|
||||
<Sheet.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<button
|
||||
{...props}
|
||||
class="fixed right-6 bottom-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-transform hover:scale-110 active:scale-95"
|
||||
aria-label="Ayuda"
|
||||
>
|
||||
<HelpCircle size={28} />
|
||||
</button>
|
||||
{/snippet}
|
||||
</Sheet.Trigger>
|
||||
<Sheet.Content side="right" class="w-[400px] sm:w-[540px]">
|
||||
<Sheet.Header>
|
||||
|
||||
@@ -49,8 +49,9 @@
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="w-full max-w-2xl rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-900 text-gray-900 dark:text-gray-100 max-h-[80vh] overflow-y-auto"
|
||||
class="max-h-[80vh] w-full max-w-2xl overflow-y-auto rounded-xl bg-white p-6 text-gray-900 shadow-2xl dark:bg-gray-900 dark:text-gray-100"
|
||||
role="document"
|
||||
bind:this={modalRef}
|
||||
role="presentation"
|
||||
@@ -88,9 +89,10 @@
|
||||
>
|
||||
Global Navigation (Alt)
|
||||
</h3>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="space-y-2 max-h-64 overflow-y-auto pr-1"
|
||||
class="max-h-64 space-y-2 overflow-y-auto pr-1"
|
||||
role="region"
|
||||
aria-label="Global Navigation shortcuts"
|
||||
bind:this={globalList}
|
||||
@@ -129,9 +131,10 @@
|
||||
{#if localShortcuts.length === 0}
|
||||
<p class="text-sm text-gray-400 italic">No specific actions for this view.</p>
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<div
|
||||
class="space-y-2 max-h-64 overflow-y-auto pr-1"
|
||||
class="max-h-64 space-y-2 overflow-y-auto pr-1"
|
||||
role="region"
|
||||
aria-label="Active Actions shortcuts"
|
||||
bind:this={localList}
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
|
||||
let {
|
||||
items
|
||||
items
|
||||
}: {
|
||||
items: {
|
||||
title: string;
|
||||
@@ -37,6 +43,7 @@
|
||||
}
|
||||
|
||||
function handleTriggerEnter(title: string) {
|
||||
if (sidebar.state !== 'collapsed') return;
|
||||
if (sidebar.state !== 'collapsed') return;
|
||||
activeTitle = title;
|
||||
}
|
||||
@@ -44,13 +51,17 @@
|
||||
function handleTriggerLeave(event: PointerEvent, title: string) {
|
||||
if (sidebar.state !== 'collapsed') return;
|
||||
|
||||
if (sidebar.state !== 'collapsed') return;
|
||||
|
||||
// Si nos movemos al contenido (o nos quedamos en el trigger), no cerramos
|
||||
if (shouldKeepOpen(event, title)) return;
|
||||
|
||||
|
||||
activeTitle = null;
|
||||
}
|
||||
|
||||
function handleContentEnter(title: string) {
|
||||
if (sidebar.state !== 'collapsed') return;
|
||||
if (sidebar.state !== 'collapsed') return;
|
||||
activeTitle = title;
|
||||
}
|
||||
@@ -58,9 +69,12 @@
|
||||
function handleContentLeave(event: PointerEvent, title: string) {
|
||||
if (sidebar.state !== 'collapsed') return;
|
||||
|
||||
if (sidebar.state !== 'collapsed') return;
|
||||
|
||||
// Si nos movemos de vuelta al trigger (o dentro del contenido), no cerramos
|
||||
if (shouldKeepOpen(event, title)) return;
|
||||
|
||||
|
||||
activeTitle = null;
|
||||
}
|
||||
|
||||
@@ -81,6 +95,7 @@
|
||||
<Sidebar.Menu>
|
||||
{#each items as item (item.title)}
|
||||
{#if item.items && item.items.length > 0}
|
||||
{#if sidebar.state === 'collapsed'}
|
||||
{#if sidebar.state === 'collapsed'}
|
||||
<!-- Sidebar Colapsado: Dropdown controlado por eventos estrictos -->
|
||||
<Sidebar.MenuItem>
|
||||
@@ -88,14 +103,21 @@
|
||||
open={activeTitle === item.title}
|
||||
onOpenChange={(v) => onOpenChange(v, item.title)}
|
||||
>
|
||||
<DropdownMenu.Trigger>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<div
|
||||
id={`trigger-${item.title}`}
|
||||
class="relative z-30 flex w-full justify-center"
|
||||
class="relative z-30 flex w-full justify-center"
|
||||
onpointerenter={() => handleTriggerEnter(item.title)}
|
||||
onpointerleave={(e) => handleTriggerLeave(e, item.title)}
|
||||
>
|
||||
<Sidebar.MenuButton
|
||||
{...props}
|
||||
tooltipContent={undefined}
|
||||
class="justify-center"
|
||||
>
|
||||
<Sidebar.MenuButton
|
||||
{...props}
|
||||
tooltipContent={undefined}
|
||||
@@ -117,6 +139,7 @@
|
||||
align="start"
|
||||
sideOffset={0}
|
||||
class="z-50 w-64 overflow-visible rounded-lg p-0 shadow-lg"
|
||||
class="z-50 w-64 overflow-visible rounded-lg p-0 shadow-lg"
|
||||
id={`content-${item.title}`}
|
||||
onpointerenter={() => 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.
|
||||
-->
|
||||
<div
|
||||
class="absolute top-0 right-full z-50 flex h-8 w-8 items-center justify-center rounded-l-lg border border-r-0 border-sidebar-border bg-sidebar-accent text-sidebar-accent-foreground shadow-none"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 right-full z-50 flex h-8 w-8 items-center justify-center rounded-l-lg border border-r-0 border-sidebar-border bg-sidebar-accent text-sidebar-accent-foreground shadow-none"
|
||||
>
|
||||
@@ -139,29 +165,36 @@
|
||||
<!--
|
||||
Panel Principal
|
||||
-->
|
||||
<div
|
||||
class="pointer-events-auto ml-[0px] h-full w-full rounded-lg rounded-tl-none border border-sidebar-border bg-popover p-1"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-auto ml-[0px] h-full w-full rounded-lg rounded-tl-none border border-sidebar-border bg-popover p-1"
|
||||
>
|
||||
<!-- Título en el panel principal -->
|
||||
<div
|
||||
class="truncate border-b px-2 py-2 text-sm font-medium text-sidebar-foreground"
|
||||
>
|
||||
<div
|
||||
class="truncate border-b px-2 py-2 text-sm font-medium text-sidebar-foreground"
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
|
||||
|
||||
<DropdownMenu.DropdownMenuGroup class="mt-1 max-h-80 overflow-y-auto">
|
||||
{#each item.items as subItem (subItem.title)}
|
||||
<DropdownMenu.Item>
|
||||
<DropdownMenu.DropdownMenuItem>
|
||||
{#snippet child({ props })}
|
||||
<a
|
||||
{...props}
|
||||
href={subItem.url}
|
||||
{...props}
|
||||
class="flex w-full items-center gap-2 overflow-hidden"
|
||||
>
|
||||
<span class="truncate">{subItem.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.DropdownMenuItem>
|
||||
{/each}
|
||||
</DropdownMenu.DropdownMenuGroup>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -83,8 +83,8 @@
|
||||
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);
|
||||
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}
|
||||
/>
|
||||
</Card.Content>
|
||||
@@ -532,17 +540,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>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -562,9 +574,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">
|
||||
|
||||
@@ -528,9 +528,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 || ''
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -970,16 +970,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(
|
||||
@@ -987,17 +988,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1006,7 +999,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...');
|
||||
@@ -1051,9 +1050,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)';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user