mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 23:53:18 +00:00
La pantalla de control era solo lectura, pero heredaba los botones de Programa de obra (no-op). Ahora tiene acciones propias, explica que el ejecutado viene de Gastos/almacén, y abre la captura ligada al concepto. Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
303 lines
11 KiB
Vue
303 lines
11 KiB
Vue
<template>
|
|
<div class="gastos-page">
|
|
<DeskTableBar v-model="tableQ" placeholder="Buscar descripción, proveedor o concepto">
|
|
<Chip :label="projectLabel" icon="pi pi-building" />
|
|
<Chip :label="`${listed.length} gastos`" icon="pi pi-list" />
|
|
<Chip :label="money(totalSubtotal)" icon="pi pi-wallet" />
|
|
</DeskTableBar>
|
|
|
|
<Message v-if="budgetWarning" :severity="budgetWarning.level === 'critical' ? 'error' : 'warn'" :closable="true" class="mb-3">
|
|
{{ budgetWarning.message }}
|
|
</Message>
|
|
|
|
<DataTable
|
|
:value="listed"
|
|
data-key="id"
|
|
paginator
|
|
:rows="25"
|
|
empty-message="No hay gastos en el periodo seleccionado."
|
|
scrollable
|
|
scroll-height="flex"
|
|
table-style="min-width: 56rem"
|
|
>
|
|
<Column field="expense_date" header="Fecha" sortable style="width: 7rem" />
|
|
<Column field="description" header="Descripción" sortable />
|
|
<Column field="supplier_name" header="Proveedor" sortable style="width: 10rem" />
|
|
<Column field="budget_item_code" header="Concepto" style="width: 8rem">
|
|
<template #body="{ data }"><span class="identifier">{{ data.budget_item_code || "—" }}</span></template>
|
|
</Column>
|
|
<Column field="subtotal" header="Subtotal" sortable style="width: 9rem">
|
|
<template #body="{ data }">{{ money(data.subtotal) }}</template>
|
|
</Column>
|
|
<Column field="iva_amount" header="IVA" sortable style="width: 8rem">
|
|
<template #body="{ data }">{{ money(data.iva_amount) }}</template>
|
|
</Column>
|
|
<Column field="total" header="Total" sortable style="width: 9rem">
|
|
<template #body="{ data }"><strong>{{ money(data.total) }}</strong></template>
|
|
</Column>
|
|
<Column field="source" header="Origen" style="width: 7rem" />
|
|
</DataTable>
|
|
|
|
<Dialog v-model:visible="showForm" header="Nuevo gasto" modal :style="{ width: '720px' }">
|
|
<div class="form-grid form-grid-compact">
|
|
<FormField label="Empresa" required>
|
|
<Select v-model="form.company_id" :options="companies" option-label="name" option-value="id" class="w-full" />
|
|
</FormField>
|
|
<FormField label="Obra (opcional)">
|
|
<Select v-model="form.project_id" :options="projects" option-label="name" option-value="id" show-clear placeholder="Gasto de empresa" class="w-full" />
|
|
</FormField>
|
|
<FormField label="Fecha" required>
|
|
<InputText v-model="form.expense_date" type="date" class="w-full" />
|
|
</FormField>
|
|
<FormField label="Concepto presupuesto" class="span-2">
|
|
<BudgetItemPicker v-if="form.project_id" v-model="form.budget_item_id" :project-id="form.project_id" />
|
|
<InputText v-else disabled placeholder="Seleccione una obra para ligar concepto" class="w-full" />
|
|
</FormField>
|
|
<FormField label="Descripción" class="span-2" required>
|
|
<Textarea v-model="form.description" class="w-full" rows="2" auto-resize />
|
|
</FormField>
|
|
<FormField label="Proveedor">
|
|
<InputText v-model="form.supplier_name" class="w-full" />
|
|
</FormField>
|
|
<FormField label="RFC proveedor">
|
|
<InputText v-model="form.supplier_rfc" class="w-full identifier-input" />
|
|
</FormField>
|
|
<FormField label="Cantidad">
|
|
<InputNumber v-model="form.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
|
|
</FormField>
|
|
<FormField label="Unidad">
|
|
<InputText v-model="form.unit" class="w-full" />
|
|
</FormField>
|
|
<FormField label="Precio unitario">
|
|
<InputNumber v-model="form.unit_price" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
|
</FormField>
|
|
<FormField label="Subtotal">
|
|
<InputNumber v-model="form.subtotal" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
|
</FormField>
|
|
<FormField label="IVA incluido en total">
|
|
<Checkbox v-model="form.tax_included" binary />
|
|
</FormField>
|
|
<FormField label="Total con IVA">
|
|
<InputNumber v-model="form.total" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
|
|
</FormField>
|
|
<FormField label="Comprobante PDF" class="span-2">
|
|
<input type="file" accept="application/pdf" @change="onFile" />
|
|
</FormField>
|
|
</div>
|
|
<Message v-if="formError" severity="error" class="mt-3" :closable="false">{{ formError }}</Message>
|
|
<template #footer>
|
|
<Button label="Cancelar" text @click="showForm = false" />
|
|
<Button label="Guardar" icon="pi pi-check" @click="save" />
|
|
</template>
|
|
</Dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import FormField from "~/components/padron/FormField.vue";
|
|
import BudgetItemPicker from "~/components/BudgetItemPicker.vue";
|
|
import { displayErrorMessage } from "~/composables/api-response";
|
|
|
|
type Expense = {
|
|
id: number;
|
|
expense_date: string;
|
|
description: string;
|
|
supplier_name: string;
|
|
budget_item_code?: string;
|
|
subtotal: number;
|
|
iva_amount: number;
|
|
total: number;
|
|
source: string;
|
|
project_id?: number | null;
|
|
};
|
|
|
|
const desk = useDesktop();
|
|
const { api } = useApi();
|
|
const toast = useToast();
|
|
const route = useRoute();
|
|
const perms = usePermissions();
|
|
|
|
const tableQ = ref("");
|
|
const expenses = ref<Expense[]>([]);
|
|
const companies = ref<{ id: number; name: string }[]>([]);
|
|
const projects = ref<{ id: number; name: string }[]>([]);
|
|
const showForm = ref(false);
|
|
const formError = ref("");
|
|
const budgetWarning = ref<{ level: string; message: string } | null>(null);
|
|
const receiptFile = ref<File | null>(null);
|
|
const canCreateExpense = computed(() => perms.can("expenses.create"));
|
|
|
|
const form = reactive({
|
|
company_id: null as number | null,
|
|
project_id: null as number | null,
|
|
expense_date: new Date().toISOString().slice(0, 10),
|
|
budget_item_id: null as number | null,
|
|
description: "",
|
|
supplier_name: "",
|
|
supplier_rfc: "",
|
|
quantity: 0,
|
|
unit: "",
|
|
unit_price: 0,
|
|
subtotal: 0,
|
|
total: 0,
|
|
tax_included: false,
|
|
});
|
|
|
|
const projectId = computed(() => Number(route.query.project_id || 0) || null);
|
|
const projectLabel = computed(() => {
|
|
const p = projects.value.find((x) => x.id === projectId.value);
|
|
return p?.name || "Todas las obras";
|
|
});
|
|
const listed = computed(() => {
|
|
const q = tableQ.value.toLowerCase();
|
|
return expenses.value.filter((e) =>
|
|
!q || [e.description, e.supplier_name, e.budget_item_code].join(" ").toLowerCase().includes(q),
|
|
);
|
|
});
|
|
const totalSubtotal = computed(() => listed.value.reduce((s, e) => s + Number(e.subtotal || 0), 0));
|
|
|
|
function money(v: number) {
|
|
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
|
|
}
|
|
|
|
function onFile(e: Event) {
|
|
const input = e.target as HTMLInputElement;
|
|
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() {
|
|
const [cat, pr] = await Promise.all([
|
|
api<{ companies?: { id: number; name: string }[] }>("/v1/catalogs"),
|
|
api<{ projects?: { id: number; name: string }[] }>("/v1/projects"),
|
|
]);
|
|
companies.value = cat.companies ?? [];
|
|
projects.value = pr.projects ?? [];
|
|
if (!form.company_id && companies.value[0]) form.company_id = companies.value[0].id;
|
|
}
|
|
|
|
async function loadExpenses() {
|
|
const qs = new URLSearchParams();
|
|
if (projectId.value) qs.set("project_id", String(projectId.value));
|
|
const r = await api<{ data?: { expenses?: Expense[] }; message?: string }>(`/v1/expenses?${qs}`);
|
|
expenses.value = (r.data?.expenses ?? []) as Expense[];
|
|
}
|
|
|
|
async function save() {
|
|
formError.value = "";
|
|
if (!form.company_id || !form.description.trim()) {
|
|
formError.value = "Empresa y descripción son obligatorios";
|
|
return;
|
|
}
|
|
try {
|
|
const r = await api<{ data?: { expense?: { id: number }; budget_warning?: { level: string; message: string } }; message?: string }>(
|
|
"/v1/expenses",
|
|
{
|
|
method: "POST",
|
|
json: { ...form, project_id: form.project_id || null },
|
|
},
|
|
);
|
|
const expenseId = r.data?.expense?.id;
|
|
if (expenseId && receiptFile.value) {
|
|
const fd = new FormData();
|
|
fd.append("file", receiptFile.value);
|
|
await fetch(`/v1/expenses/${expenseId}/attachments`, { method: "POST", body: fd, credentials: "include" });
|
|
}
|
|
budgetWarning.value = r.data?.budget_warning ?? null;
|
|
toast.add({ severity: "success", summary: r.message || "Gasto registrado", life: 2500 });
|
|
showForm.value = false;
|
|
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) {
|
|
formError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadCatalogs();
|
|
syncChrome();
|
|
await loadExpenses();
|
|
if (route.query.nuevo) openNew();
|
|
});
|
|
|
|
watch(projectId, () => {
|
|
loadExpenses();
|
|
syncChrome();
|
|
});
|
|
|
|
watch(
|
|
() => String(route.query.nuevo || ""),
|
|
(nuevo) => {
|
|
if (nuevo) openNew();
|
|
},
|
|
);
|
|
</script>
|