mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 11:23:18 +00:00
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Resumen Implementa las observaciones de facturas, nómina e IMSS sobre el catálogo de proveedores y la nómina semanal que ya existían. ## Qué queda disponible - Proveedores: régimen fiscal, código postal, estatus activo/inactivo y filtro por nombre o RFC. - Facturas recibidas y emitidas: Excel, UUID, revisión si el RFC no está en el catálogo, clasificación a obra/concepto y gasto `source = invoice` solo para la recibida clasificada. - Nómina: bono, descuento con observación, horas y día festivo dentro del armado semanal. La semana pagada no acepta cambios. - Préstamos: edición (libre sin pagos; con pagos solo nota y cuota) y estado de cuenta con saldo = entregado + comisión − pagos. - Finiquitos y liquidaciones: conceptos, neto descontando descuentos vigentes y saldo de préstamo, y gasto al pagar. - Dispersión: corte semanal, corrección antes de definitiva y comprobante. - Historial de alta, baja y reingreso en la ficha, más movimientos IMSS. - Reporte por periodo en Excel y PDF. - Jornal IMSS distinto del jornal real y consulta de cuotas exportable a Excel. ## Verificación `deno check` de la API pasó. En este entorno no hay Postgres, así que `api/pendientes_test.ts` queda listo para correr cuando la base tenga las migraciones 042–050 y el permiso IAM 007. <!-- CURSOR_AGENT_PR_BODY_END --> <div><a href="https://cursor.com/agents/bc-5e23dc3b-921e-4091-9650-7091d3a52bbf?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> <a href="https://cursor.com/background-agent?bcId=bc-5e23dc3b-921e-4091-9650-7091d3a52bbf&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> </div>
267 lines
9.5 KiB
Vue
267 lines
9.5 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"
|
|
/>
|
|
<IamDetail v-else-if="detail.kind === 'iam-user' || detail.kind === 'iam-role'" :detail="detail" />
|
|
<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";
|
|
import IamDetail from "~/components/IamDetail.vue";
|
|
|
|
const { user, logout } = useAuth();
|
|
const perms = usePermissions();
|
|
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) };
|
|
}
|
|
|
|
function navIf(label: string, icon: string, to: string, permission: string): NavItem | null {
|
|
if (!perms.can(permission)) return null;
|
|
return nav(label, icon, to);
|
|
}
|
|
|
|
const menu = computed<NavItem[]>(() => {
|
|
const items: NavItem[] = [];
|
|
const home = navIf("Inicio", "pi pi-home", "/", "reports.view");
|
|
if (home) items.push(home);
|
|
|
|
const obraItems = [
|
|
navIf("Proyectos", "pi pi-building", "/proyectos", "projects.view"),
|
|
navIf("Presupuesto", "pi pi-calculator", "/presupuesto", "budget.view"),
|
|
navIf("Programa de obra", "pi pi-calendar", "/programa-obra", "work_program.view"),
|
|
navIf("Control costos", "pi pi-chart-bar", "/control-presupuesto", "cost_control.view"),
|
|
].filter(Boolean) as NavItem[];
|
|
if (obraItems.length) items.push({ label: "Obra", icon: "pi pi-building", items: obraItems });
|
|
|
|
const opItems = [
|
|
navIf("Facturas", "pi pi-file", "/facturas", "invoices.view"),
|
|
navIf("Gastos", "pi pi-money-bill", "/gastos", "expenses.view"),
|
|
navIf("Almacén", "pi pi-box", "/almacen", "warehouse.view"),
|
|
navIf("Kanban", "pi pi-th-large", "/kanban", "kanban.view"),
|
|
].filter(Boolean) as NavItem[];
|
|
if (opItems.length) items.push({ label: "Operación", icon: "pi pi-briefcase", items: opItems });
|
|
|
|
const personalItems = [
|
|
navIf("Padrón", "pi pi-users", "/padron", "workers.view"),
|
|
navIf("Gafetes", "pi pi-id-card", "/gafetes", "documents.view"),
|
|
navIf("Nómina", "pi pi-wallet", "/nomina", "payroll.view"),
|
|
].filter(Boolean) as NavItem[];
|
|
if (personalItems.length) items.push({ label: "Personal", icon: "pi pi-users", items: personalItems });
|
|
|
|
const adminItems = [navIf("Usuarios", "pi pi-shield", "/usuarios", "users.view")].filter(Boolean) as NavItem[];
|
|
if (adminItems.length) items.push({ label: "Administración", icon: "pi pi-shield", items: adminItems });
|
|
|
|
return items;
|
|
});
|
|
|
|
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();
|
|
if (!path.startsWith("/usuarios") && (desk.detail.value.kind === "iam-user" || desk.detail.value.kind === "iam-role")) desk.clearDetail();
|
|
},
|
|
);
|
|
</script>
|