panels-origin/web-panel/pages/almacen.vue
Cursor Agent ab89727522
feat: gastos, almacén central/obra y control presupuestal
Implementa el módulo integral de costos según el plan acordado:

- Esquema Liquibase: expense_entries, almacén (central + obra), IVA,
  tenant_cost_settings, permisos IAM y RLS
- Funciones RPC core: gastos, almacén, control presupuestal, sync nómina,
  cierre de almacén al concluir proyecto
- API HTTP: expenses_http, warehouse_http, cost_control_http, iam_http
- Middleware requirePermission con matriz IAM
- UI web-panel: /gastos, /almacen, /control-presupuesto, /usuarios
- Componentes BudgetItemPicker, CostSemaphore y semáforos CSS
- Smoke tests extendidos para gastos/almacén/control

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-04 04:04:17 +00:00

235 lines
10 KiB
Vue

<template>
<div class="almacen-page">
<DeskTableBar v-model="tableQ" placeholder="Buscar material o obra">
<Chip label="Almacén global" icon="pi pi-box" />
<Chip :label="`${stockRows.length} líneas`" icon="pi pi-list" />
<Chip :label="money(stockValue)" icon="pi pi-wallet" />
</DeskTableBar>
<TabView>
<TabPanel header="Stock global">
<DataTable :value="filteredStock" data-key="material_id" paginator :rows="25" scrollable scroll-height="flex">
<Column field="project_code" header="Obra / Central" />
<Column field="material_code" header="Clave" style="width: 8rem" />
<Column field="material_name" header="Material" />
<Column field="qty_on_hand" header="Cantidad" style="width: 8rem" />
<Column field="stock_value" header="Valor" style="width: 9rem">
<template #body="{ data }">{{ money(data.stock_value) }}</template>
</Column>
</DataTable>
</TabPanel>
<TabPanel header="Kardex">
<DataTable :value="movements" data-key="id" paginator :rows="25" scrollable scroll-height="flex">
<Column field="movement_date" header="Fecha" style="width: 7rem" />
<Column field="movement_type" header="Tipo" style="width: 8rem" />
<Column field="material_name" header="Material" />
<Column field="quantity" header="Cant." style="width: 7rem" />
<Column field="total_cost" header="Importe" style="width: 9rem">
<template #body="{ data }">{{ money(data.total_cost) }}</template>
</Column>
<Column field="destination_note" header="Destino / nota" />
</DataTable>
</TabPanel>
</TabView>
<Dialog v-model:visible="showEntry" header="Entrada de material" modal :style="{ width: '640px' }">
<div class="form-grid form-grid-compact">
<FormField label="Almacén">
<Select v-model="entryForm.warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Código material">
<InputText v-model="entryForm.material_code" class="w-full identifier-input" />
</FormField>
<FormField label="Nombre material">
<InputText v-model="entryForm.material_name" class="w-full" />
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="entryForm.quantity" :min="0" class="w-full" />
</FormField>
<FormField label="Costo unitario">
<InputNumber v-model="entryForm.unit_cost" mode="currency" currency="MXN" locale="es-MX" class="w-full" />
</FormField>
</div>
<template #footer>
<Button label="Cancelar" text @click="showEntry = false" />
<Button label="Registrar entrada" icon="pi pi-check" @click="saveEntry" />
</template>
</Dialog>
<Dialog v-model:visible="showExit" header="Salida a obra / concepto" modal :style="{ width: '720px' }">
<div class="form-grid form-grid-compact">
<FormField label="Almacén obra">
<Select v-model="exitForm.warehouse_id" :options="projectWarehouses" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Material">
<Select v-model="exitForm.material_id" :options="materialOptions" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="exitForm.quantity" :min="0" class="w-full" />
</FormField>
<FormField label="Concepto presupuesto" class="span-2">
<BudgetItemPicker v-if="exitProjectId" v-model="exitForm.budget_item_id" :project-id="exitProjectId" />
</FormField>
<FormField label="Destino físico" class="span-2">
<InputText v-model="exitForm.destination_note" class="w-full" />
</FormField>
</div>
<Message v-if="exitWarning" severity="warn" :closable="false">{{ exitWarning }}</Message>
<template #footer>
<Button label="Cancelar" text @click="showExit = false" />
<Button label="Registrar salida" icon="pi pi-check" @click="saveExit" />
</template>
</Dialog>
<Dialog v-model:visible="showTransfer" header="Transferencia central ↔ obra" modal :style="{ width: '640px' }">
<div class="form-grid form-grid-compact">
<FormField label="Desde">
<Select v-model="transferForm.from_warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Hacia">
<Select v-model="transferForm.to_warehouse_id" :options="warehouses" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Material">
<Select v-model="transferForm.material_id" :options="materialOptions" option-label="label" option-value="id" class="w-full" />
</FormField>
<FormField label="Cantidad">
<InputNumber v-model="transferForm.quantity" :min="0" class="w-full" />
</FormField>
</div>
<template #footer>
<Button label="Cancelar" text @click="showTransfer = false" />
<Button label="Transferir" icon="pi pi-arrow-right-arrow-left" @click="saveTransfer" />
</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 WarehouseRow = { id: number; kind: string; project_id?: number; project_code?: string; label: string };
type StockRow = Record<string, unknown>;
type MovementRow = Record<string, unknown>;
const desk = useDesktop();
const { api } = useApi();
const toast = useToast();
const tableQ = ref("");
const warehouses = ref<WarehouseRow[]>([]);
const stockRows = ref<StockRow[]>([]);
const movements = ref<MovementRow[]>([]);
const materialOptions = ref<{ id: number; label: string }[]>([]);
const showEntry = ref(false);
const showExit = ref(false);
const showTransfer = ref(false);
const exitWarning = ref("");
const entryForm = reactive({ warehouse_id: 0, material_code: "", material_name: "", quantity: 0, unit_cost: 0 });
const exitForm = reactive({ warehouse_id: 0, material_id: 0, quantity: 0, budget_item_id: null as number | null, destination_note: "" });
const transferForm = reactive({ from_warehouse_id: 0, to_warehouse_id: 0, material_id: 0, quantity: 0 });
const projectWarehouses = computed(() => warehouses.value.filter((w) => w.kind === "project"));
const exitProjectId = computed(() => projectWarehouses.value.find((w) => w.id === exitForm.warehouse_id)?.project_id ?? 0);
const stockValue = computed(() => stockRows.value.reduce((s, r) => s + Number(r.stock_value || 0), 0));
const filteredStock = computed(() => {
const q = tableQ.value.toLowerCase();
return stockRows.value.filter((r) =>
!q || [r.material_name, r.material_code, r.project_code].join(" ").toLowerCase().includes(q),
);
});
function money(v: number) {
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
}
async function loadWarehouses() {
await api("/v1/warehouses/central/ensure", { method: "POST", json: {} });
const r = await api<{ data?: { warehouses?: Array<Record<string, unknown>> } }>("/v1/warehouses");
const rows = (r.data?.warehouses ?? []) as Array<Record<string, unknown>>;
warehouses.value = rows.map((w) => ({
id: Number(w.id),
kind: String(w.kind),
project_id: w.project_id ? Number(w.project_id) : undefined,
project_code: String(w.project_code ?? (w.kind === "central" ? "CENTRAL" : "")),
label: w.kind === "central" ? "Almacén central" : `${w.project_code} — ${w.project_name ?? "Obra"}`,
}));
}
async function loadStock() {
const r = await api<{ data?: { stock?: StockRow[] } }>("/v1/warehouses/stock/global");
stockRows.value = r.data?.stock ?? [];
const mats = new Map<number, string>();
for (const row of stockRows.value) {
mats.set(Number(row.material_id), `${row.material_code} — ${row.material_name}`);
}
materialOptions.value = [...mats.entries()].map(([id, label]) => ({ id, label }));
}
async function loadMovements() {
const wh = warehouses.value[0]?.id;
if (!wh) return;
const r = await api<{ data?: { movements?: MovementRow[] } }>(`/v1/warehouses/${wh}/movements`);
movements.value = r.data?.movements ?? [];
}
async function saveEntry() {
try {
const r = await api<{ message?: string }>(`/v1/warehouses/${entryForm.warehouse_id}/entries`, {
method: "POST",
json: entryForm,
});
toast.add({ severity: "success", summary: r.message || "Entrada registrada", life: 2000 });
showEntry.value = false;
await loadStock();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
}
async function saveExit() {
try {
const r = await api<{ data?: { budget_warning?: { message: string } }; message?: string }>(
`/v1/warehouses/${exitForm.warehouse_id}/exits`,
{ method: "POST", json: exitForm },
);
exitWarning.value = r.data?.budget_warning?.message ?? "";
toast.add({ severity: "success", summary: r.message || "Salida registrada", life: 2000 });
if (!exitWarning.value) showExit.value = false;
await loadStock();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
}
async function saveTransfer() {
try {
const r = await api<{ message?: string }>("/v1/warehouse-transfers", { method: "POST", json: transferForm });
toast.add({ severity: "success", summary: r.message || "Transferencia completada", life: 2000 });
showTransfer.value = false;
await loadStock();
} catch (e) {
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
}
}
onMounted(async () => {
desk.configure({
tab: "Almacén",
ribbon: [{
key: "wh",
label: "Movimientos",
items: [
{ key: "entry", label: "Entrada", icon: "pi pi-download", command: () => { showEntry.value = true; } },
{ key: "exit", label: "Salida", icon: "pi pi-upload", command: () => { showExit.value = true; } },
{ key: "xfer", label: "Transferir", icon: "pi pi-arrow-right-arrow-left", command: () => { showTransfer.value = true; } },
],
}],
});
await loadWarehouses();
await loadStock();
await loadMovements();
});
</script>