- 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>
338 lines
10 KiB
Svelte
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>
|