From 90811174105479ad67a2b0d00738ce5fec697951 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Wed, 4 Mar 2026 10:41:40 -0700 Subject: [PATCH 1/3] feat: Implement auto-fill for material, unit of measure, and tariff fraction descriptions in the fixed asset class form. --- backend/celerybeat-schedule | Bin 16384 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 backend/celerybeat-schedule diff --git a/backend/celerybeat-schedule b/backend/celerybeat-schedule deleted file mode 100644 index 8cda996d90f19534ef5b500c264fc536e50391cb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16384 zcmeI(O=}ZD7zgl)i6*fP6eB1KB3^8etZ&$Zcu;}|S*&0M!9$nLW@?6Rc4c=~Odybx zT9|{-DTp7yv-kx(d-4N#^5CUVKY^gnbhb8!;!Dt?_8*vip54vNPJWqvNCJ8H;%=SI zFt*t!m{`r@OB1h}4P&2ez!m}!fB*y_009U<00Izz00bZafqzlpWIky`3jqf>7y+yU ztOKkA+y|%wr~{}2r~{}2r~{}2s00659VptnnoZ}CowLd1f{ly$e!XV*H!f^F@z-OC zx_qI>PCaqls=xsbO7Lk^YgZUnZi7M2ZZNjo^M{=IVuN?oyqIf zJ{sl}wZF~Nyyt?aqtZ}2=JRioia7GTQE6@SUjQ^?wf~ZH(V5eZ9MI7tmV>GKwH{AI z;Gez8ABiCLT0-`hDQEUDm47@g~$dgqty7R))*T&8C7*{Aum=|YvQoJQ;(P*pR>4iBYI`z~{@xa Date: Wed, 4 Mar 2026 10:42:11 -0700 Subject: [PATCH 2/3] feat: Implement auto-fill descriptions for key fields and enhance form layout in the fixed asset class form. --- .../customs_brokers/data-table-actions.svelte | 46 +- .../classes/forms/FixedAssetClassForm.svelte | 211 +++- .../src/lib/components/help/HelpDrawer.svelte | 17 +- .../keyboard/ShortcutsHelpModal.svelte | 17 +- .../lib/components/sidebar/nav-main.svelte | 73 +- .../src/lib/components/ui/dialog/index.ts | 4 +- .../src/routes/dashboard/users/+page.svelte | 927 ++++++++++-------- 7 files changed, 787 insertions(+), 508 deletions(-) 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 d32046fc..2d529e05 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 @@ -1,16 +1,16 @@
-
+
- {#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/help/HelpDrawer.svelte b/frontend/src/lib/components/help/HelpDrawer.svelte index e2de2644..6f543bdf 100644 --- a/frontend/src/lib/components/help/HelpDrawer.svelte +++ b/frontend/src/lib/components/help/HelpDrawer.svelte @@ -121,13 +121,16 @@ - - + + {#snippet child({ props })} + + {/snippet} diff --git a/frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte b/frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte index 3a9be85d..07a6d8bf 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)

+ +

Active Actions (Alt+Shift)

{#if localShortcuts.length === 0} -

No specific actions for this view.

+

No specific actions for this view.

{:else} + +
- 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"; + 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,30 +37,30 @@ } function handleTriggerEnter(title: string) { - if (sidebar.state !== "collapsed") return; + if (sidebar.state !== 'collapsed') return; activeTitle = title; } 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; } 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,23 +81,26 @@ {#each items as item (item.title)} {#if item.items && item.items.length > 0} - {#if sidebar.state === "collapsed"} + {#if sidebar.state === 'collapsed'} onOpenChange(v, item.title)} - modal={false} > - + {#snippet child({ props })}
handleTriggerEnter(item.title)} onpointerleave={(e) => handleTriggerLeave(e, item.title)} > - + {#if item.icon} {:else} @@ -113,7 +116,7 @@ side="right" align="start" sideOffset={0} - class="z-50 w-64 rounded-lg p-0 shadow-lg overflow-visible" + 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)} @@ -123,7 +126,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. --> -
+
{#if item.icon} {:else} @@ -134,19 +139,28 @@ -
- +
-
+
{item.title}
- + {#each item.items as subItem (subItem.title)} - - - {subItem.title} - + + {#snippet child({ props })} + + {subItem.title} + + {/snippet} {/each} @@ -209,4 +223,3 @@ {/each} - diff --git a/frontend/src/lib/components/ui/dialog/index.ts b/frontend/src/lib/components/ui/dialog/index.ts index 1d37c04e..da8652a5 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 = Dialog.Root; -const Portal = Dialog.Portal; +const Root = Dialog?.Root ?? (class { } as any); +const Portal = Dialog?.Portal ?? (class { } as any); export { Root, diff --git a/frontend/src/routes/dashboard/users/+page.svelte b/frontend/src/routes/dashboard/users/+page.svelte index 1edb8a30..2420caa4 100644 --- a/frontend/src/routes/dashboard/users/+page.svelte +++ b/frontend/src/routes/dashboard/users/+page.svelte @@ -2,9 +2,16 @@ import { usersAPI, type User, type UserStats } from '$lib/api/dashboard/users'; import { userRolesAPI, type UserRole } from '$lib/api/dashboard/admin/user-roles'; import { rolesAPI, type CompanyRole } from '$lib/api/dashboard/admin/roles'; - import { rolePermissionsAPI, type RolePermission } from '$lib/api/dashboard/admin/role-permissions'; + import { + rolePermissionsAPI, + type RolePermission + } from '$lib/api/dashboard/admin/role-permissions'; import { permissionsAPI, type Permission } from '$lib/api/dashboard/admin/permissions'; - import { userPermissionsAPI, type UserPermission, type EffectiveUserPermissions } from '$lib/api/dashboard/admin/user-permissions'; + import { + userPermissionsAPI, + type UserPermission, + type EffectiveUserPermissions + } from '$lib/api/dashboard/admin/user-permissions'; import { companyStore } from '$lib/stores/company.svelte'; import * as Card from '$lib/components/ui/card'; import * as Dialog from '$lib/components/ui/dialog'; @@ -20,13 +27,13 @@ import * as Tooltip from '$lib/components/ui/tooltip'; import { Badge } from '$lib/components/ui/badge'; import { Checkbox } from '$lib/components/ui/checkbox'; - import { - Plus, - Pencil, - Trash2, - Key, - UserCheck, - UserX, + import { + Plus, + Pencil, + Trash2, + Key, + UserCheck, + UserX, Search, RefreshCw, Users, @@ -109,7 +116,7 @@ // Dividir el código en partes const parts = permission.code.split('.'); - + // Si solo tiene module.action, formatear simple if (parts.length === 2) { const [module, action] = parts; @@ -120,8 +127,8 @@ // La última parte es la acción, el resto son módulos/submódulos const action = parts[parts.length - 1]; const modules = parts.slice(0, -1); - - const modulePath = modules.map(m => getModuleLabel(m)).join(' › '); + + const modulePath = modules.map((m) => getModuleLabel(m)).join(' › '); return `${modulePath} - ${getActionLabel(action)}`; } @@ -164,9 +171,7 @@ // Roles disponibles que NO están asignados const unassignedRoles = $derived( - availableRoles.filter( - role => !userRoles.some(ur => ur.company_role_id === role.id) - ) + availableRoles.filter((role) => !userRoles.some((ur) => ur.company_role_id === role.id)) ); // Usuario seleccionado para editar/eliminar @@ -250,7 +255,7 @@ page_size: pageSize, search: searchTerm || undefined }); - + users = response.users; totalPages = response.total_pages; } catch (error: any) { @@ -275,8 +280,13 @@ // Crear usuario async function handleCreate() { - if (!createForm.email || !createForm.username || !createForm.first_name || - !createForm.last_name || !createForm.password) { + if ( + !createForm.email || + !createForm.username || + !createForm.first_name || + !createForm.last_name || + !createForm.password + ) { toast.error('Por favor complete todos los campos requeridos'); return; } @@ -385,7 +395,9 @@ try { await usersAPI.delete(selectedUser.id, companyId, softDelete); - toast.success(softDelete ? 'Usuario desactivado exitosamente' : 'Usuario eliminado permanentemente'); + toast.success( + softDelete ? 'Usuario desactivado exitosamente' : 'Usuario eliminado permanentemente' + ); showDeleteDialog = false; selectedUser = null; await Promise.all([loadUsers(), loadStats()]); @@ -429,10 +441,14 @@ } try { - await usersAPI.changePassword(selectedUser.id, { - password: passwordForm.password, - temporary: passwordForm.temporary - }, companyId); + await usersAPI.changePassword( + selectedUser.id, + { + password: passwordForm.password, + temporary: passwordForm.temporary + }, + companyId + ); toast.success('Contraseña actualizada exitosamente'); showPasswordDialog = false; selectedUser = null; @@ -449,11 +465,7 @@ async function openRolesDialog(user: User) { selectedUser = user; showRolesDialog = true; - await Promise.all([ - loadUserRoles(), - loadAvailableRoles(), - loadUserEffectivePermissions() - ]); + await Promise.all([loadUserRoles(), loadAvailableRoles(), loadUserEffectivePermissions()]); } async function loadAvailableRoles() { @@ -550,34 +562,34 @@ function openUserPermissionsDialog() { if (!effectivePermissions) return; - + // Permisos disponibles son todos los permisos que no están: // - Ya concedidos individualmente // - Ya revocados individualmente - const grantedIds = new Set(effectivePermissions.granted_permissions.map(p => p.id)); - const revokedIds = new Set(effectivePermissions.revoked_permissions.map(p => p.id)); - + const grantedIds = new Set(effectivePermissions.granted_permissions.map((p) => p.id)); + const revokedIds = new Set(effectivePermissions.revoked_permissions.map((p) => p.id)); + availablePermissionsForUser = allPermissionsState.filter( - p => !grantedIds.has(p.id) && !revokedIds.has(p.id) + (p) => !grantedIds.has(p.id) && !revokedIds.has(p.id) ); - + selectedUserPermissionIds = []; showUserPermissionsDialog = true; } function openGrantUserPermDialog() { if (!effectivePermissions) return; - + // Permisos disponibles son todos los permisos que no están: // - Ya concedidos individualmente // - Ya revocados individualmente - const grantedIds = new Set(effectivePermissions.granted_permissions.map(p => p.id)); - const revokedIds = new Set(effectivePermissions.revoked_permissions.map(p => p.id)); - + const grantedIds = new Set(effectivePermissions.granted_permissions.map((p) => p.id)); + const revokedIds = new Set(effectivePermissions.revoked_permissions.map((p) => p.id)); + availablePermissionsForUser = allPermissionsState.filter( - p => !grantedIds.has(p.id) && !revokedIds.has(p.id) + (p) => !grantedIds.has(p.id) && !revokedIds.has(p.id) ); - + selectedUserPermissionIds = []; permissionSearchQuery = ''; showGrantUserPermDialog = true; @@ -593,11 +605,11 @@ try { // Conceder cada permiso seleccionado await Promise.all( - selectedUserPermissionIds.map(permId => + selectedUserPermissionIds.map((permId) => userPermissionsAPI.grant(userId, companyId, permId) ) ); - + toast.success(`${selectedUserPermissionIds.length} permiso(s) concedido(s)`); // Cerrar todos los diálogos showGrantUserPermDialog = false; @@ -644,7 +656,7 @@ function toggleUserPermission(permId: number) { if (selectedUserPermissionIds.includes(permId)) { - selectedUserPermissionIds = selectedUserPermissionIds.filter(id => id !== permId); + selectedUserPermissionIds = selectedUserPermissionIds.filter((id) => id !== permId); } else { selectedUserPermissionIds = [...selectedUserPermissionIds, permId]; } @@ -735,7 +747,7 @@ function updatePermissionsByModuleForRole() { const grouped = new Map(); - + const filtered = availablePermissionsState.filter((p) => { if (!permissionSearchQuery.trim()) return true; const search = permissionSearchQuery.toLowerCase(); @@ -745,14 +757,14 @@ p.action.toLowerCase().includes(search) ); }); - + filtered.forEach((p) => { if (!grouped.has(p.module)) { grouped.set(p.module, []); } grouped.get(p.module)!.push(p); }); - + permissionsByModuleState = grouped; } @@ -823,23 +835,23 @@ try { await rolesAPI.delete(roleToDelete.id, companyId); - + // Limpiar estado si el rol eliminado estaba seleccionado if (selectedRoleForPermissions?.id === roleToDelete.id) { selectedRoleForPermissions = null; rolePermissionsState = []; } - + showDeleteRoleDialog = false; const deletedRoleId = roleToDelete.id; roleToDelete = null; - + // Recargar listas await Promise.all([loadRolesTab(), loadAvailableRoles()]); - + // Verificar que el rol ya no esté en la lista - rolesState = rolesState.filter(r => r.id !== deletedRoleId); - + rolesState = rolesState.filter((r) => r.id !== deletedRoleId); + toast.success('Rol eliminado permanentemente'); } catch (error: any) { // Manejar error específico cuando el rol tiene usuarios asignados @@ -857,7 +869,11 @@ if (!companyId || !selectedRoleForPermissions || selectedPermissionIds.length === 0) return; try { - await rolePermissionsAPI.assignMultiple(selectedRoleForPermissions.id, companyId, selectedPermissionIds); + await rolePermissionsAPI.assignMultiple( + selectedRoleForPermissions.id, + companyId, + selectedPermissionIds + ); toast.success('Permisos asignados correctamente'); showPermissionsDialog = false; await selectRoleForPermissions(selectedRoleForPermissions); @@ -890,7 +906,6 @@ } } - // Cargar datos reactivamente cuando cambia la compañía $effect(() => { const companyId = companyStore.activeCompany?.id; @@ -900,9 +915,9 @@ }); -
+
-
+

Gestión de Usuarios y Roles

Administra usuarios, roles y permisos de tu organización

@@ -913,256 +928,270 @@ - + Usuarios - + Roles y Permisos - +
-
- - {#if stats} -
- - - Total Usuarios - - - -
{stats.total_users}
-
-
+ + {#if stats} +
+ + + Total Usuarios + + + +
{stats.total_users}
+
+
- - - Activos - - - -
{stats.active_users}
-
-
+ + + Activos + + + +
{stats.active_users}
+
+
- - - Disponibles - - - -
{stats.users_available}
-

de {stats.max_users_allowed} permitidos

-
-
+ + + Disponibles + + + +
{stats.users_available}
+

de {stats.max_users_allowed} permitidos

+
+
- - - Uso de Licencia - - -
{stats.usage_percentage.toFixed(1)}%
-
-
= 70 && stats.usage_percentage < 90} - class:bg-red-600={stats.usage_percentage >= 90} - style="width: {stats.usage_percentage}%" - >
-
-
-
-
- {/if} - - - - -
- Usuarios -
-
- - -
- -
-
-
- - - - - Usuario - Email - Nombre - Rol - Estado - Acciones - - - - {#if loading && users.length === 0} - - - - - - {:else if users.length === 0} - - - No se encontraron usuarios - - - {:else} - {#each users as user} - - {user.username} - -
- {user.email} - {#if user.email_verified} - Verificado - {/if} -
-
- {user.first_name} {user.last_name} - - {#if user.role} -
- {#each user.role.split(', ') as roleName} - {roleName} - {/each} -
- {:else} - - - {/if} -
- - {#if user.enabled} - Activo - {:else} - Inactivo - {/if} - - -
- - - - - Gestionar roles - - - - - - Editar usuario - - - - - - Restablecer contrasena - - - - - - Eliminar usuario - -
-
-
- {/each} - {/if} -
-
- - - {#if totalPages > 1} -
-

- Página {currentPage} de {totalPages} -

-
- - -
+ + + Uso de Licencia + + +
{stats.usage_percentage.toFixed(1)}%
+
+
= 70 && stats.usage_percentage < 90} + class:bg-red-600={stats.usage_percentage >= 90} + style="width: {stats.usage_percentage}%" + >
+
+
+
{/if} -
-
+ + + + +
+ Usuarios +
+
+ + +
+ +
+
+
+ + + + + Usuario + Email + Nombre + Rol + Estado + Acciones + + + + {#if loading && users.length === 0} + + + + + + {:else if users.length === 0} + + + No se encontraron usuarios + + + {:else} + {#each users as user} + + {user.username} + +
+ {user.email} + {#if user.email_verified} + Verificado + {/if} +
+
+ {user.first_name} {user.last_name} + + {#if user.role} +
+ {#each user.role.split(', ') as roleName} + {roleName} + {/each} +
+ {:else} + - + {/if} +
+ + {#if user.enabled} + Activo + {:else} + Inactivo + {/if} + + +
+ + + {#snippet child({ props })} + + {/snippet} + + Gestionar roles + + + + {#snippet child({ props })} + + {/snippet} + + Editar usuario + + + + {#snippet child({ props })} + + {/snippet} + + Restablecer contrasena + + + + {#snippet child({ props })} + + {/snippet} + + Eliminar usuario + +
+
+
+ {/each} + {/if} +
+
+ + + {#if totalPages > 1} +
+

+ Página {currentPage} de {totalPages} +

+
+ + +
+
+ {/if} +
+
- -
+ +
-
-
-

+
+
+

Roles

@@ -1172,10 +1201,10 @@
{#if loadingRoles} -

Cargando...

+

Cargando...

{:else if rolesState.length === 0} -
-

No hay roles disponibles

+
+

No hay roles disponibles

@@ -1256,19 +1288,19 @@ Módulo Acción Permiso - Acciones + Acciones {#if loadingPermissionsState} - + Cargando permisos... {:else if rolePermissionsState.length === 0} - + Este rol no tiene permisos asignados @@ -1276,16 +1308,25 @@ {#each rolePermissionsState as rp (rp.id)} - {rp.permission?.module ? getModuleLabel(rp.permission.module) : '-'} + {rp.permission?.module + ? getModuleLabel(rp.permission.module) + : '-'} - {rp.permission?.action ? getActionLabel(rp.permission.action) : '-'} + {rp.permission?.action + ? getActionLabel(rp.permission.action) + : '-'} {#if rp.permission}
{formatPermissionLabel(rp.permission)}
- {rp.permission.code} + {rp.permission.code}
{:else} - @@ -1308,8 +1349,8 @@
{:else} -
- +
+

Selecciona un rol para ver sus permisos

{/if} @@ -1327,7 +1368,8 @@ Crear Nuevo Usuario - Ingresa la información del nuevo usuario. Se enviará un correo para configurar su contraseña. + Ingresa la información del nuevo usuario. Se enviará un correo para configurar su + contraseña.
@@ -1356,7 +1398,7 @@
- + @@ -1391,12 +1433,17 @@
- +
- + @@ -1421,12 +1468,17 @@

- +
- + @@ -1446,11 +1498,14 @@ Cancelar - handleDelete(false)} class="bg-destructive text-destructive-foreground hover:bg-destructive/90"> - + handleDelete(false)} + class="text-destructive-foreground bg-destructive hover:bg-destructive/90" + > + Eliminar Permanente @@ -1461,9 +1516,7 @@ Gestionar Roles - {selectedUser?.username} - - Asigna o remueve roles para este usuario - + Asigna o remueve roles para este usuario
@@ -1475,27 +1528,25 @@
{:else if userRoles.length === 0} -
+
Este usuario no tiene roles asignados
{:else}
{#each userRoles as userRole} -
+
-
{userRole.company_role?.name || 'Rol desconocido'}
+
+ {userRole.company_role?.name || 'Rol desconocido'} +
- Asignado el {new Date(userRole.created_at).toLocaleDateString()} + Asignado el {new Date(userRole.created_at).toLocaleDateString()}
-
@@ -1503,24 +1554,33 @@
{/if}
- +
{#if unassignedRoles.length === 0}

- No hay roles disponibles. Para cambiar de rol, primero remueve el rol actual usando el botón de eliminar arriba. + No hay roles disponibles. Para cambiar de rol, primero remueve el rol actual usando el + botón de eliminar arriba.

{:else}
selectedRoleId = v ? parseInt(v) : 0} + value={typeof selectedRoleId === 'number' + ? selectedRoleId.toString() + : selectedRoleId} + onValueChange={(v: string | undefined) => (selectedRoleId = v ? parseInt(v) : 0)} > - {selectedRoleId && selectedRoleId !== 0 - ? availableRoles.find(r => r.id === (typeof selectedRoleId === 'string' ? parseInt(selectedRoleId) : selectedRoleId))?.name || 'Selecciona un rol' + {selectedRoleId && selectedRoleId !== 0 + ? availableRoles.find( + (r) => + r.id === + (typeof selectedRoleId === 'string' + ? parseInt(selectedRoleId) + : selectedRoleId) + )?.name || 'Selecciona un rol' : 'Selecciona un rol'} @@ -1530,7 +1590,7 @@
@@ -1539,25 +1599,28 @@
- - + - + Permisos Individuales - {selectedUser?.username} @@ -1565,24 +1628,30 @@ -
+
{#if loadingUserPermissions}
{:else if effectivePermissions} -
+
-
{effectivePermissions.role_permissions.length}
+
+ {effectivePermissions.role_permissions.length} +
Del Rol
-
{effectivePermissions.granted_permissions.length}
+
+ {effectivePermissions.granted_permissions.length} +
Concedidos Extra
-
{effectivePermissions.revoked_permissions.length}
+
+ {effectivePermissions.revoked_permissions.length} +
Revocados
@@ -1591,17 +1660,17 @@ {#if effectivePermissions.granted_permissions.length > 0}
-
-
+
{#each effectivePermissions.granted_permissions as perm} -
-
-
{formatPermissionLabel(perm)}
+
+
+
{formatPermissionLabel(perm)}
{perm.code}
- - + @@ -1706,7 +1785,7 @@ - + Conceder Permisos Extra a {selectedUser?.username} @@ -1714,7 +1793,7 @@ -
+
{#if availablePermissionsForUser.length === 0} -

+

No hay permisos disponibles para conceder

{:else}
{#each [...availablePermissionsForUser.reduce((map, p) => { - const module = p.module; - if (!map.has(module)) map.set(module, []); - map.get(module).push(p); - return map; - }, new Map())] as [module, permissions] (module)} -
-

+ const module = p.module; + if (!map.has(module)) map.set(module, []); + map.get(module).push(p); + return map; + }, new Map())] as [module, permissions] (module)} +
+

{getModuleLabel(module)} - ({permissions.length} {permissions.length === 1 ? 'permiso' : 'permisos'}) + ({permissions.length} {permissions.length === 1 ? 'permiso' : 'permisos'})

{#each permissions as permission (permission.id)} - {#if !permissionSearchQuery || formatPermissionLabel(permission).toLowerCase().includes(permissionSearchQuery.toLowerCase()) || permission.code.toLowerCase().includes(permissionSearchQuery.toLowerCase())} -
+ {#if !permissionSearchQuery || formatPermissionLabel(permission) + .toLowerCase() + .includes(permissionSearchQuery.toLowerCase()) || permission.code + .toLowerCase() + .includes(permissionSearchQuery.toLowerCase())} +
toggleUserPermission(permission.id)} /> -
+
-
- {getActionLabel(permission.action)} - {permission.code} +
+ {getActionLabel(permission.action)} + {permission.code}
@@ -1774,8 +1865,11 @@
- - + @@ -1784,12 +1878,12 @@ - + Agregar Permisos a "{selectedRoleForPermissions?.name}" -
+
{#if availablePermissionsState.length === 0} -

+

No hay permisos disponibles para asignar

{:else}
{#each [...permissionsByModuleState] as [module, permissions] (module)} -
-

+
+

{getModuleLabel(module)} - ({permissions.length} {permissions.length === 1 ? 'permiso' : 'permisos'}) + ({permissions.length} {permissions.length === 1 ? 'permiso' : 'permisos'})

{#each permissions as permission (permission.id)} -
+
togglePermission(permission.id)} /> -
+
-
- {getActionLabel(permission.action)} - {permission.code} +
+ {getActionLabel(permission.action)} + {permission.code}
@@ -1902,12 +2003,17 @@ ¿Eliminar rol permanentemente? {#if roleToDelete} - Estás a punto de eliminar el rol "{roleToDelete.name}" de forma permanente. + Estás a punto de eliminar el rol "{roleToDelete.name}" de forma + permanente.

- Esta acción NO se puede deshacer y el rol será eliminado completamente del sistema. + Esta acción NO se puede deshacer y el rol será + eliminado completamente del sistema.

{#if rolePermissionsState.length > 0 && selectedRoleForPermissions?.id === roleToDelete.id} - Este rol tiene {rolePermissionsState.length} {rolePermissionsState.length === 1 ? 'permiso asignado' : 'permisos asignados'}. + Este rol tiene {rolePermissionsState.length} + {rolePermissionsState.length === 1 ? 'permiso asignado' : 'permisos asignados'}. {/if}
Si solo deseas desactivarlo temporalmente, usa el interruptor "Activo" en el modo de edición. @@ -1916,7 +2022,10 @@ Cancelar - + Eliminar permanentemente From 5c677b9987346c18749721d9e60616505c8331cc Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Wed, 4 Mar 2026 11:40:48 -0700 Subject: [PATCH 3/3] feat: Enhance pedimento save flow with detailed error handling, unique constraint checks for soft-deleted items, and multi-row selection in data table. --- .../a76/pedmientos/services/pedimentos.py | 9 ++- backend/core/error_handlers.py | 17 +++-- frontend/src/lib/api.ts | 4 +- .../dashboard/pedimentos/data-table.svelte | 58 +++++++-------- .../routes/dashboard/pedimentos/+page.svelte | 72 ++++++++++++------- .../pedimentos/edit/[id]/+page.svelte | 55 ++++++++------ 6 files changed, 130 insertions(+), 85 deletions(-) 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 ca30b42e..688868a0 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; @@ -271,7 +272,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/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/routes/dashboard/pedimentos/+page.svelte b/frontend/src/routes/dashboard/pedimentos/+page.svelte index 6898d88d..4ed9472d 100644 --- a/frontend/src/routes/dashboard/pedimentos/+page.svelte +++ b/frontend/src/routes/dashboard/pedimentos/+page.svelte @@ -79,30 +79,34 @@ 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); 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} /> @@ -443,17 +451,21 @@
- -
@@ -464,9 +476,19 @@ - ¿Eliminar pedimento? + + {#if selectedIds.length > 1} + ¿Eliminar {selectedIds.length} pedimentos? + {:else} + ¿Eliminar pedimento? + {/if} + - 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.
diff --git a/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte b/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte index ccd4acc7..4d133757 100644 --- a/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/pedimentos/edit/[id]/+page.svelte @@ -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)'; }