@@ -0,0 +1,914 @@
< script lang = "ts" >
import { Button } from '$lib/components/ui/button' ;
import { Input } from '$lib/components/ui/input' ;
import { Label } from '$lib/components/ui/label' ;
import * as Dialog from '$lib/components/ui/dialog' ;
import FixedAssetClassForm from '$lib/components/dashboard/classes/forms/FixedAssetClassForm.svelte' ;
import { Folder , Save , Plus , RefreshCw } from 'lucide-svelte' ;
import { toast } from 'svelte-sonner' ;
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' ;
import { getToken } from '$lib/auth' ;
import { onMount } from 'svelte' ;
// Tipo extendido que combina A76Class y FAClass
interface FixedAssetClassExtended extends A76Class {
fa_class_id? : number ;
depreciation_rate? : number | null ;
fda_code? : string | null ;
class_enabled? : boolean | null ;
}
// Estado de la lista de clases
let classes = $state < FixedAssetClassExtended [] >([]);
let selectedClass = $state < FixedAssetClassExtended | null >( null );
let isLoading = $state ( false );
let searchTerm = $state ( '' );
let searchDescription = $state ( '' );
let searchType = $state ( '' );
let searchFraction = $state ( '' );
let showInsertDialog = $state ( false );
let showDeleteDialog = $state ( false );
let validationError = $state < string >( '' );
let isSaving = $state ( false );
// Estado del formulario
let formData = $state ({
class_code : '' ,
description_es : '' ,
description_en : '' ,
material_key : '' ,
unit_of_measure : '' ,
fraction : '' ,
us_fraction : '' ,
unit_measure_trade : '' ,
bom : ''
});
// Clases filtradas según búsqueda
const filteredClasses = $derived (
classes . filter (( c ) => {
// Filtro por código de clase
const matchesCode = ! searchTerm ||
c . class_code . toLowerCase (). includes ( searchTerm . toLowerCase ());
// Filtro por descripción (español o inglés)
const matchesDescription = ! searchDescription ||
( c . description_es ? . toLowerCase (). includes ( searchDescription . toLowerCase ()) ?? false ) ||
( c . description_en ? . toLowerCase (). includes ( searchDescription . toLowerCase ()) ?? false );
// Filtro por tipo de material
const matchesType = ! searchType ||
( c . material_key ? . toLowerCase (). includes ( searchType . toLowerCase ()) ?? false );
// Filtro por fracción arancelaria
const matchesFraction = ! searchFraction ||
( c . fraction ? . toLowerCase (). includes ( searchFraction . toLowerCase ()) ?? false );
return matchesCode && matchesDescription && matchesType && matchesFraction ;
})
);
// Reactively load classes when company changes
$effect (() => {
const companyId = companyStore . activeCompany ? . id ;
if ( companyId ) {
loadClasses ();
}
});
async function loadClasses() {
const companyId = companyStore . activeCompany ? . id ;
if ( ! companyId ) {
console . log ( 'No company selected, skipping load' );
return ;
}
isLoading = true ;
try {
console . log ( 'Cargando clases para company:' , companyId );
const response = await classesApi . list ({
company_id : companyId ,
page : 1 ,
page_size : 1000
});
if ( ! response . data ) return ;
// Para cada clase base, intentar cargar sus datos de activo fijo
const classesWithFA = await Promise . all (
response . data . items . map ( async ( baseClass ) => {
try {
const faResponse = await faClassesApi . list ({
company_id : companyId ,
class_id : baseClass.id ,
page : 1 ,
page_size : 1
});
const faData = faResponse . data ? . items [ 0 ];
return {
... baseClass ,
fa_class_id : faData?.id ,
depreciation_rate : faData?.depreciation_rate ,
fda_code : faData?.fda_code ,
class_enabled : faData?.class_enabled
} as FixedAssetClassExtended ;
} catch ( error ) {
// Si no tiene FA class, solo retornar la clase base
return baseClass as FixedAssetClassExtended ;
}
})
);
classes = classesWithFA ;
console . log ( 'Clases cargadas:' , classes . length );
} catch ( error ) {
console . error ( 'Error cargando clases:' , error );
toast . error ( 'Error al cargar las clases de activo fijo' );
} finally {
isLoading = false ;
}
}
function selectClass ( cls : A76Class ) {
selectedClass = cls ;
formData = {
class_code : cls.class_code ,
description_es : cls.description_es || '' ,
description_en : cls.description_en || '' ,
material_key : cls.material_key || '' ,
unit_of_measure : cls.unit_of_measure || '' ,
fraction : cls.fraction || '' ,
us_fraction : cls.us_fraction || '' ,
unit_measure_trade : '' ,
bom : ''
};
}
async function saveFixedAssetClass ( formData : any ) {
console . log ( '=== INICIO saveFixedAssetClass ===' );
const companyId = companyStore . activeCompany ? . id ;
const clientId = 2 ; // Cliente demo creado en la base de datos
// CAMBIO: Usar $state.snapshot para obtener una copia real, no reactiva
const data = $state . snapshot ( formData );
console . log ( 'saveFixedAssetClass called with data:' , data );
if ( ! companyId ) {
toast . error ( 'No hay empresa seleccionada' );
throw new Error ( 'No hay empresa seleccionada' );
}
// Validar campos obligatorios
const missingFields : string [] = [];
if ( ! data . class_code ? . trim ()) {
missingFields . push ( 'Código de clase' );
}
if ( ! data . description_es ? . trim ()) {
missingFields . push ( 'Descripción en español' );
}
if ( ! data . material_key ? . trim ()) {
missingFields . push ( 'Tipo de activo fijo' );
}
if ( ! data . unit_of_measure ? . trim ()) {
missingFields . push ( 'Unidad de medida comercial' );
}
if ( ! data . fraction ? . trim ()) {
missingFields . push ( 'Fracción arancelaria' );
}
if ( missingFields . length > 0 ) {
const fieldsList = missingFields . join ( ', ' );
validationError = `Debe completar los siguientes campos obligatorios: ${ fieldsList } ` ;
toast . error ( validationError , {
duration : 8000
});
throw new Error ( `Campos obligatorios faltantes: ${ fieldsList } ` );
}
// Limpiar error de validación si todo está bien
validationError = '' ;
try {
// Usar el endpoint combinado /fa que crea ambos registros en una transacción
const token = await getToken ();
if ( ! token ) {
toast . error ( 'No estás autenticado' );
throw new Error ( 'No estás autenticado' );
}
const payload = {
client_id : clientId ,
class_code : data.class_code.trim (),
description_es : data.description_es.trim (),
description_en : data.description_en?.trim () || '' ,
material_key : data.material_key.trim (),
unit_of_measure : data.unit_of_measure.trim (),
fraction : data.fraction.trim (),
us_fraction : data.us_fraction?.trim () || '' ,
sub_key : data.sub_key || '' ,
physical_review : data.physical_review ? 1 : 0 ,
iva_exempt_fraction : data.iva_exempt_fraction || '' ,
// FA-specific fields
import_tariff_code : data.import_tariff_code || null ,
import_tariff_type : data.import_tariff_type || null ,
export_tariff_code : data.export_tariff_code || null ,
export_tariff_type : data.export_tariff_type || null ,
depreciation_rate : data.annual_depreciation_rate || null ,
fda_code : data.fda_key || null ,
eccn_code : data.eccn_code || null ,
class_enabled : true
};
console . log ( 'Sending payload:' , payload );
const response = await fetch ( `http://localhost:8000/api/v1/a76/classes/fa?company_id= ${ companyId } ` , {
method : 'POST' ,
headers : {
'Content-Type' : 'application/json' ,
'Authorization' : `Bearer ${ token } `
},
body : JSON.stringify ( payload )
});
if ( ! response . ok ) {
const error = await response . json ();
console . error ( 'Server error:' , error );
// Manejar diferentes formatos de error
let errorMessage = 'Error al crear la clase' ;
let isDuplicateError = false ;
if ( error . detail ) {
if ( Array . isArray ( error . detail )) {
// Si detail es un array (errores de validación de Pydantic)
errorMessage = error . detail . map (( e : any ) =>
` ${ e . loc ? e . loc . join ( ' → ' ) : '' } : ${ e . msg || e } `
). join ( ', ' );
} else if ( typeof error . detail === 'string' ) {
errorMessage = error . detail ;
// Detectar si es un error de código duplicado
if ( errorMessage . includes ( 'código' ) && errorMessage . includes ( 'ya está en uso' )) {
isDuplicateError = true ;
}
} else {
errorMessage = JSON . stringify ( error . detail );
}
}
// Mensaje más específico para errores de duplicado
console . log ( 'isDuplicateError:' , isDuplicateError );
if ( isDuplicateError ) {
validationError = `⚠️ ${ errorMessage } \ n \ nPor favor, cambie el código de clase a uno diferente.` ;
} else {
validationError = `⚠️ ${ errorMessage } ` ;
}
console . log ( 'MENSAJE ASIGNADO (save):' , validationError );
toast . error ( errorMessage , { duration : 8000 });
throw new Error ( errorMessage );
}
const responseData = await response . json ();
validationError = '' ;
toast . success ( '✅ Clase de activo fijo creada correctamente' );
return responseData ;
} catch ( error : any ) {
console . error ( 'Error saving fixed asset class:' , error );
// El toast ya se mostró arriba, solo re-lanzar el error
throw error ;
}
}
async function updateFixedAssetClass ( formData : any ) {
console . log ( '=== INICIO updateFixedAssetClass ===' );
const companyId = companyStore . activeCompany ? . id ;
// CAMBIO 1: Usar $state.snapshot para obtener una copia real, no reactiva
// Esto garantiza que aunque el hijo borre el formulario, 'data' mantenga los valores
const data = $state . snapshot ( formData );
console . log ( 'updateFixedAssetClass called with snapshot data:' , data );
if ( ! companyId || ! selectedClass ) {
toast . error ( 'No hay empresa o clase seleccionada' );
return ;
}
// CAMBIO 2: Validar sobre 'data' (la copia muerta)
const missingFields : string [] = [];
if ( ! data . class_code ? . trim ()) missingFields . push ( 'Código de clase' );
if ( ! data . description_es ? . trim ()) missingFields . push ( 'Descripción en español' );
if ( ! data . material_key ? . trim ()) missingFields . push ( 'Tipo de activo fijo' );
if ( ! data . unit_of_measure ? . trim ()) missingFields . push ( 'Unidad de medida comercial' );
if ( ! data . fraction ? . trim ()) missingFields . push ( 'Fracción arancelaria' );
if ( missingFields . length > 0 ) {
const errorMsg = `Campos obligatorios faltantes: ${ missingFields . join ( ', ' ) } ` ;
validationError = `⚠️ ${ errorMsg } ` ;
toast . error ( errorMsg );
// Lanzamos el error para que el 'onSave' del Dialog no cierre la ventana
throw new Error ( errorMsg );
}
validationError = '' ;
try {
// CAMBIO 3: Usar siempre 'data' para los payloads
const a76Response = await classesApi . update ( selectedClass . id , {
class_code : data.class_code.trim (),
description_es : data.description_es.trim (),
description_en : data.description_en?.trim () || '' ,
material_key : data.material_key.trim (),
unit_of_measure : data.unit_of_measure.trim (),
fraction : data.fraction.trim (),
us_fraction : data.us_fraction || '' ,
physical_review : data.physical_review ? 1 : 0 ,
iva_exempt_fraction : data.iva_exempt_fraction || ''
}, companyId );
if ( selectedClass . fa_class_id ) {
await faClassesApi . update ( selectedClass . fa_class_id , {
depreciation_rate : data.annual_depreciation_rate || null ,
fda_code : data.fda_key || null
}, companyId );
} else {
await faClassesApi . create ({
class_id : selectedClass.id ,
depreciation_rate : data.annual_depreciation_rate || null ,
fda_code : data.fda_key || null ,
class_enabled : true
}, companyId );
}
toast . success ( 'Clase actualizada correctamente' );
return { a76 : a76Response.data };
} catch ( error : any ) {
console . error ( 'Error updating fixed asset class:' , error );
console . error ( 'Error response:' , error ? . response );
console . error ( 'Error response data:' , error ? . response ? . data );
console . error ( 'Error response detail:' , error ? . response ? . data ? . detail );
console . error ( 'Error type:' , typeof error ? . response ? . data ? . detail );
let errorMessage = 'Error al actualizar la clase' ;
let isDuplicateError = false ;
// Extract error message from response
if ( error ? . response ? . data ? . detail ) {
if ( Array . isArray ( error . response . data . detail )) {
errorMessage = error . response . data . detail . map (( e : any ) =>
` ${ e . loc ? e . loc . join ( ' → ' ) : '' } : ${ e . msg || e } `
). join ( ', ' );
} else if ( typeof error . response . data . detail === 'string' ) {
errorMessage = error . response . data . detail ;
// Detectar si es un error de código duplicado
if ( errorMessage . includes ( 'código' ) && errorMessage . includes ( 'ya está en uso' )) {
isDuplicateError = true ;
}
} else {
errorMessage = JSON . stringify ( error . response . data . detail );
}
} else if ( error ? . message ) {
errorMessage = error . message ;
}
console . error ( 'Final error message:' , errorMessage );
console . error ( 'Is duplicate error:' , isDuplicateError );
// Mensaje más específico para errores de duplicado
console . log ( 'isDuplicateError:' , isDuplicateError );
if ( isDuplicateError ) {
validationError = `⚠️ ${ errorMessage } \ n \ nPor favor, cambie el código de clase a uno diferente.` ;
} else {
validationError = `⚠️ ${ errorMessage } ` ;
}
console . log ( 'MENSAJE ASIGNADO (update):' , validationError );
toast . error ( errorMessage , { duration : 8000 });
console . error ( 'Toast shown, about to throw error' );
throw error ;
}
}
function handleNew() {
selectedClass = null ;
formData = {
class_code : '' ,
description_es : '' ,
description_en : '' ,
material_key : '' ,
unit_of_measure : '' ,
fraction : '' ,
us_fraction : '' ,
unit_measure_trade : '' ,
bom : ''
};
}
async function handleRefresh() {
await loadClasses ();
toast . success ( 'Clases actualizadas' );
}
function handleDelete() {
if ( ! selectedClass ) {
toast . error ( 'Selecciona una clase para borrar' );
return ;
}
showDeleteDialog = true ;
}
async function confirmDelete() {
if ( ! selectedClass ) return ;
const companyId = companyStore . activeCompany ? . id ;
if ( ! companyId ) {
toast . error ( 'No hay empresa seleccionada' );
return ;
}
const classToDelete = selectedClass ;
try {
// El backend ahora elimina automáticamente la extensión FA si existe
await classesApi . delete ( classToDelete . id , companyId );
toast . success ( `Clase ${ classToDelete . class_code } eliminada correctamente` );
// Recargar lista
await loadClasses ();
selectedClass = null ;
showDeleteDialog = false ;
formData = {
class_code : '' ,
description_es : '' ,
description_en : '' ,
material_key : '' ,
unit_of_measure : '' ,
fraction : '' ,
us_fraction : '' ,
unit_measure_trade : '' ,
bom : ''
};
} catch ( error ) {
console . error ( 'Error deleting class:' , error );
toast . error ( 'Error al eliminar la clase' );
}
}
</ script >
< div class = "flex flex-col h-[calc(100vh-4rem)] p-4 gap-4 pb-15" >
<!-- Título -->
< div class = "flex flex-col gap-1" >
< h1 class = "text-2xl font-bold" > CATALOGO DE CLASES DE ACTIVO FIJO</ h1 >
< p class = "text-sm text-muted-foreground" >
Gestiona y consulta las clases de activo fijo
</ p >
</ div >
<!-- Contenedor principal con grid y detalles -->
< div class = "flex-1 flex gap-4 overflow-hidden" >
<!-- Panel izquierdo: Grid/Tabla de clases -->
< div class = "flex-1 flex flex-col gap-4 overflow-hidden" >
<!-- Sección de Filtros -->
< div class = "border rounded-lg bg-card" >
< div class = "p-4 space-y-4" >
< div class = "flex items-center justify-between" >
< h2 class = "text-sm font-semibold" > Filtros</ h2 >
< span class = "text-xs text-muted-foreground" >
Filtra las clases por diferentes criterios (los filtros se aplican automáticamente)
</ span >
</ div >
< div class = "grid grid-cols-4 gap-4" >
< div class = "space-y-2" >
< Label class = "text-xs" > Clase</ Label >
< Input
bind:value = { searchTerm }
placeholder="Ej: AF001 "
class = "h-9"
/>
</ div >
< div class = "space-y-2" >
< Label class = "text-xs" > Descripción</ Label >
< Input
bind:value = { searchDescription }
placeholder="Buscar descripción ..."
class = "h-9"
/>
</ div >
< div class = "space-y-2" >
< Label class = "text-xs" > Tipo</ Label >
< Input
bind:value = { searchType }
placeholder="MP, SC , DESP ..."
class = "h-9"
/>
</ div >
< div class = "space-y-2" >
< Label class = "text-xs" > Fracción</ Label >
< Input
bind:value = { searchFraction }
placeholder="Fracción arancelaria "
class = "h-9"
/>
</ div >
</ div >
</ div >
</ div >
<!-- Tabla de clases -->
< div class = "flex-1 flex flex-col border rounded-lg overflow-hidden" >
< div class = "flex items-center justify-between p-3 border-b bg-white dark:bg-muted/50" >
< h2 class = "text-sm font-semibold" > Listado de Clases</ h2 >
< div class = "flex items-center gap-2" >
< span class = "text-xs text-muted-foreground" >
Mostrando de { filteredClasses . length } registros
</ span >
< Button variant = "outline" size = "sm" onclick = { handleRefresh } >
< RefreshCw class = "h-4 w-4 mr-2" />
Actualizar
</ Button >
</ div >
</ div >
<!-- Tabla de clases -->
< div class = "flex-1 overflow-auto" >
< table class = "w-full text-sm" >
< thead class = "bg-white dark:bg-black text-gray-900 dark:text-white sticky top-0 z-10 border-b" >
< tr >
< th class = "px-2 py-2 text-left w-8" >
< input type = "checkbox" class = "h-4 w-4" />
</ th >
< th class = "px-2 py-2 text-left" > Clase</ th >
< th class = "px-2 py-2 text-left" > Descripción Español</ th >
< th class = "px-2 py-2 text-left" > Descripción Inglés</ th >
< th class = "px-2 py-2 text-left" > Tipo</ th >
< th class = "px-2 py-2 text-left" > U.M</ th >
< th class = "px-2 py-2 text-left" > Fracción</ th > < th class = "px-2 py-2 text-left" > U.M.T.</ th > < th class = "px-2 py-2 text-left" > Fracción US</ th >
</ tr >
</ thead >
< tbody >
{ #if isLoading }
< tr >
< td colspan = "10" class = "text-center py-8 text-muted-foreground" > Cargando...</ td >
</ tr >
{ :else if filteredClasses . length === 0 }
< tr >
< td colspan = "10" class = "text-center py-8 text-muted-foreground" >
No hay clases de activo fijo registradas
</ td >
</ tr >
{ : else }
{ #each filteredClasses as cls ( cls . id )}
< tr
class = "border-b cursor-pointer transition-colors { selectedClass ? . id ===
cls . id
? 'bg-gray-300 dark:bg-gray-600'
: 'hover:bg-gray-100 dark:hover:bg-gray-700' } "
onclick = {() => selectClass ( cls )}
>
< td class = "px-2 py-1" >
< input
type = "checkbox"
checked = { selectedClass ? . id === cls . id }
class="h-4 w-4 "
/>
</ td >
< td class = "px-2 py-1" >
< span class = "inline-flex items-center rounded-md bg-blue-50 dark:bg-blue-900/30 px-2 py-1 text-xs font-mono font-bold text-blue-700 dark:text-blue-400 border border-blue-200 dark:border-blue-800" >
{ cls . class_code }
</ span >
</ td >
< td class = "px-2 py-1 font-medium text-sm" > { cls . description_es || '' } </ td >
< td class = "px-2 py-1" > { cls . description_en || '' } </ td >
< td class = "px-2 py-1" >
< span class = "px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider { cls . material_key === 'MP' ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' : cls . material_key === 'SC' ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400' : cls . material_key === 'DESP' ? 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400' : 'bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-400' } " >
{ cls . material_key || '' }
</ span >
</ td >
< td class = "px-2 py-1 text-muted-foreground" > { cls . unit_of_measure || '' } </ td >
< td class = "px-2 py-1 font-mono text-xs text-orange-600 dark:text-orange-400" > { cls . fraction || '' } </ td > < td class = "px-2 py-1 text-xs text-muted-foreground" > -</ td > < td class = "px-2 py-1" > { cls . us_fraction || '-' } </ td >
</ tr >
{ /each }
{ /if }
</ tbody >
</ table >
</ div >
</ div >
</ div >
<!-- Panel derecho: Detalles y edición -->
< div class = "w-96 flex-none flex flex-col border rounded-xl bg-muted/30 shadow-sm overflow-hidden" >
< div class = "p-4 border-b" >
< p class = "text-[10px] uppercase tracking-widest opacity-80 text-muted-foreground" > Código de Clase</ p >
< h2 class = "text-3xl font-black font-mono tracking-tighter" >
{ formData . class_code || '---' }
</ h2 >
</ div >
< div class = "flex-1 overflow-auto p-5 space-y-6 bg-card" >
< div class = "space-y-3" >
< div >
< Label class = "text-[10px] uppercase text-muted-foreground font-bold" > Descripción ES</ Label >
< p class = "text-sm font-semibold leading-tight" > { formData . description_es || 'Sin descripción' } </ p >
</ div >
< div class = "pt-2 border-t border-dashed" >
< Label class = "text-[10px] uppercase text-muted-foreground font-bold" > Description EN</ Label >
< p class = "text-sm italic text-muted-foreground" > { formData . description_en || 'No translation available' } </ p >
</ div >
</ div >
< div class = "grid grid-cols-2 gap-4 pt-4 border-t" >
< div class = "space-y-1" >
< Label class = "text-[10px] uppercase text-muted-foreground font-bold" > Tipo Activo</ Label >
< div class = "flex items-center gap-2" >
< Folder class = "h-3 w-3 text-blue-500" />
< span class = "text-sm font-bold" > { formData . material_key || '-' } </ span >
</ div >
</ div >
< div class = "space-y-1" >
< Label class = "text-[10px] uppercase text-muted-foreground font-bold" > U.M. Com.</ Label >
< span class = "text-sm font-bold" > { formData . unit_of_measure || '-' } </ span >
</ div >
</ div >
< div class = "p-3 bg-orange-50 dark:bg-orange-950/20 rounded-lg border border-orange-100 dark:border-orange-900" >
< Label class = "text-[10px] uppercase text-orange-600 dark:text-orange-400 font-bold" > Fracción Arancelaria</ Label >
< p class = "text-lg font-mono font-bold text-orange-700 dark:text-orange-300" >
{ formData . fraction || '0000.00.00' }
</ p >
</ div >
</ div >
</ div >
</ div >
</ div >
<!-- Footer fijo con botones de acción -->
< div class = "fixed bottom-0 left-0 right-0 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-t shadow-lg z-[5]" >
< div class = "px-4 py-4 max-w-[1400px] mx-auto" >
<!-- Botones de acción -->
< div class = "flex justify-end gap-2" >
< Button size = "sm" onclick = {() => {
selectedClass = null ;
validationError = '' ;
showInsertDialog = true ;
}} >
< Plus class = "h-4 w-4 mr-1" />
Insertar
</ Button >
< Button variant = "outline" size = "sm" onclick = {() => {
if ( ! selectedClass ) {
toast . error ( 'Selecciona una clase para editar' );
return ;
}
validationError = '' ;
showInsertDialog = true ;
}} disabled= { ! selectedClass } > Editar</ Button >
< Button variant = "outline" size = "sm" onclick = { handleDelete } disabled= { ! selectedClass } > Borrar</ Button >
</ div >
</ div >
</ div >
<!-- Dialog para Insertar/Editar Clase de Activo Fijo -->
< Dialog.Root bind:open = { showInsertDialog } >
< Dialog.Content class = "!max-w-[1600px] !w-[1600px] !h-[90vh] p-0 overflow-hidden flex flex-col" >
< Dialog.Header class = "p-6 pb-4 border-b" >
< Dialog.Title > { selectedClass ? 'Editar' : 'Nueva' } Clase de Activo Fijo</ Dialog.Title >
</ Dialog.Header >
<!-- Mensaje de error de validación -->
{ #if validationError }
< div class = "mx-6 mt-4 p-4 bg-red-50 border border-red-200 rounded-lg" >
< div class = "flex items-start gap-3" >
< div class = "flex-shrink-0 w-5 h-5 rounded-full bg-red-500 text-white flex items-center justify-center text-sm font-bold mt-0.5" >
!
</ div >
< div class = "flex-1" >
< h3 class = "text-sm font-semibold text-red-800 mb-1" > Error de Validación</ h3 >
< p class = "text-sm text-red-700 whitespace-pre-line" > { validationError } </ p >
</ div >
< button
onclick = {() => validationError = '' }
class="flex-shrink-0 text-red-400 hover:text-red-600 "
aria-label = "Cerrar mensaje de error" >
< svg class = "w-5 h-5" fill = "none" stroke = "currentColor" viewBox = "0 0 24 24" >
< path stroke-linecap = "round" stroke-linejoin = "round" stroke-width = "2" d = "M6 18L18 6M6 6l12 12" />
</ svg >
</ button >
</ div >
</ div >
{ /if }
< div class = "flex-1 overflow-auto p-6" >
< FixedAssetClassForm
initialData = { selectedClass }
externalError= { validationError }
onClearError = {() => validationError = '' }
onSave= { async ( data : Partial < FixedAssetClassExtended >) => {
// Evitar múltiples clics
if ( isSaving ) {
console . log ( '⚠️ Ya está guardando, ignorando clic' );
return ;
}
isSaving = true ;
validationError = '' ;
console . log ( '========================================' );
console . log ( '=== INICIO ONSAVE ===' );
console . log ( 'Datos recibidos:' , data );
console . log ( 'selectedClass:' , selectedClass );
console . log ( '========================================' );
try {
const cleanData = $state . snapshot ( data );
const companyId = companyStore . activeCompany ? . id ;
const token = await getToken ();
if ( ! companyId ) {
throw new Error ( 'No hay empresa seleccionada' );
}
if ( ! token ) {
throw new Error ( 'No estás autenticado' );
}
let response ;
if ( selectedClass ? . id ) {
// === ACTUALIZACIÓN ===
console . log ( '🔄 MODO: ACTUALIZACIÓN' );
console . log ( 'ID de clase:' , selectedClass . id );
response = await classesApi . update ( selectedClass . id , {
class_code : cleanData.class_code?.trim () || '' ,
description_es : cleanData.description_es?.trim () || '' ,
description_en : cleanData.description_en?.trim () || '' ,
material_key : cleanData.material_key?.trim () || '' ,
unit_of_measure : cleanData.unit_of_measure?.trim () || '' ,
fraction : cleanData.fraction?.trim () || '' ,
us_fraction : cleanData.us_fraction || '' ,
physical_review : cleanData.physical_review ? 1 : 0 ,
iva_exempt_fraction : cleanData.iva_exempt_fraction || ''
}, companyId );
// ¡IMPORTANTE! fetchApi NO lanza excepciones, retorna { error , status }
if ( response . error ) {
console . error ( '❌ Error en respuesta de actualización:' , response );
throw new Error ( response . error );
}
console . log ( '✅ Actualización exitosa' );
} else {
// === CREACIÓN ===
console . log ( '➕ MODO: CREACIÓN' );
const payload = {
client_id : 2 ,
class_code : cleanData.class_code?.trim () || '' ,
description_es : cleanData.description_es?.trim () || '' ,
description_en : cleanData.description_en?.trim () || '' ,
material_key : cleanData.material_key?.trim () || '' ,
unit_of_measure : cleanData.unit_of_measure?.trim () || '' ,
fraction : cleanData.fraction?.trim () || '' ,
us_fraction : cleanData.us_fraction?.trim () || '' ,
sub_key : cleanData.sub_key || '' ,
physical_review : cleanData.physical_review ? 1 : 0 ,
iva_exempt_fraction : cleanData.iva_exempt_fraction || '' ,
depreciation_rate : cleanData.depreciation_rate || null ,
fda_code : cleanData.fda_code || null ,
class_enabled : true
};
console . log ( 'Payload:' , payload );
const fetchResponse = await fetch ( `http://localhost:8000/api/v1/a76/classes/fa?company_id=$ { companyId } `, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': ` Bearer $ { token } `
},
body : JSON.stringify ( payload )
});
if ( ! fetchResponse . ok ) {
const errorData = await fetchResponse . json ();
console . error ( '❌ Error del servidor:' , errorData );
throw errorData ;
}
response = await fetchResponse . json ();
console . log ( '✅ Creación exitosa' );
}
// === ÉXITO TOTAL ===
console . log ( '✅ GUARDADO EXITOSO - Cerrando diálogo' );
const wasUpdate = !! selectedClass ? . id ;
await loadClasses ();
showInsertDialog = false ;
selectedClass = null ;
validationError = '' ;
toast . success ( wasUpdate ? 'Clase actualizada correctamente' : 'Clase creada correctamente' );
} catch ( error: any ) {
// === ERROR ===
console . error ( '========================================' );
console . error ( '❌ ERROR CAPTURADO' );
console . error ( 'Error:' , error );
console . error ( 'Error.response:' , error ? . response );
console . error ( 'Error.response.data:' , error ? . response ? . data );
console . error ( 'Error.detail:' , error ? . detail );
console . error ( '========================================' );
let errorMsg = 'Error al guardar' ;
// Primero intentar con error.detail (fetch directo)
if ( error ? . detail ) {
if ( typeof error . detail === 'string' ) {
errorMsg = error . detail ;
} else if ( Array . isArray ( error . detail )) {
errorMsg = error . detail . map (( e : any ) => e . msg || e ). join ( ', ' );
}
}
// Luego con error.response.data.detail (axios)
else if ( error ? . response ? . data ? . detail ) {
if ( typeof error . response . data . detail === 'string' ) {
errorMsg = error . response . data . detail ;
} else if ( Array . isArray ( error . response . data . detail )) {
errorMsg = error . response . data . detail . map (( e : any ) => e . msg || e ). join ( ', ' );
}
}
// Por último el mensaje genérico
else if ( error ? . message ) {
errorMsg = error . message ;
}
console . error ( '📝 Mensaje de error extraído:' , errorMsg );
validationError = errorMsg ;
console . error ( '🔴 validationError asignado:' , validationError );
console . error ( '🔴 showInsertDialog permanece:' , showInsertDialog );
console . error ( '========================================' );
// NO cerramos el diálogo, permanece abierto
} finally {
isSaving = false ;
console . log ( '✅ isSaving = false' );
}
}}
onCancel = {() => {
showInsertDialog = false ;
selectedClass = null ;
}}
/ >
</ div >
< Dialog.Footer class = "p-6 pt-4 border-t" >
< Button variant = "outline" onclick = {() => { validationError = '' ; showInsertDialog = false ;
selectedClass = null ;
}} > Cancelar</Button >
< Button type = "button" disabled = { isSaving } onclick= {() => {
// Trigger the form's handleSave by getting a reference via DOM
const saveEvent = new CustomEvent ( 'save-form' );
document . dispatchEvent ( saveEvent );
}} >
{ #if isSaving }
< RefreshCw class = "h-4 w-4 mr-2 animate-spin" />
Guardando...
{ : else }
< Save class = "h-4 w-4 mr-2" />
Guardar
{ /if }
</ Button >
</ Dialog.Footer >
</ Dialog.Content >
</ Dialog.Root >
<!-- Dialog de confirmación para borrar -->
< Dialog.Root bind:open = { showDeleteDialog } >
< Dialog.Content class = "max-w-md" >
< Dialog.Header >
< Dialog.Title > ¿Confirmar eliminación?</ Dialog.Title >
</ Dialog.Header >
< div class = "py-4" >
< p class = "text-sm text-muted-foreground" >
¿Estás seguro que deseas eliminar la clase < strong class = "text-foreground" > { selectedClass ? . class_code } </ strong > ?
</ p >
< p class = "text-sm text-muted-foreground mt-2" >
{ selectedClass ? . description_es }
</ p >
< p class = "text-sm text-destructive mt-4" >
Esta acción no se puede deshacer.
</ p >
</ div >
< Dialog.Footer >
< Button variant = "outline" onclick = {() => showDeleteDialog = false } > Cancelar</Button >
< Button variant = "destructive" onclick = { confirmDelete } > Eliminar</Button >
</ Dialog.Footer >
</ Dialog.Content >
</ Dialog.Root >