chore: baseline plantilla-proyectos como base del CRM
This commit is contained in:
337
frontend/src/lib/components/sidebar/nav-main.svelte
Normal file
337
frontend/src/lib/components/sidebar/nav-main.svelte
Normal file
@@ -0,0 +1,337 @@
|
||||
<script lang="ts">
|
||||
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 { authStore, permissionsRefreshing, permissionsHydrated, userHasPermission } from '$lib/auth';
|
||||
import { systemStore } from '$lib/stores/system.svelte';
|
||||
import { page } from '$app/state';
|
||||
|
||||
function resolveVisibleNavItems<T>(
|
||||
filtered: T[],
|
||||
lastNonEmpty: T[],
|
||||
hasAuthenticatedUser: boolean
|
||||
): T[] {
|
||||
if (filtered.length > 0) return filtered;
|
||||
if (hasAuthenticatedUser && lastNonEmpty.length > 0) return lastNonEmpty;
|
||||
return filtered;
|
||||
}
|
||||
|
||||
let {
|
||||
items
|
||||
}: {
|
||||
items: {
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: any;
|
||||
isActive?: boolean;
|
||||
permission?: string;
|
||||
systemContext?: 'fixed_asset' | 'inventory';
|
||||
items?: {
|
||||
title: string;
|
||||
url: string;
|
||||
permission?: string;
|
||||
systemContext?: 'fixed_asset' | 'inventory';
|
||||
}[];
|
||||
}[];
|
||||
} = $props();
|
||||
|
||||
function isUrlActive(url: string): boolean {
|
||||
const pathname = page.url.pathname;
|
||||
return pathname === url || pathname.startsWith(url + '/');
|
||||
}
|
||||
|
||||
function matchesSystem(ctx: 'fixed_asset' | 'inventory' | undefined): boolean {
|
||||
if (!ctx) return true;
|
||||
if (!systemStore.activeSystem) return false;
|
||||
return ctx === systemStore.activeSystem;
|
||||
}
|
||||
|
||||
// Filtrar items según permisos y sistema activo
|
||||
const filteredItems = $derived(
|
||||
items
|
||||
.map((item) => ({
|
||||
...item,
|
||||
items: item.items?.filter((subItem) => {
|
||||
if (subItem.permission && !userHasPermission($authStore.user, subItem.permission)) return false;
|
||||
if (!matchesSystem(subItem.systemContext)) return false;
|
||||
return true;
|
||||
})
|
||||
}))
|
||||
.filter((item) => {
|
||||
// 1. Filtrar por permiso explícito del item principal
|
||||
if (item.permission && !userHasPermission($authStore.user, item.permission)) return false;
|
||||
|
||||
// 2. Filtrar por sistema activo
|
||||
if (!matchesSystem(item.systemContext)) return false;
|
||||
|
||||
// 3. Ocultar categorías (url="#") que se quedaron sin sub-items visibles
|
||||
if (item.url === '#' && item.items && item.items.length === 0) return false;
|
||||
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
type NavMainItem = (typeof items)[number];
|
||||
|
||||
const NAV_SNAPSHOT_KEY = 'app:sidebar:nav-main:v1';
|
||||
|
||||
function loadNavSnapshot(): NavMainItem[] {
|
||||
if (typeof window === 'undefined') return [];
|
||||
try {
|
||||
const raw = sessionStorage.getItem(NAV_SNAPSHOT_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? (parsed as NavMainItem[]) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveNavSnapshot(items: NavMainItem[]): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
sessionStorage.setItem(NAV_SNAPSHOT_KEY, JSON.stringify(items));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshot del último menú no vacío: evita parpadeo si filteredItems queda [] durante revalidación.
|
||||
let lastNonEmptyItems = $state<NavMainItem[]>(loadNavSnapshot());
|
||||
|
||||
$effect(() => {
|
||||
if (filteredItems.length > 0) {
|
||||
lastNonEmptyItems = filteredItems;
|
||||
saveNavSnapshot(filteredItems);
|
||||
}
|
||||
});
|
||||
|
||||
const visibleItems = $derived(
|
||||
resolveVisibleNavItems(
|
||||
filteredItems,
|
||||
lastNonEmptyItems,
|
||||
Boolean($authStore.user)
|
||||
)
|
||||
);
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
// Estado del Sistema Híbrido controlado por hover estricto (sin timers)
|
||||
let activeTitle = $state<string | null>(null);
|
||||
|
||||
function shouldKeepOpen(event: PointerEvent, title: string) {
|
||||
const next = event.relatedTarget as Node | null;
|
||||
if (!next) return false;
|
||||
|
||||
const trigger = document.getElementById(`trigger-${title}`);
|
||||
const content = document.getElementById(`content-${title}`);
|
||||
|
||||
// Mantenemos abierto si el puntero se mueve hacia el trigger o el contenido del menú
|
||||
return (trigger && trigger.contains(next)) || (content && content.contains(next));
|
||||
}
|
||||
|
||||
function handleTriggerEnter(title: string) {
|
||||
if (sidebar.state !== 'collapsed') return;
|
||||
activeTitle = title;
|
||||
}
|
||||
|
||||
function handleTriggerLeave(event: PointerEvent, title: string) {
|
||||
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;
|
||||
activeTitle = title;
|
||||
}
|
||||
|
||||
function handleContentLeave(event: PointerEvent, title: string) {
|
||||
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;
|
||||
}
|
||||
|
||||
function onOpenChange(open: boolean, title: string) {
|
||||
// Sincronización base
|
||||
if (open) {
|
||||
activeTitle = title;
|
||||
} else {
|
||||
if (activeTitle === title) {
|
||||
activeTitle = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel class="flex items-center gap-2">
|
||||
<span>Anexo-76</span>
|
||||
{#if $permissionsRefreshing}
|
||||
<span
|
||||
class="size-1.5 shrink-0 animate-pulse rounded-full bg-primary"
|
||||
title="Actualizando permisos"
|
||||
aria-label="Actualizando permisos"
|
||||
></span>
|
||||
{/if}
|
||||
</Sidebar.GroupLabel>
|
||||
<Sidebar.Menu
|
||||
id="dashboard-sidebar-nav"
|
||||
aria-label="Navegación principal"
|
||||
class={visibleItems.length === 0 && !$permissionsHydrated ? 'opacity-0' : ''}
|
||||
>
|
||||
{#each visibleItems as item (item.title)}
|
||||
{#if item.items && item.items.length > 0}
|
||||
{#if sidebar.state === 'collapsed'}
|
||||
<!-- Sidebar Colapsado: Dropdown controlado por eventos estrictos -->
|
||||
<Sidebar.MenuItem>
|
||||
<DropdownMenu.Root
|
||||
open={activeTitle === item.title}
|
||||
onOpenChange={(v) => onOpenChange(v, item.title)}
|
||||
>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<div
|
||||
id={`trigger-${item.title}`}
|
||||
class="relative z-30 flex w-full justify-center"
|
||||
onpointerenter={() => handleTriggerEnter(item.title)}
|
||||
onpointerleave={(e) => handleTriggerLeave(e, item.title)}
|
||||
>
|
||||
<Sidebar.MenuButton
|
||||
{...props}
|
||||
tooltipContent={undefined}
|
||||
class="justify-center"
|
||||
>
|
||||
{#if item.icon}
|
||||
<item.icon />
|
||||
{:else}
|
||||
<div class="size-4"></div>
|
||||
{/if}
|
||||
<!-- Ocultamos el texto en modo colapsado para asegurar que solo sea el icono -->
|
||||
<span class="sr-only">{item.title}</span>
|
||||
</Sidebar.MenuButton>
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={0}
|
||||
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)}
|
||||
>
|
||||
<!--
|
||||
Header "Tab" (Icono Flotante)
|
||||
Posicionado con right-full para estar exactamente donde el trigger termina (offset 0).
|
||||
Usamos w-8 h-8 para coincidir con un botón de tamaño estándar de sidebar.
|
||||
-->
|
||||
<div
|
||||
class="absolute top-0 right-full z-50 flex h-8 w-8 items-center justify-center rounded-l-lg border border-r-0 border-sidebar-border bg-sidebar-accent text-sidebar-accent-foreground shadow-none"
|
||||
>
|
||||
{#if item.icon}
|
||||
<item.icon class="size-4 shrink-0" />
|
||||
{:else}
|
||||
<div class="size-4 shrink-0"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Panel Principal
|
||||
-->
|
||||
<div
|
||||
class="pointer-events-auto ml-[0px] h-full w-full rounded-lg rounded-tl-none border border-sidebar-border bg-popover p-1"
|
||||
>
|
||||
<!-- Título en el panel principal -->
|
||||
<div
|
||||
class="truncate border-b px-2 py-2 text-sm font-medium text-sidebar-foreground"
|
||||
>
|
||||
{item.title}
|
||||
</div>
|
||||
|
||||
<DropdownMenu.DropdownMenuGroup class="mt-1 max-h-80 overflow-y-auto">
|
||||
{#each item.items as subItem (subItem.title)}
|
||||
<DropdownMenu.Item>
|
||||
{#snippet child({ props })}
|
||||
<a
|
||||
{...props}
|
||||
href={subItem.url}
|
||||
class="flex w-full items-center gap-2 overflow-hidden"
|
||||
>
|
||||
<span class="truncate">{subItem.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.DropdownMenuGroup>
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Sidebar.MenuItem>
|
||||
{:else}
|
||||
<!-- Sidebar Expandido: Collapsible original -->
|
||||
<Collapsible.Root
|
||||
open={item.isActive || item.items?.some((sub) => isUrlActive(sub.url))}
|
||||
class="group/collapsible"
|
||||
>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuItem {...props}>
|
||||
<Collapsible.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Sidebar.MenuButton {...props} tooltipContent={item.title}>
|
||||
{#if item.icon}
|
||||
<item.icon />
|
||||
{/if}
|
||||
<span>{item.title}</span>
|
||||
<ChevronRight
|
||||
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
|
||||
/>
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content>
|
||||
<Sidebar.MenuSub>
|
||||
{#each item.items as subItem (subItem.title)}
|
||||
<Sidebar.MenuSubItem>
|
||||
<Sidebar.MenuSubButton isActive={isUrlActive(subItem.url)}>
|
||||
{#snippet child({ props })}
|
||||
<a href={subItem.url} {...props}>
|
||||
<span>{subItem.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuSubButton>
|
||||
</Sidebar.MenuSubItem>
|
||||
{/each}
|
||||
</Sidebar.MenuSub>
|
||||
</Collapsible.Content>
|
||||
</Sidebar.MenuItem>
|
||||
{/snippet}
|
||||
</Collapsible.Root>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Items sin submenú -->
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton tooltipContent={item.title}>
|
||||
{#snippet child({ props })}
|
||||
<a href={item.url} {...props}>
|
||||
{#if item.icon}
|
||||
<item.icon />
|
||||
{/if}
|
||||
<span>{item.title}</span>
|
||||
</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/if}
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.Group>
|
||||
Reference in New Issue
Block a user