panels-origin/web-panel/pages/facturas.vue
Alberto Martinez 90bcd48f95 Facturas, nómina, dispersión e IMSS (#28)
<!-- 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>&nbsp;<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>&nbsp;</div>
2026-09-25 03:26:30 +00:00

138 lines
5.6 KiB
Vue

<template>
<div>
<DeskTableBar v-model="tableQ" placeholder="Proveedor, RFC, UUID o folio">
<SelectButton v-model="direction" :options="dirOpts" option-label="label" option-value="value" :allow-empty="false" @change="load" />
<InputNumber v-model="amountMin" placeholder="Monto mín" />
<InputNumber v-model="amountMax" placeholder="Monto máx" />
<Button label="Importar Excel" icon="pi pi-upload" @click="showImport = true" />
</DeskTableBar>
<DataTable :value="listed" data-key="id" paginator :rows="20" @row-dblclick="(e: { data: Invoice }) => open(e.data)">
<Column field="invoice_date" header="Fecha" />
<Column field="supplier_name" header="Proveedor" />
<Column field="supplier_rfc" header="RFC" />
<Column field="uuid" header="UUID" />
<Column field="folio" header="Folio" />
<Column field="total" header="Total">
<template #body="{ data }">{{ money(data.total) }}</template>
</Column>
<Column field="status" header="Estatus" />
</DataTable>
<Dialog v-model:visible="showImport" header="Importar facturas" modal :style="{ width: '28rem' }">
<Select v-model="companyId" :options="companies" option-label="name" option-value="id" placeholder="Empresa" class="w-full" />
<input class="mt-3" type="file" accept=".xlsx,.xls" @change="onFile" />
<template #footer>
<Button label="Cargar" icon="pi pi-check" :disabled="!file || !companyId" @click="upload" />
</template>
</Dialog>
<Dialog v-model:visible="showDetail" header="Clasificar factura" modal :style="{ width: '36rem' }">
<p v-if="detail">{{ detail.supplier_name }} · {{ detail.uuid || detail.folio }} · {{ detail.review_note }}</p>
<div v-for="line in lines" :key="line.id" class="mt-2">
<div>{{ line.description }} · {{ money(line.amount) }}</div>
<Select v-model="line.project_id" :options="projects" option-label="name" option-value="id" placeholder="Obra" class="w-full mt-1" />
<BudgetItemPicker v-if="line.project_id" v-model="line.budget_item_id" :project-id="line.project_id" class="mt-1" />
</div>
<template #footer>
<Button label="Clasificar" icon="pi pi-check" @click="classify" />
</template>
</Dialog>
</div>
</template>
<script setup lang="ts">
type Invoice = {
id: number;
invoice_date: string;
supplier_name: string;
supplier_rfc: string;
uuid: string;
folio: string;
total: number;
status: string;
review_note?: string;
};
type Line = { id: number; description: string; amount: number; project_id: number | null; budget_item_id: number | null };
const { api } = useApi();
const toast = useToast();
const tableQ = ref("");
const direction = ref("recibida");
const dirOpts = [{ label: "Recibidas", value: "recibida" }, { label: "Emitidas", value: "emitida" }];
const amountMin = ref<number | null>(null);
const amountMax = ref<number | null>(null);
const invoices = ref<Invoice[]>([]);
const companies = ref<{ id: number; name: string }[]>([]);
const projects = ref<{ id: number; name: string }[]>([]);
const showImport = ref(false);
const showDetail = ref(false);
const companyId = ref<number | null>(null);
const file = ref<File | null>(null);
const detail = ref<Invoice | null>(null);
const lines = ref<Line[]>([]);
const listed = computed(() => {
const q = tableQ.value.trim().toLowerCase();
return invoices.value.filter((i) => {
if (amountMin.value != null && Number(i.total) < amountMin.value) return false;
if (amountMax.value != null && Number(i.total) > amountMax.value) return false;
if (!q) return true;
return [i.supplier_name, i.supplier_rfc, i.uuid, i.folio].join(" ").toLowerCase().includes(q);
});
});
function money(n: number) {
return Number(n || 0).toLocaleString("es-MX", { style: "currency", currency: "MXN" });
}
async function load() {
const q = new URLSearchParams({ direction: direction.value });
const r = await api<{ invoices?: Invoice[]; data?: { invoices?: Invoice[] } }>(`/v1/invoices?${q}`);
invoices.value = r.invoices ?? r.data?.invoices ?? [];
}
function onFile(ev: Event) {
file.value = (ev.target as HTMLInputElement).files?.[0] ?? null;
}
async function upload() {
if (!file.value || !companyId.value) return;
const body = new FormData();
body.set("company_id", String(companyId.value));
body.set("file", file.value);
const r = await api<{ created?: number; review?: number; data?: { created?: number; review?: number } }>("/v1/invoices/import", { method: "POST", body });
const created = r.created ?? r.data?.created ?? 0;
const review = r.review ?? r.data?.review ?? 0;
toast.add({ severity: "success", summary: `${created} facturas, ${review} en revisión`, life: 2500 });
showImport.value = false;
await load();
}
async function open(row: Invoice) {
const r = await api<{ invoice?: Invoice; lines?: Line[]; data?: { invoice?: Invoice; lines?: Line[] } }>(`/v1/invoices/${row.id}`);
detail.value = r.invoice ?? r.data?.invoice ?? row;
lines.value = r.lines ?? r.data?.lines ?? [];
showDetail.value = true;
}
async function classify() {
if (!detail.value) return;
const line = lines.value[0];
await api(`/v1/invoices/${detail.value.id}/classify`, {
method: "POST",
json: { line_id: line?.id, project_id: line?.project_id, budget_item_id: line?.budget_item_id },
});
showDetail.value = false;
await load();
}
onMounted(async () => {
const [c, p] = await Promise.all([
api<{ companies?: { id: number; name: string }[] }>("/v1/companies"),
api<{ projects?: { id: number; name: string }[] }>("/v1/projects"),
]);
companies.value = c.companies ?? [];
projects.value = p.projects ?? [];
await load();
});
</script>