mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 13:23:17 +00:00
Pre-computed merge for change #14
This commit is contained in:
commit
9e43d7adb3
4 changed files with 175 additions and 23 deletions
|
|
@ -50,6 +50,9 @@ async function load() {
|
||||||
label: [row.code, row.description].filter(Boolean).join(" — "),
|
label: [row.code, row.description].filter(Boolean).join(" — "),
|
||||||
}));
|
}));
|
||||||
filtered.value = items.value.slice(0, 30);
|
filtered.value = items.value.slice(0, 30);
|
||||||
|
if (props.modelValue) {
|
||||||
|
selected.value = items.value.find((i) => i.id === props.modelValue) ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSearch(event: { query: string }) {
|
function onSearch(event: { query: string }) {
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ export function useDesktop() {
|
||||||
}) {
|
}) {
|
||||||
tabLabel.value = opts.tab;
|
tabLabel.value = opts.tab;
|
||||||
if (opts.treeTitle != null) treeTitle.value = opts.treeTitle;
|
if (opts.treeTitle != null) treeTitle.value = opts.treeTitle;
|
||||||
if (opts.ribbon) ribbon.value = opts.ribbon;
|
ribbon.value = opts.ribbon ?? [];
|
||||||
if (opts.tree) tree.value = opts.tree;
|
if (opts.tree) tree.value = opts.tree;
|
||||||
if (opts.treeKey != null) treeKey.value = opts.treeKey;
|
if (opts.treeKey != null) treeKey.value = opts.treeKey;
|
||||||
if (opts.showTree != null) showTree.value = opts.showTree;
|
if (opts.showTree != null) showTree.value = opts.showTree;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,16 @@
|
||||||
<Chip v-if="summary" :label="money(summary.executed_subtotal)" icon="pi pi-wallet" />
|
<Chip v-if="summary" :label="money(summary.executed_subtotal)" icon="pi pi-wallet" />
|
||||||
</DeskTableBar>
|
</DeskTableBar>
|
||||||
|
|
||||||
|
<Message
|
||||||
|
v-if="summary && items.length && Number(summary.executed_subtotal) === 0"
|
||||||
|
severity="info"
|
||||||
|
:closable="false"
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
|
Esta pantalla compara presupuesto contra lo ejecutado. Los gastos se capturan en
|
||||||
|
<strong>Gastos</strong>, ligados a un concepto. Use «Registrar gasto» o haga clic en un renglón.
|
||||||
|
</Message>
|
||||||
|
|
||||||
<div v-if="summary" class="kpi-row mb-3">
|
<div v-if="summary" class="kpi-row mb-3">
|
||||||
<div class="kpi-card">
|
<div class="kpi-card">
|
||||||
<span class="kpi-label">Presupuesto</span>
|
<span class="kpi-label">Presupuesto</span>
|
||||||
|
|
@ -38,6 +48,7 @@
|
||||||
scroll-height="flex"
|
scroll-height="flex"
|
||||||
table-style="min-width: 72rem"
|
table-style="min-width: 72rem"
|
||||||
empty-message="Seleccione una obra con presupuesto cargado."
|
empty-message="Seleccione una obra con presupuesto cargado."
|
||||||
|
@row-click="onRowClick"
|
||||||
>
|
>
|
||||||
<Column field="code" header="Clave" style="width: 7rem">
|
<Column field="code" header="Clave" style="width: 7rem">
|
||||||
<template #body="{ data }"><span class="identifier">{{ data.code || "—" }}</span></template>
|
<template #body="{ data }"><span class="identifier">{{ data.code || "—" }}</span></template>
|
||||||
|
|
@ -91,6 +102,7 @@ type Summary = {
|
||||||
const desk = useDesktop();
|
const desk = useDesktop();
|
||||||
const { api } = useApi();
|
const { api } = useApi();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const perms = usePermissions();
|
||||||
|
|
||||||
const tableQ = ref("");
|
const tableQ = ref("");
|
||||||
const projects = ref<{ id: number; name: string }[]>([]);
|
const projects = ref<{ id: number; name: string }[]>([]);
|
||||||
|
|
@ -99,6 +111,7 @@ const summary = ref<Summary | null>(null);
|
||||||
|
|
||||||
const projectId = computed(() => Number(route.query.project_id || 0) || projects.value[0]?.id || 0);
|
const projectId = computed(() => Number(route.query.project_id || 0) || projects.value[0]?.id || 0);
|
||||||
const projectName = computed(() => projects.value.find((p) => p.id === projectId.value)?.name || "Sin obra");
|
const projectName = computed(() => projects.value.find((p) => p.id === projectId.value)?.name || "Sin obra");
|
||||||
|
const canCreateExpense = computed(() => perms.can("expenses.create"));
|
||||||
const filteredItems = computed(() => {
|
const filteredItems = computed(() => {
|
||||||
const q = tableQ.value.toLowerCase();
|
const q = tableQ.value.toLowerCase();
|
||||||
return items.value.filter((i) => !q || [i.code, i.description].join(" ").toLowerCase().includes(q));
|
return items.value.filter((i) => !q || [i.code, i.description].join(" ").toLowerCase().includes(q));
|
||||||
|
|
@ -108,13 +121,33 @@ function money(v: number) {
|
||||||
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
|
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goGastos(opts?: { nuevo?: boolean; budgetItemId?: number }) {
|
||||||
|
if (!projectId.value) return;
|
||||||
|
const qs = new URLSearchParams({ project_id: String(projectId.value) });
|
||||||
|
if (opts?.nuevo) qs.set("nuevo", "1");
|
||||||
|
if (opts?.budgetItemId) qs.set("budget_item_id", String(opts.budgetItemId));
|
||||||
|
navigateTo(`/gastos?${qs}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowClick(e: { data: ControlItem }) {
|
||||||
|
if (!canCreateExpense.value) {
|
||||||
|
goGastos();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
goGastos({ nuevo: true, budgetItemId: e.data.id });
|
||||||
|
}
|
||||||
|
|
||||||
async function loadProjects() {
|
async function loadProjects() {
|
||||||
const r = await api<{ projects?: { id: number; name: string }[] }>("/v1/projects");
|
const r = await api<{ projects?: { id: number; name: string }[] }>("/v1/projects");
|
||||||
projects.value = r.projects ?? [];
|
projects.value = r.projects ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadControl() {
|
async function loadControl() {
|
||||||
if (!projectId.value) return;
|
if (!projectId.value) {
|
||||||
|
summary.value = null;
|
||||||
|
items.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
const [s, i] = await Promise.all([
|
const [s, i] = await Promise.all([
|
||||||
api<{ data?: Summary }>(`/v1/projects/${projectId.value}/cost-control/summary`),
|
api<{ data?: Summary }>(`/v1/projects/${projectId.value}/cost-control/summary`),
|
||||||
api<{ data?: { items?: ControlItem[] } }>(`/v1/projects/${projectId.value}/cost-control/items`),
|
api<{ data?: { items?: ControlItem[] } }>(`/v1/projects/${projectId.value}/cost-control/items`),
|
||||||
|
|
@ -123,20 +156,67 @@ async function loadControl() {
|
||||||
items.value = i.data?.items ?? [];
|
items.value = i.data?.items ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
function syncChrome() {
|
||||||
await loadProjects();
|
|
||||||
desk.configure({
|
desk.configure({
|
||||||
tab: "Control presupuesto",
|
tab: "Control presupuesto",
|
||||||
treeTitle: "Obras",
|
treeTitle: "Obras",
|
||||||
|
showTree: true,
|
||||||
|
showDetail: false,
|
||||||
|
treeKey: projectId.value ? `p:${projectId.value}` : "",
|
||||||
|
countLabel: projectId.value ? `${items.value.length} conceptos` : "",
|
||||||
tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })),
|
tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })),
|
||||||
onTreeSelect: (key) => {
|
onTreeSelect: (key) => {
|
||||||
if (key.startsWith("p:")) navigateTo(`/control-presupuesto?project_id=${key.slice(2)}`);
|
if (key.startsWith("p:")) navigateTo(`/control-presupuesto?project_id=${key.slice(2)}`);
|
||||||
},
|
},
|
||||||
|
ribbon: [
|
||||||
|
{
|
||||||
|
key: "control",
|
||||||
|
label: "Control",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "gasto",
|
||||||
|
label: "Registrar gasto",
|
||||||
|
icon: "pi pi-plus",
|
||||||
|
disabled: !projectId.value || !canCreateExpense.value,
|
||||||
|
title: canCreateExpense.value
|
||||||
|
? "Captura un gasto ligado a un concepto del presupuesto"
|
||||||
|
: "Sin permiso para capturar gastos",
|
||||||
|
command: () => goGastos({ nuevo: true }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "gastos",
|
||||||
|
label: "Ver gastos",
|
||||||
|
icon: "pi pi-list",
|
||||||
|
disabled: !projectId.value,
|
||||||
|
command: () => goGastos(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "vista",
|
||||||
|
label: "Vista",
|
||||||
|
items: [
|
||||||
|
{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", disabled: !projectId.value, command: loadControl },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadProjects();
|
||||||
|
if (!route.query.project_id && projectId.value) {
|
||||||
|
await navigateTo(`/control-presupuesto?project_id=${projectId.value}`, { replace: true });
|
||||||
|
}
|
||||||
|
syncChrome();
|
||||||
await loadControl();
|
await loadControl();
|
||||||
|
syncChrome();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(projectId, loadControl);
|
watch(projectId, async () => {
|
||||||
|
await loadControl();
|
||||||
|
syncChrome();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -150,4 +230,5 @@ watch(projectId, loadControl);
|
||||||
}
|
}
|
||||||
.kpi-label { display: block; font-size: 11px; color: #748095; margin-bottom: 4px; }
|
.kpi-label { display: block; font-size: 11px; color: #748095; margin-bottom: 4px; }
|
||||||
.cost-critical-text { color: #b42318; font-weight: 600; }
|
.cost-critical-text { color: #b42318; font-weight: 600; }
|
||||||
|
.control-page :deep(.p-datatable-tbody > tr) { cursor: pointer; }
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,7 @@ const desk = useDesktop();
|
||||||
const { api } = useApi();
|
const { api } = useApi();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
const perms = usePermissions();
|
||||||
|
|
||||||
const tableQ = ref("");
|
const tableQ = ref("");
|
||||||
const expenses = ref<Expense[]>([]);
|
const expenses = ref<Expense[]>([]);
|
||||||
|
|
@ -124,6 +125,7 @@ const showForm = ref(false);
|
||||||
const formError = ref("");
|
const formError = ref("");
|
||||||
const budgetWarning = ref<{ level: string; message: string } | null>(null);
|
const budgetWarning = ref<{ level: string; message: string } | null>(null);
|
||||||
const receiptFile = ref<File | null>(null);
|
const receiptFile = ref<File | null>(null);
|
||||||
|
const canCreateExpense = computed(() => perms.can("expenses.create"));
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
company_id: null as number | null,
|
company_id: null as number | null,
|
||||||
|
|
@ -163,6 +165,72 @@ function onFile(e: Event) {
|
||||||
receiptFile.value = input.files?.[0] ?? null;
|
receiptFile.value = input.files?.[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.company_id = companies.value[0]?.id ?? null;
|
||||||
|
form.project_id = projectId.value;
|
||||||
|
form.expense_date = new Date().toISOString().slice(0, 10);
|
||||||
|
form.budget_item_id = Number(route.query.budget_item_id || 0) || null;
|
||||||
|
form.description = "";
|
||||||
|
form.supplier_name = "";
|
||||||
|
form.supplier_rfc = "";
|
||||||
|
form.quantity = 0;
|
||||||
|
form.unit = "";
|
||||||
|
form.unit_price = 0;
|
||||||
|
form.subtotal = 0;
|
||||||
|
form.total = 0;
|
||||||
|
form.tax_included = false;
|
||||||
|
receiptFile.value = null;
|
||||||
|
formError.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openNew() {
|
||||||
|
resetForm();
|
||||||
|
showForm.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncChrome() {
|
||||||
|
desk.configure({
|
||||||
|
tab: "Gastos",
|
||||||
|
treeTitle: "Obras",
|
||||||
|
showTree: true,
|
||||||
|
showDetail: false,
|
||||||
|
treeKey: projectId.value ? `p:${projectId.value}` : "",
|
||||||
|
tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })),
|
||||||
|
onTreeSelect: (key) => {
|
||||||
|
if (key.startsWith("p:")) navigateTo(`/gastos?project_id=${key.slice(2)}`);
|
||||||
|
},
|
||||||
|
ribbon: [
|
||||||
|
{
|
||||||
|
key: "gastos",
|
||||||
|
label: "Captura",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "new",
|
||||||
|
label: "Nuevo gasto",
|
||||||
|
icon: "pi pi-plus",
|
||||||
|
disabled: !canCreateExpense.value,
|
||||||
|
command: openNew,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "control",
|
||||||
|
label: "Control",
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: "control",
|
||||||
|
label: "Control presupuesto",
|
||||||
|
icon: "pi pi-chart-bar",
|
||||||
|
disabled: !projectId.value,
|
||||||
|
command: () => navigateTo(`/control-presupuesto?project_id=${projectId.value}`),
|
||||||
|
},
|
||||||
|
{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: loadExpenses },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCatalogs() {
|
async function loadCatalogs() {
|
||||||
const [cat, pr] = await Promise.all([
|
const [cat, pr] = await Promise.all([
|
||||||
api<{ companies?: { id: number; name: string }[] }>("/v1/catalogs"),
|
api<{ companies?: { id: number; name: string }[] }>("/v1/catalogs"),
|
||||||
|
|
@ -204,6 +272,11 @@ async function save() {
|
||||||
toast.add({ severity: "success", summary: r.message || "Gasto registrado", life: 2500 });
|
toast.add({ severity: "success", summary: r.message || "Gasto registrado", life: 2500 });
|
||||||
showForm.value = false;
|
showForm.value = false;
|
||||||
await loadExpenses();
|
await loadExpenses();
|
||||||
|
if (route.query.nuevo || route.query.budget_item_id) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (projectId.value) qs.set("project_id", String(projectId.value));
|
||||||
|
await navigateTo(`/gastos${qs.toString() ? `?${qs}` : ""}`, { replace: true });
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
formError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
|
formError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
|
||||||
}
|
}
|
||||||
|
|
@ -211,25 +284,20 @@ async function save() {
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadCatalogs();
|
await loadCatalogs();
|
||||||
desk.configure({
|
syncChrome();
|
||||||
tab: "Gastos",
|
|
||||||
treeTitle: "Obras",
|
|
||||||
tree: projects.value.map((p) => ({ key: `p:${p.id}`, label: p.name, icon: "pi pi-building" })),
|
|
||||||
onTreeSelect: (key) => {
|
|
||||||
if (key.startsWith("p:")) navigateTo(`/gastos?project_id=${key.slice(2)}`);
|
|
||||||
},
|
|
||||||
ribbon: [
|
|
||||||
{
|
|
||||||
key: "gastos",
|
|
||||||
label: "Captura",
|
|
||||||
items: [
|
|
||||||
{ key: "new", label: "Nuevo gasto", icon: "pi pi-plus", command: () => { showForm.value = true; } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
await loadExpenses();
|
await loadExpenses();
|
||||||
|
if (route.query.nuevo) openNew();
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(projectId, loadExpenses);
|
watch(projectId, () => {
|
||||||
|
loadExpenses();
|
||||||
|
syncChrome();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => String(route.query.nuevo || ""),
|
||||||
|
(nuevo) => {
|
||||||
|
if (nuevo) openNew();
|
||||||
|
},
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue