feature/permisos-pedimentos

This commit is contained in:
2026-05-04 15:43:34 -06:00
parent aac5607e05
commit a1561d3796
23 changed files with 279 additions and 59 deletions

View File

@@ -386,22 +386,27 @@ export function getSidebarData(): SidebarData {
{
title: m["sidebar.pedimentos.pedimento_management"](),
url: "/dashboard/pedimentos",
permission: 'pedimentos_mgmt.view',
},
{
title: m["sidebar.pedimentos.pedimento_codes"](),
url: "/dashboard/reference_data/pedimento_codes",
permission: 'ref_pedimento_codes.view',
},
{
title: m["sidebar.pedimentos.customs_regimes"](),
url: "/dashboard/reference_data/pedimento_regimens",
permission: 'ref_pedimento_regimens.view',
},
{
title: m["sidebar.pedimentos.payment_methods"](),
url: "/dashboard/reference_data/payment_methods",
permission: 'pedimentos_payments.view',
},
{
title: m["sidebar.pedimentos.customs_sections"](),
url: "/dashboard/reference_data/customs_sections",
permission: 'ref_customs_sections.view',
},
{
title: m["sidebar.pedimentos.anexo_22_app_31"](),

View File

@@ -0,0 +1,43 @@
/**
* Permisos de pedimentos — códigos `pedimentos_mgmt.*` (seed / API).
*
* - **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).
*/
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 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). */
export function canCreatePedimento(user: User | null): boolean {
return hasAssignedCode(user, 'pedimentos_mgmt.create');
}
export function canEditPedimento(user: User | null): boolean {
return hasAssignedCode(user, 'pedimentos_mgmt.edit');
}
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. */
export function canOpenNuevoPedimentoForm(user: User | null): boolean {
return canEditPedimento(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);
}

View File

@@ -1,17 +1,14 @@
import type { PageServerLoad } from './$types';
import { redirect } from '@sveltejs/kit';
import {
getAuthTokens,
authenticatedFetch
} from '$lib/server/api';
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
export const load: PageServerLoad = async ({ fetch, cookies, url, parent }) => {
// Esperar a que el layout padre valide/refresque el token
const parentData = await parent();
// Verificar autenticación
const { accessToken } = getAuthTokens(cookies);
if (!accessToken) {
throw redirect(302, '/login');
}
@@ -23,13 +20,13 @@ export const load: PageServerLoad = async ({ fetch, cookies, url, parent }) => {
// 3. Primera compañía del usuario (fallback)
const companyIdParam = url.searchParams.get('company_id');
const cookieCompanyId = cookies.get('active_company_id');
const companyId = companyIdParam
const companyId = companyIdParam
? parseInt(companyIdParam)
: cookieCompanyId
? parseInt(cookieCompanyId)
: parentData.companies?.[0]?.id;
// Si aún no hay companyId, mostrar error
if (!companyId) {
return {
@@ -41,8 +38,8 @@ export const load: PageServerLoad = async ({ fetch, cookies, url, parent }) => {
companies: parentData.companies || []
};
}
// Usar authenticatedFetch para manejar automáticamente el refresh de tokens
// Misma idea que facturas: lista desde API; permisos en cliente vía syncCompanyPermissions + userHasPermission
const response = await authenticatedFetch(
`v1/a76/pedimentos?company_id=${companyId}&page=1&page_size=50`,
{},
@@ -52,12 +49,17 @@ export const load: PageServerLoad = async ({ fetch, cookies, url, parent }) => {
);
if (!response.ok) {
const message =
response.status === 403
? 'No tiene permiso para ver pedimentos'
: 'Error al cargar pedimentos';
return {
items: [],
total: 0,
page: 1,
page_size: 50,
error: 'Error al cargar pedimentos',
error: message,
companies: parentData.companies || [],
currentCompanyId: companyId
};

View File

@@ -21,10 +21,22 @@
import { reportsWinsaaiApi } from '$lib/api/dashboard/a76/reports/reports-winsaai';
import PdfProgressDialog from '$lib/components/dashboard/invoices/pdf-progress-dialog.svelte';
import { Checkbox } from '$lib/components/ui/checkbox';
import { get } from 'svelte/store';
import { currentUser } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
import {
canViewPedimentosList,
canEditPedimento,
canDeletePedimento,
canOpenNuevoPedimentoForm
} from '$lib/permissions/pedimento-permissions';
// Los datos iniciales vienen del servidor
let { data }: { data: PageData } = $props();
const canViewList = $derived(canViewPedimentosList($currentUser));
const canNuevoPedimentoAction = $derived(canOpenNuevoPedimentoForm($currentUser));
// Estado para filtros
let filters = $state({
status: '',
@@ -69,6 +81,14 @@ let filters = $state({
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);
@@ -95,7 +115,6 @@ let filters = $state({
// Estado para selección de filas
let selectedId = $state<number | null>(null);
let hasSelection = $derived(selectedId !== null);
let showDeleteDialog = $state(false);
let isWinsaiiConfirmOpen = $state(false);
let isWinsaiiByClass = $state(false);
@@ -107,6 +126,12 @@ let filters = $state({
let currentStatusFunction = $state<any>(null);
let selectedPedimento = $derived(allItems.find((p) => p.id === selectedId) || null);
const canEditSelected = $derived(
selectedPedimento ? canEditPedimento($currentUser) : false
);
const canDeleteSelected = $derived(
selectedPedimento ? canDeletePedimento($currentUser) : false
);
function handleRowClick(pedimento: Pedimento) {
// Toggle: si ya está seleccionado, deseleccionar; si no, seleccionar
@@ -114,6 +139,10 @@ let filters = $state({
}
function handleEditSelected() {
if (!canEditPedimento(get(currentUser))) {
toast.error('No tiene permiso para editar pedimentos');
return;
}
if (selectedId) {
window.location.href = `/dashboard/pedimentos/edit/${selectedId}`;
}
@@ -123,6 +152,10 @@ let filters = $state({
if (!selectedId) {
return;
}
if (!canDeletePedimento(get(currentUser))) {
toast.error('No tiene permiso para eliminar pedimentos');
return;
}
showDeleteDialog = true;
}
@@ -268,18 +301,6 @@ let filters = $state({
}
}
useShortcuts(
'Pedimentos',
obtenerAtajosListaPedimento({
manejarCrear: handleCreateClick,
manejarActualizar: reloadData,
manejarEditar: handleEditSelected,
manejarEliminar: handleDelete,
irATabla: focusFirstPedimentoTableRow,
irAAcciones: focusPedimentoListFooterActions
})
);
async function loadMore() {
if (loading || !hasMore) return;
@@ -446,7 +467,10 @@ let filters = $state({
}
function handleCreateClick() {
// Redirigir a la página de creación (reusa la página de edición con ID "new")
if (!canOpenNuevoPedimentoForm(get(currentUser))) {
toast.error('No tiene permiso para editar pedimentos');
return;
}
window.location.href = '/dashboard/pedimentos/edit/new';
}
@@ -455,6 +479,18 @@ let filters = $state({
reloadData();
}
useShortcuts(
'Pedimentos',
obtenerAtajosListaPedimento({
manejarCrear: handleCreateClick,
manejarActualizar: reloadData,
manejarEditar: handleEditSelected,
manejarEliminar: handleDelete,
irATabla: focusFirstPedimentoTableRow,
irAAcciones: focusPedimentoListFooterActions
})
);
// Opciones de status para el filtro
const statusOptions = [
{ value: '', label: 'Todos' },
@@ -479,6 +515,13 @@ let filters = $state({
const columns = createColumns(handleSuccess);
</script>
{#if !canViewList}
<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">
<!-- Header -->
<div class="flex items-center justify-between">
@@ -500,7 +543,7 @@ let filters = $state({
</option>
{/each}
</select>
<Button class="h-9" onclick={handleCreateClick}>
<Button class="h-9" onclick={handleCreateClick} disabled={!canNuevoPedimentoAction}>
<Plus class="mr-2" size={16} />
Nuevo Pedimento
</Button>
@@ -581,11 +624,21 @@ 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={!hasSelection}>
<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={!hasSelection}>
<Button
variant="outline"
size="sm"
onclick={handleDelete}
disabled={!canDeleteSelected}
>
<Trash2 size={16} class="mr-1" />
Borrar
</Button>
@@ -593,7 +646,7 @@ let filters = $state({
variant="outline"
size="sm"
onclick={handleInterfaceAgenteAduanal}
disabled={!hasSelection}
disabled={!selectedPedimento}
>
<Send class="mr-2 h-4 w-4" />
Interface Agente Aduanal
@@ -602,6 +655,7 @@ let filters = $state({
</div>
</div>
</div>
{/if}
<!-- Diálogo de confirmación para borrar -->
<Dialog.Root bind:open={showDeleteDialog}>

View File

@@ -29,7 +29,10 @@ export const load: PageServerLoad = async ({ params, cookies, fetch }) => {
if (!response.ok) {
console.error('Error fetching creation data:', response.status);
// Graceful degradation: return empty data
if (response.status === 403) {
throw error(403, 'No tiene permiso para editar pedimentos');
}
// Graceful degradation: return empty data (no autorización / errores distintos de 403)
return {
pedimento: null,
pedimentoId: null,
@@ -102,6 +105,9 @@ export const load: PageServerLoad = async ({ params, cookies, fetch }) => {
if (response.status === 401) {
throw redirect(302, '/login');
}
if (response.status === 403) {
throw error(403, 'No tiene permiso para editar pedimentos');
}
throw error(response.status, 'Error al cargar el pedimento');
}

View File

@@ -85,9 +85,16 @@
import { companyStore } from '$lib/stores/company.svelte';
import PrerequisitesModal from '$lib/components/dashboard/PrerequisitesModal.svelte';
import { currentUser } from '$lib/auth';
import ErrorState from '$lib/components/dashboard/common/error-state.svelte';
import { canAccessPedimentoEditorPage } from '$lib/permissions/pedimento-permissions';
let { data }: { data: ExtendedPageData } = $props();
const canUsePedimentoEditor = $derived(
canAccessPedimentoEditorPage($currentUser, !!data.isCreate)
);
let activeTab = $state('general');
// Focus first input when switching main tabs (mouse or shortcut)
@@ -1069,6 +1076,13 @@
}
</script>
{#if !canUsePedimentoEditor}
<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="space-y-6">
<!-- Header -->
<div class="flex items-center justify-between">
@@ -1297,3 +1311,4 @@
/>
{/if}
</div>
{/if}