feature/permisos-desabilitar-botones

This commit is contained in:
2026-05-05 12:12:17 -06:00
parent a0c87cfac8
commit 9435e73761
11 changed files with 214 additions and 122 deletions

View File

@@ -67,6 +67,8 @@ def get_creation_data(
required_permissions=["invoice.exp.create"],
)
return InvoiceCatalogService.get_creation_data(db, tenant_id, company_id)
except HTTPException:
raise
except Exception as e:
logger.exception("get_creation_data failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al cargar datos de creación: {str(e)}")
@@ -187,6 +189,8 @@ def create_invoice(
validate_access_to_resource(db, company_id, current_user, required_permissions=[f"{perm_base}.create"])
return services.InvoiceService.create(db, data, tenant_id, company_id)
except HTTPException:
raise
except Exception as e:
logger.exception("create_invoice failed: %s", e)
raise HTTPException(status_code=500, detail=f"Error al guardar factura: {str(e)}")

View File

@@ -30,13 +30,15 @@ async def get_creation_data(
Consolidates multiple catalog calls into a single endpoint.
"""
from core.security import validate_access_to_resource
# Alta de pedimento: misma capacidad que editar en negocio / UI (pedimento-permissions).
validate_access_to_resource(db, company_id, current_user, ["pedimentos_mgmt.edit"])
# Alta de pedimento: requiere permiso create.
validate_access_to_resource(db, company_id, current_user, ["pedimentos_mgmt.create"])
tenant_id = current_user["tenant_id"]
try:
return PedimentoCatalogService.get_creation_data(db, tenant_id, company_id)
except HTTPException:
raise
except Exception as e:
print(f"Error fetching creation data: {e}")
import traceback
@@ -93,7 +95,7 @@ crud_router = TenantCRUDRoutes(
max_page_size=1000,
list_permissions=["pedimentos_mgmt.view"],
get_permissions=["pedimentos_mgmt.view"],
create_permissions=["pedimentos_mgmt.edit"],
create_permissions=["pedimentos_mgmt.create"],
update_permissions=["pedimentos_mgmt.edit"],
delete_permissions=["pedimentos_mgmt.delete"],
).router

View File

@@ -22,31 +22,33 @@
let deleteDialogOpen = $state(false);
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger disabled={!canEdit && !canDelete}>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
<span class="sr-only">Abrir menú</span>
<EllipsisVertical class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
{#if canEdit}
<DropdownMenu.Item onclick={() => editDialogOpen = true}>
<Pencil class="mr-2 h-4 w-4" />
Editar
</DropdownMenu.Item>
{/if}
{#if canDelete}
<DropdownMenu.Item onclick={() => deleteDialogOpen = true} class="text-destructive">
<Trash2 class="mr-2 h-4 w-4" />
Eliminar
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
{#if canEdit || canDelete}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon" class="h-8 w-8 p-0">
<span class="sr-only">Abrir menú</span>
<EllipsisVertical class="h-4 w-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Label>Acciones</DropdownMenu.Label>
{#if canEdit}
<DropdownMenu.Item onclick={() => editDialogOpen = true}>
<Pencil class="mr-2 h-4 w-4" />
Editar
</DropdownMenu.Item>
{/if}
{#if canDelete}
<DropdownMenu.Item onclick={() => deleteDialogOpen = true} class="text-destructive">
<Trash2 class="mr-2 h-4 w-4" />
Eliminar
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
{#if canEdit}
<CreateEditDialog

View File

@@ -0,0 +1,24 @@
import type { User } from '$lib/auth';
import { userHasPermission } from '$lib/auth';
function hasAssignedCode(user: User | null, code: string): boolean {
if (!user) return false;
return user.permissions.includes(code);
}
export function canViewCustomsBrokers(user: User | null): boolean {
return userHasPermission(user, 'customs_brokers.view');
}
export function canCreateCustomsBrokers(user: User | null): boolean {
return hasAssignedCode(user, 'customs_brokers.create');
}
export function canEditCustomsBrokers(user: User | null): boolean {
return hasAssignedCode(user, 'customs_brokers.edit');
}
export function canDeleteCustomsBrokers(user: User | null): boolean {
return hasAssignedCode(user, 'customs_brokers.delete');
}

View File

@@ -4,8 +4,7 @@
* - **Ver listado:** `userHasPermission` (incluye comodín de rol Keycloak `admin` como en otras pantallas).
* - **Crear / editar / borrar (mutaciones):** solo códigos en `user.permissions` (tras `syncCompanyPermissions`).
*
* **Alta de pedimento (`Nuevo` / `/edit/new`):** misma regla que editar — `pedimentos_mgmt.edit`
* (negocio: alta y edición son la misma capacidad).
* **Alta de pedimento (`Nuevo` / `/edit/new`):** requiere `pedimentos_mgmt.create`.
*/
import type { User } from '$lib/auth';
import { userHasPermission } from '$lib/auth';
@@ -19,7 +18,7 @@ export function canViewPedimentosList(user: User | null): boolean {
return userHasPermission(user, 'pedimentos_mgmt.view');
}
/** Permiso de API/rol para POST crear pedimento; el botón "Nuevo" en UI usa `canOpenNuevoPedimentoForm` (edit). */
/** Permiso de API/rol para crear pedimento. */
export function canCreatePedimento(user: User | null): boolean {
return hasAssignedCode(user, 'pedimentos_mgmt.create');
}
@@ -32,12 +31,12 @@ export function canDeletePedimento(user: User | null): boolean {
return hasAssignedCode(user, 'pedimentos_mgmt.delete');
}
/** Botón "Nuevo Pedimento" y navegación a `/edit/new` — requiere poder editar en RBAC de la app. */
/** Botón "Nuevo Pedimento" y navegación a `/edit/new` — requiere permiso create. */
export function canOpenNuevoPedimentoForm(user: User | null): boolean {
return canEditPedimento(user);
return canCreatePedimento(user);
}
/** Pantalla de alta (`/edit/new`) o edición — ambas exigen `pedimentos_mgmt.edit` en cliente (alineado a API). */
export function canAccessPedimentoEditorPage(user: User | null, _isCreate: boolean): boolean {
return canEditPedimento(user);
/** Pantalla de alta (`/edit/new`) o edición. */
export function canAccessPedimentoEditorPage(user: User | null, isCreate: boolean): boolean {
return isCreate ? canCreatePedimento(user) : canEditPedimento(user);
}

View File

@@ -13,6 +13,14 @@
import { page } from '$app/stores';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosListaAgentes } from '$lib/config/shortcuts/dashboard/customs_brokers/list';
import { currentUser } from '$lib/auth';
import {
canViewCustomsBrokers,
canCreateCustomsBrokers,
canEditCustomsBrokers,
canDeleteCustomsBrokers
} from '$lib/permissions/customs-broker-permissions';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
import {
customsSectionsApi,
@@ -60,6 +68,10 @@
filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize)
);
let totalItems = $derived(filteredItems.length);
const canViewBrokerList = $derived(canViewCustomsBrokers($currentUser));
const canCreateBroker = $derived(canCreateCustomsBrokers($currentUser));
const canEditBroker = $derived(canEditCustomsBrokers($currentUser));
const canDeleteBroker = $derived(canDeleteCustomsBrokers($currentUser));
// --- Customs Sections State ---
let sections = $state<CustomsSection[]>([]);
@@ -116,13 +128,25 @@
selectedItem = item;
}
function handleRowDoubleClick(item: CustomsBroker) {
if (!canEditBroker) {
toast.error('No tienes permiso para editar agentes aduanales');
return;
}
selectedItem = item;
goto(`/dashboard/customs_brokers/edit/${item.broker_key}`);
}
function handleEdit() {
if (!canEditBroker) {
toast.error('No tienes permiso para editar agentes aduanales');
return;
}
if (selectedItem) goto(`/dashboard/customs_brokers/edit/${selectedItem.broker_key}`);
}
async function handleDelete() {
if (!canDeleteBroker) {
toast.error('No tienes permiso para borrar agentes aduanales');
return;
}
if (!selectedItem || !companyStore.activeCompany?.id) return;
showDeleteDialog = true;
}
@@ -166,6 +190,10 @@
irAduanas: () => (activeTab = 'customs'),
crear: () => {
if (activeTab === 'brokers') {
if (!canCreateBroker) {
toast.error('No tienes permiso para crear agentes aduanales');
return;
}
goto('/dashboard/customs_brokers/edit/new');
} else {
// For Customs Sections, we might need a dialog.
@@ -191,6 +219,13 @@
const brokerColumns = createBrokerColumns(handleActionSuccess);
</script>
{#if !canViewBrokerList}
<div
class="flex h-[calc(100svh-4rem)] flex-col p-6 group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)]"
>
<ErrorState status={403} />
</div>
{:else}
<div class="flex flex-col gap-6 p-6 h-[calc(100svh-4rem)] group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)] overflow-hidden">
<div class="flex-none flex items-center justify-between">
<div class="space-y-1">
@@ -419,22 +454,33 @@
<div class="mx-auto max-w-[1400px] px-4 py-4">
<div class="flex justify-end gap-2">
{#if activeTab === 'brokers'}
<Button size="sm" href="/dashboard/customs_brokers/edit/new">
<Plus class="mr-1 h-4 w-4" />
Nuevo
</Button>
<Button variant="outline" size="sm" onclick={handleEdit} disabled={!selectedItem}>
Editar
</Button>
<Button
variant="outline"
size="sm"
onclick={handleDelete}
disabled={!selectedItem}
class="text-destructive hover:text-destructive"
>
Borrar
</Button>
{#if canCreateBroker}
<Button size="sm" href="/dashboard/customs_brokers/edit/new">
<Plus class="mr-1 h-4 w-4" />
Nuevo
</Button>
{/if}
{#if canEditBroker}
<Button
variant="outline"
size="sm"
onclick={handleEdit}
disabled={!selectedItem}
>
Editar
</Button>
{/if}
{#if canDeleteBroker}
<Button
variant="outline"
size="sm"
onclick={handleDelete}
disabled={!selectedItem}
class="text-destructive hover:text-destructive"
>
Borrar
</Button>
{/if}
{:else}
<Button size="sm" onclick={() => toast.info('Pendiente')}>
<Plus class="mr-1 h-4 w-4" />
@@ -444,5 +490,6 @@
</div>
</div>
</div>
{/if}
<DeleteDialog bind:open={showDeleteDialog} broker={selectedItem} onSuccess={handleActionSuccess} />

View File

@@ -2,11 +2,16 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { companyStore } from '$lib/stores/company.svelte';
import { currentUser } from '$lib/auth';
import {
customsBrokersApi,
uploadCustomsBrokerVuFile,
type CreateCustomsBrokerData
} from '$lib/api/dashboard/a76/customs-brokers';
import {
canCreateCustomsBrokers,
canEditCustomsBrokers
} from '$lib/permissions/customs-broker-permissions';
// UI Components
import CountryDialog from '$lib/components/dashboard/invoices/edit/items/fa/country-dialog.svelte';
@@ -44,11 +49,15 @@
import { getFileDisplayName } from '$lib/utils';
import { useShortcuts } from '$lib/hooks/use-shortcuts';
import { obtenerAtajosEdicionAgente } from '$lib/config/shortcuts/dashboard/customs_brokers/edit';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
// --- 1. LÓGICA DE IDENTIFICACIÓN ---
let routeId = $derived($page.params.id);
let isEdit = $derived(!!routeId && routeId !== 'new');
let title = $derived(isEdit ? 'Editar Agente Aduanal' : 'Nuevo Agente Aduanal');
const canAccessBrokerEditor = $derived(
isEdit ? canEditCustomsBrokers($currentUser) : canCreateCustomsBrokers($currentUser)
);
// --- 2. ESTADO ---
let loading = $state(false);
@@ -279,6 +288,10 @@
// --- 4. GUARDADO ---
async function handleSave() {
if (!canAccessBrokerEditor) {
toast.error('No tienes permiso para guardar agentes aduanales');
return;
}
if (!companyStore.activeCompany) {
toast.error('Selecciona una compañía');
return;
@@ -406,6 +419,13 @@
);
</script>
{#if !canAccessBrokerEditor}
<div
class="flex h-[calc(100svh-4rem)] flex-col p-6 group-has-data-[collapsible=icon]/sidebar-wrapper:h-[calc(100svh-3rem)]"
>
<ErrorState status={403} />
</div>
{:else}
<Tabs.Root bind:value={activeTab}>
<div class="space-y-3">
<!-- Header -->
@@ -1144,3 +1164,4 @@
</div>
</div>
</Tabs.Root>
{/if}

View File

@@ -1017,6 +1017,11 @@
}
function handleCreateClick() {
if (!canCreateInvoice) {
toast.error('No tienes permiso para crear este tipo de factura.');
return;
}
const params = new URLSearchParams(window.location.search);
const queryString = params.toString();
@@ -1533,10 +1538,12 @@
<Settings class="mr-2" size={16} />
{m.invoice_list_actions_parameters()}
</Button>
<Button class="h-9" onclick={handleCreateClick} disabled={!canCreateInvoice}>
<Plus class="mr-2" size={16} />
{m.invoice_list_actions_new_invoice()}
</Button>
{#if canCreateInvoice}
<Button class="h-9" onclick={handleCreateClick}>
<Plus class="mr-2" size={16} />
{m.invoice_list_actions_new_invoice()}
</Button>
{/if}
</div>
</div>

View File

@@ -74,10 +74,13 @@ export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => {
catalogsData = await creationResponse.json();
} else if (creationResponse.status === 403) {
throw error(403, 'No tienes permiso para crear este tipo de factura.');
} else if (creationResponse.status === 401) {
throw error(401, 'Tu sesión expiró. Vuelve a iniciar sesión.');
} else if (creationResponse.status === 422) {
throw error(422, 'Parámetros inválidos para crear factura.');
} else {
console.error('Error fetching creation data:', creationResponse.status);
// We continuing with empty catalogs might be better than crashing?
// But UI will likely be broken. Let's rely on empty arrays initialization below.
throw error(creationResponse.status, 'No fue posible preparar el formulario de creación.');
}
if (settingsResult && settingsResult.settings) {
@@ -160,7 +163,9 @@ export const load: PageServerLoad = async ({ params, cookies, fetch, url }) => {
} catch (err: any) {
console.error('Error in load function:', err);
if (err && err.status === 404) throw err; // Propagate 404
if (err && typeof err.status === 'number' && [401, 403, 404, 422].includes(err.status)) {
throw err;
}
throw error(500, 'Error interno al cargar la página');
}
};

View File

@@ -36,6 +36,8 @@
const canViewList = $derived(canViewPedimentosList($currentUser));
const canNuevoPedimentoAction = $derived(canOpenNuevoPedimentoForm($currentUser));
const canEditPedimentoAction = $derived(canEditPedimento($currentUser));
const canDeletePedimentoAction = $derived(canDeletePedimento($currentUser));
// Estado para filtros
let filters = $state({
@@ -74,21 +76,13 @@ let filters = $state({
}
// Escuchar cambios de compañía
const handleCompanyChange = (event: CustomEvent) => {
const handleCompanyChange = () => {
// Recargar los datos sin recargar la página completa
reloadData();
};
window.addEventListener('companyChanged', handleCompanyChange as EventListener);
void (async () => {
const cid = companyStore.activeCompany?.id;
if (cid) {
const { syncCompanyPermissions } = await import('$lib/auth');
await syncCompanyPermissions(cid);
}
})();
// Cleanup
return () => {
window.removeEventListener('companyChanged', handleCompanyChange as EventListener);
@@ -468,7 +462,7 @@ let filters = $state({
function handleCreateClick() {
if (!canOpenNuevoPedimentoForm(get(currentUser))) {
toast.error('No tiene permiso para editar pedimentos');
toast.error('No tiene permiso para crear pedimentos');
return;
}
window.location.href = '/dashboard/pedimentos/edit/new';
@@ -543,10 +537,12 @@ let filters = $state({
</option>
{/each}
</select>
<Button class="h-9" onclick={handleCreateClick} disabled={!canNuevoPedimentoAction}>
<Plus class="mr-2" size={16} />
Nuevo Pedimento
</Button>
{#if canNuevoPedimentoAction}
<Button class="h-9" onclick={handleCreateClick}>
<Plus class="mr-2" size={16} />
Nuevo Pedimento
</Button>
{/if}
</div>
</div>
@@ -624,24 +620,28 @@ let filters = $state({
<div class="mx-auto max-w-[1400px] px-4 py-4">
<!-- Botones de acción -->
<div class="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onclick={handleEditSelected}
disabled={!canEditSelected}
>
<Edit size={16} class="mr-1" />
Editar
</Button>
<Button
variant="outline"
size="sm"
onclick={handleDelete}
disabled={!canDeleteSelected}
>
<Trash2 size={16} class="mr-1" />
Borrar
</Button>
{#if canEditPedimentoAction}
<Button
variant="outline"
size="sm"
onclick={handleEditSelected}
disabled={!canEditSelected}
>
<Edit size={16} class="mr-1" />
Editar
</Button>
{/if}
{#if canDeletePedimentoAction}
<Button
variant="outline"
size="sm"
onclick={handleDelete}
disabled={!canDeleteSelected}
>
<Trash2 size={16} class="mr-1" />
Borrar
</Button>
{/if}
<Button
variant="outline"
size="sm"

View File

@@ -30,23 +30,15 @@ export const load: PageServerLoad = async ({ params, cookies, fetch }) => {
if (!response.ok) {
console.error('Error fetching creation data:', response.status);
if (response.status === 403) {
throw error(403, 'No tiene permiso para editar pedimentos');
throw error(403, 'No tiene permiso para crear pedimentos');
}
// Graceful degradation: return empty data (no autorización / errores distintos de 403)
return {
pedimento: null,
pedimentoId: null,
isCreate: true,
pedimentoCodes: [],
customsSections: [],
customsBrokers: [],
clients: [],
codePedimentoRegimens: [],
transportTypes: [],
transportModes: [],
pedimentoTransportCatalog: [],
error: 'Error al cargar catálogos. Verifique la conexión con el backend.'
};
if (response.status === 401) {
throw error(401, 'Tu sesión expiró. Vuelve a iniciar sesión.');
}
if (response.status === 422) {
throw error(422, 'Parámetros inválidos para crear pedimento.');
}
throw error(response.status, 'No fue posible preparar el formulario de creación.');
}
const data = await response.json();
@@ -66,21 +58,10 @@ export const load: PageServerLoad = async ({ params, cookies, fetch }) => {
};
} catch (e) {
console.error('❌ Error loading new pedimento data:', e);
// Graceful degradation: return empty data so the page loads, but show error
return {
pedimento: null,
pedimentoId: null,
isCreate: true,
pedimentoCodes: [],
customsSections: [],
customsBrokers: [],
clients: [],
codePedimentoRegimens: [],
transportTypes: [],
transportModes: [],
pedimentoTransportCatalog: [],
error: 'Error al cargar catálogos. Verifique la conexión con el backend.'
};
if (e && typeof e === 'object' && 'status' in e) {
throw e;
}
throw error(500, 'Error al cargar catálogos de creación');
}
}