panels-origin/web-panel/layouts/default.vue
Alberto Martinez 7bcef22d59 Menú agrupado y ribbon tipo mockup
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
El menú principal deja de ser una lista plana que se parte en dos filas. Queda en una sola barra con iconos a la izquierda y grupos:

- Inicio
- Obra (Proyectos, Presupuesto, Programa de obra, Control costos)
- Operación (Gastos, Almacén, Kanban)
- Personal (Padrón, Gafetes, Nómina)
- Administración (Usuarios)

Configuración y empresas siguen en el menú del avatar. Ayuda duplicada se quitó del menú (queda el botón de la derecha).

El ribbon (Nuevo/Editar) se compacta como tarjeta: icono arriba, grupos, hover suave y Alta en azul.

Validación:
- `npm run build` en `web-panel` completó sin errores.
- Capturas del escritorio en `/proyectos`: menú en una fila, dropdown de Obra y ribbon de Acciones.
<!-- CURSOR_AGENT_PR_BODY_END -->

<div><a href="https://cursor.com/agents/bc-3755a070-b0cd-40ee-b616-7493e3911e5c?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-3755a070-b0cd-40ee-b616-7493e3911e5c&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div>
2026-09-08 16:57:19 +00:00

261 lines
8.3 KiB
Vue

<template>
<div class="shell">
<Menubar class="menubar" :model="menu" breakpoint="1100px">
<template #start>
<button type="button" class="brand-lockup" aria-label="Ir al inicio" @click="navigateTo('/')">
<span class="brand-mark" aria-hidden="true">
<span /><span /><span /><span />
</span>
<span class="brand-word">{{ brand }}</span>
</button>
</template>
<template #item="{ item, props, hasSubmenu, root }">
<a
v-bind="props.action"
class="menubar-link"
:class="{
'is-active': itemIsActive(item),
'is-root': root !== false,
'has-submenu': Boolean(hasSubmenu || item.items?.length),
}"
>
<i v-if="item.icon" :class="item.icon" aria-hidden="true" />
<span>{{ item.label }}</span>
<i v-if="hasSubmenu || item.items?.length" class="pi pi-angle-down menubar-chevron" aria-hidden="true" />
</a>
</template>
<template #end>
<div class="topbar-tools">
<IconField class="desk-search desk-search--topbar">
<InputIcon class="pi pi-search" />
<InputText v-model="globalQ" placeholder="Buscar en el sistema..." @keyup.enter="goSearch" />
</IconField>
<Button icon="pi pi-bell" text rounded aria-label="Notificaciones" @click="showNotifications" />
<Button icon="pi pi-question-circle" text rounded aria-label="Ayuda" @click="showHelp" />
<button type="button" class="topbar-user-trigger" aria-label="Menú de usuario" @click="toggleProfile">
<Avatar :label="initials(user?.display_name)" shape="circle" />
<i class="pi pi-angle-down" aria-hidden="true" />
</button>
</div>
</template>
</Menubar>
<Menu ref="profileMenu" :model="profileItems" popup />
<AppRibbon />
<div class="desk-body">
<Splitter :key="`${showTree}-${showDetail}`" class="desk-split">
<SplitterPanel v-if="showTree" :size="18" :min-size="12" class="desk-pane">
<AppModuleTree />
</SplitterPanel>
<SplitterPanel
:size="showDetail && showTree ? 60 : showDetail || showTree ? 80 : 100"
:min-size="30"
class="desk-pane desk-center-pane"
>
<div class="desk-main">
<div class="desk-tabstrip">
<span class="desk-tab is-active">{{ tabLabel }}</span>
</div>
<div class="desk-page">
<slot />
</div>
</div>
</SplitterPanel>
<SplitterPanel v-if="showDetail" :size="20" :min-size="16" class="desk-pane">
<div class="desk-side desk-detail">
<div class="desk-side-title desk-detail-title">
<span>Detalles del registro</span>
<span class="desk-detail-title-actions" aria-hidden="true">
<i class="pi pi-ellipsis-v" />
<i class="pi pi-angle-down" />
</span>
</div>
<div class="desk-detail-scroll">
<WorkerFicha
v-if="detail.kind === 'worker'"
:worker-id="detail.id"
:doc="detail.doc"
:tab="detail.tab"
@edit="editFromFicha"
/>
<ProjectFicha
v-else-if="detail.kind === 'project'"
:project="detail.project"
:doc="detail.doc"
@edit="editFromFicha"
/>
<BudgetFicha
v-else-if="detail.kind === 'budget'"
:item="detail.budgetItem"
@edit="editFromFicha"
/>
<div v-else class="detail-empty">
<i class="pi pi-file detail-empty-icon" />
<strong>Seleccione un registro</strong>
<span>La ficha aparecerá en este panel.</span>
</div>
</div>
</div>
</SplitterPanel>
</Splitter>
</div>
<AppStatusbar />
<LoanDialog
:visible="loanPrompt.visible"
:worker-id="loanPrompt.workerId"
:person-name="loanPrompt.personName"
@update:visible="setLoanVisible"
@saved="onLoanSaved"
/>
</div>
</template>
<script setup lang="ts">
import LoanDialog from "~/components/padron/LoanDialog.vue";
const { user, logout } = useAuth();
const { initials } = useUi();
const route = useRoute();
const desk = useDesktop();
const toast = useToast();
const { isDark, toggle: toggleThemeMode } = useTheme();
const profileMenu = ref();
const { tabLabel, showTree, showDetail, detail, globalQ, loanPrompt } = desk;
const profileItems = computed(() => [
{ label: user.value?.display_name || user.value?.username || "Usuario", disabled: true },
{ label: user.value?.company_name || user.value?.company_code || "Empresa", disabled: true },
{ separator: true },
{
label: "Empresas",
icon: "pi pi-briefcase",
command: () => navigateTo("/configuracion?tab=empresas"),
},
{
label: "Configuración",
icon: "pi pi-cog",
command: () => navigateTo("/configuracion"),
},
{
label: isDark.value ? "Tema claro" : "Tema oscuro",
icon: isDark.value ? "pi pi-sun" : "pi pi-moon",
command: toggleTheme,
},
{ separator: true },
{ label: "Cerrar sesión", icon: "pi pi-sign-out", command: logout },
]);
const brand = computed(() => "PANELS");
type NavItem = {
label: string;
icon?: string;
to?: string;
command?: () => void;
items?: NavItem[];
};
function nav(label: string, icon: string, to: string): NavItem {
return { label, icon, to, command: () => navigateTo(to) };
}
const menu: NavItem[] = [
nav("Inicio", "pi pi-home", "/"),
{
label: "Obra",
icon: "pi pi-building",
items: [
nav("Proyectos", "pi pi-building", "/proyectos"),
nav("Presupuesto", "pi pi-calculator", "/presupuesto"),
nav("Programa de obra", "pi pi-calendar", "/programa-obra"),
nav("Control costos", "pi pi-chart-bar", "/control-presupuesto"),
],
},
{
label: "Operación",
icon: "pi pi-briefcase",
items: [
nav("Gastos", "pi pi-money-bill", "/gastos"),
nav("Almacén", "pi pi-box", "/almacen"),
nav("Kanban", "pi pi-th-large", "/kanban"),
],
},
{
label: "Personal",
icon: "pi pi-users",
items: [
nav("Padrón", "pi pi-users", "/padron"),
nav("Gafetes", "pi pi-id-card", "/gafetes"),
nav("Nómina", "pi pi-wallet", "/nomina"),
],
},
{
label: "Administración",
icon: "pi pi-shield",
items: [nav("Usuarios", "pi pi-shield", "/usuarios")],
},
];
function isActive(to: string) {
if (!to) return false;
if (to === "/") return route.path === "/";
return route.path === to || route.path.startsWith(`${to}/`);
}
function itemIsActive(item: NavItem | undefined): boolean {
if (!item) return false;
if (item.to && isActive(item.to)) return true;
return Boolean(item.items?.some((child) => itemIsActive(child)));
}
function goSearch() {
navigateTo("/padron");
}
function showNotifications() {
toast.add({ severity: "info", summary: "Notificaciones", detail: "No hay notificaciones nuevas.", life: 2200 });
}
function showHelp() {
toast.add({ severity: "info", summary: `Ayuda de ${brand.value}`, detail: "Seleccione un módulo para consultar su flujo de trabajo.", life: 2800 });
}
function toggleTheme() {
toggleThemeMode();
toast.add({
severity: "success",
summary: "Tema",
detail: isDark.value ? "Tema oscuro activado." : "Tema claro activado.",
life: 1800,
});
}
function toggleProfile(event: Event) {
profileMenu.value?.toggle(event);
}
function editFromFicha() {
const edit = desk.ribbon.value.flatMap((g) => g.items).find((i) => i.key === "edit");
edit?.command?.();
}
function onLoanSaved() {
desk.bumpDetail();
}
function setLoanVisible(open: boolean) {
loanPrompt.value.visible = open;
}
watch(
() => route.path,
(path) => {
const workerOk = path.startsWith("/padron") || path.startsWith("/nomina") || path.startsWith("/kanban");
if (!workerOk && desk.detail.value.kind === "worker") desk.clearDetail();
if (!path.startsWith("/proyectos") && desk.detail.value.kind === "project") desk.clearDetail();
if (!path.startsWith("/presupuesto") && desk.detail.value.kind === "budget") desk.clearDetail();
},
);
</script>