Files
CRM_AGENTES_CARGA/frontend/src/lib/components/sidebar/nav-main.svelte
Aduanasoft af02145332 feat(crm): frontend del CRM (clientes API, navegación, dashboard + Kanban) y rebrand
- Clientes API tipados por entidad en src/lib/api/crm
- Navegación CRM en el sidebar
- Páginas: panel (KPIs + embudo), cuentas, contactos, prospectos, actividades
- Kanban de oportunidades con drag & drop (mueve entre etapas) y siembra de embudo
- Rebrand plantilla → CRM (.env, docker-compose name, package.json, README)
- Fix: CORE_DB_HOST=postgres (coincide con el servicio del compose)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:53:56 -06:00

338 lines
10 KiB
Svelte

<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>CRM</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>