mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 21:43:17 +00:00
720 lines
27 KiB
Vue
720 lines
27 KiB
Vue
<template>
|
|
<div class="almacen-page">
|
|
<DeskTableBar v-model="tableQ" :placeholder="isGlobalView ? 'Buscar material o almacén' : 'Buscar material'">
|
|
<Chip :label="currentLabel" icon="pi pi-box" />
|
|
<Chip :label="`${visibleStock.length} líneas`" icon="pi pi-list" />
|
|
<Chip :label="money(stockValue)" icon="pi pi-wallet" />
|
|
</DeskTableBar>
|
|
|
|
<TabView>
|
|
<TabPanel :header="isGlobalView ? 'Consolidado' : 'Stock'">
|
|
<DataTable
|
|
:value="visibleStock"
|
|
data-key="row_key"
|
|
paginator
|
|
:rows="25"
|
|
scrollable
|
|
scroll-height="flex"
|
|
empty-message="No hay existencias en este almacén."
|
|
>
|
|
<Column v-if="isGlobalView && stockMode === 'detail'" field="warehouse_label" header="Almacén" />
|
|
<Column field="material_code" header="Clave" style="width: 8rem">
|
|
<template #body="{ data }"><span class="identifier">{{ data.material_code }}</span></template>
|
|
</Column>
|
|
<Column field="material_name" header="Material" />
|
|
<Column field="unit_label" header="Unidad" style="width: 8rem">
|
|
<template #body="{ data }">{{ data.unit_label || data.unit || "—" }}</template>
|
|
</Column>
|
|
<Column field="qty_on_hand" header="Cantidad" style="width: 8rem" />
|
|
<Column v-if="isGlobalView && stockMode === 'consolidated'" field="warehouse_count" header="Almacenes" style="width: 8rem" />
|
|
<Column field="stock_value" header="Valor" style="width: 9rem">
|
|
<template #body="{ data }">{{ money(data.stock_value) }}</template>
|
|
</Column>
|
|
</DataTable>
|
|
</TabPanel>
|
|
<TabPanel v-if="isGlobalView" header="Por almacén">
|
|
<DataTable
|
|
:value="filteredDetail"
|
|
data-key="row_key"
|
|
paginator
|
|
:rows="25"
|
|
scrollable
|
|
scroll-height="flex"
|
|
empty-message="No hay existencias en almacenes abiertos."
|
|
>
|
|
<Column field="warehouse_label" header="Almacén" />
|
|
<Column field="material_code" header="Clave" style="width: 8rem">
|
|
<template #body="{ data }"><span class="identifier">{{ data.material_code }}</span></template>
|
|
</Column>
|
|
<Column field="material_name" header="Material" />
|
|
<Column field="unit_label" header="Unidad" style="width: 8rem">
|
|
<template #body="{ data }">{{ data.unit_label || data.unit || "—" }}</template>
|
|
</Column>
|
|
<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="filteredMovements" data-key="id" paginator :rows="25" scrollable scroll-height="flex">
|
|
<Column field="movement_date" header="Fecha" style="width: 7rem" />
|
|
<Column v-if="isGlobalView" field="warehouse_label" header="Almacén" />
|
|
<Column header="Tipo" style="width: 9rem">
|
|
<template #body="{ data }">{{ movementLabel(data.movement_type) }}</template>
|
|
</Column>
|
|
<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="showOpen" header="Abrir almacén de obra" modal :style="{ width: '520px' }">
|
|
<p class="muted mb-3">
|
|
No todas las obras tienen almacén. Elija el proyecto para abrirlo. El almacén global permanece disponible.
|
|
</p>
|
|
<FormField label="Proyecto" required>
|
|
<Select
|
|
v-model="openProjectId"
|
|
:options="projectsWithoutWarehouse"
|
|
option-label="label"
|
|
option-value="id"
|
|
filter
|
|
placeholder="Seleccione una obra"
|
|
class="w-full"
|
|
/>
|
|
</FormField>
|
|
<Message v-if="!projectsWithoutWarehouse.length" severity="info" class="mt-3" :closable="false">
|
|
Todas las obras activas ya tienen almacén abierto.
|
|
</Message>
|
|
<template #footer>
|
|
<Button label="Cancelar" text @click="showOpen = false" />
|
|
<Button label="Abrir almacén" icon="pi pi-folder-open" :disabled="!openProjectId" @click="openWarehouse" />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<Dialog v-model:visible="showClose" header="Cerrar almacén de obra" modal :style="{ width: '560px' }">
|
|
<p class="muted mb-3">
|
|
El almacén global no se cierra. Al terminar la obra puede cerrar este almacén y devolver el stock restante.
|
|
</p>
|
|
<Message :closable="false" class="mb-3">{{ selectedWarehouse?.label }}</Message>
|
|
<FormField label="Notas de cierre">
|
|
<Textarea v-model="closeNotes" class="w-full" rows="2" auto-resize />
|
|
</FormField>
|
|
<div class="flex align-items-center gap-2 mt-3">
|
|
<Checkbox v-model="transferRemaining" binary input-id="xfer-remaining" />
|
|
<label for="xfer-remaining">Traspasar stock restante al almacén global</label>
|
|
</div>
|
|
<template #footer>
|
|
<Button label="Cancelar" text @click="showClose = false" />
|
|
<Button label="Cerrar almacén" icon="pi pi-lock" severity="danger" @click="closeWarehouse" />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<Dialog v-model:visible="showEntry" header="Entrada de material" modal :style="{ width: '680px' }">
|
|
<div class="form-grid form-grid-compact">
|
|
<FormField label="Almacén destino" required>
|
|
<Select v-model="entryForm.warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
|
|
</FormField>
|
|
<FormField label="Fecha">
|
|
<DatePicker v-model="entryDate" date-format="dd/mm/yy" show-icon class="w-full" />
|
|
</FormField>
|
|
<FormField label="Material" required class="span-2">
|
|
<div class="flex gap-2 align-items-center">
|
|
<Select
|
|
v-model="entryForm.material_id"
|
|
:options="materialSelectOptions"
|
|
option-label="label"
|
|
option-value="id"
|
|
filter
|
|
placeholder="Seleccione del catálogo"
|
|
class="w-full"
|
|
/>
|
|
<Button icon="pi pi-plus" outlined aria-label="Catálogo" title="Catálogo de materiales" @click="showCatalog = true" />
|
|
</div>
|
|
</FormField>
|
|
<FormField label="Unidad">
|
|
<InputText :model-value="selectedEntryMaterial?.unit_label || selectedEntryMaterial?.unit || ''" class="w-full" readonly />
|
|
</FormField>
|
|
<FormField label="Cantidad" required>
|
|
<InputNumber v-model="entryForm.quantity" :min="0" :max-fraction-digits="4" 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>
|
|
<FormField v-if="selectedEntrySuppliers" label="Proveedores del material" class="span-2">
|
|
<small class="muted">{{ selectedEntrySuppliers }}</small>
|
|
</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 de material" modal :style="{ width: '720px' }">
|
|
<div class="form-grid form-grid-compact">
|
|
<FormField label="Almacén" required>
|
|
<Select v-model="exitForm.warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
|
|
</FormField>
|
|
<FormField label="Material" required>
|
|
<Select
|
|
v-model="exitForm.material_id"
|
|
:options="stockMaterialOptions(exitForm.warehouse_id)"
|
|
option-label="label"
|
|
option-value="id"
|
|
filter
|
|
class="w-full"
|
|
/>
|
|
</FormField>
|
|
<FormField label="Cantidad" required>
|
|
<InputNumber v-model="exitForm.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
|
|
</FormField>
|
|
<FormField label="Concepto presupuesto" class="span-2">
|
|
<BudgetItemPicker v-if="exitProjectId" v-model="exitForm.budget_item_id" :project-id="exitProjectId" />
|
|
<InputText v-else disabled placeholder="Solo aplica en almacén de obra" class="w-full" />
|
|
</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="Traspaso entre almacenes" modal :style="{ width: '680px' }">
|
|
<div class="form-grid form-grid-compact">
|
|
<FormField label="Desde" required>
|
|
<Select v-model="transferForm.from_warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
|
|
</FormField>
|
|
<FormField label="Hacia" required>
|
|
<Select v-model="transferForm.to_warehouse_id" :options="openWarehouses" option-label="label" option-value="id" class="w-full" />
|
|
</FormField>
|
|
<FormField label="Material" required>
|
|
<Select
|
|
v-model="transferForm.material_id"
|
|
:options="stockMaterialOptions(transferForm.from_warehouse_id)"
|
|
option-label="label"
|
|
option-value="id"
|
|
filter
|
|
class="w-full"
|
|
/>
|
|
</FormField>
|
|
<FormField label="Cantidad" required>
|
|
<InputNumber v-model="transferForm.quantity" :min="0" :max-fraction-digits="4" class="w-full" />
|
|
</FormField>
|
|
<FormField label="Notas" class="span-2">
|
|
<InputText v-model="transferForm.notes" 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>
|
|
|
|
<MaterialCatalogDialog v-model="showCatalog" @saved="onMaterialSaved" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import FormField from "~/components/padron/FormField.vue";
|
|
import BudgetItemPicker from "~/components/BudgetItemPicker.vue";
|
|
import MaterialCatalogDialog, { type Material } from "~/components/MaterialCatalogDialog.vue";
|
|
import { displayErrorMessage } from "~/composables/api-response";
|
|
|
|
type WarehouseRow = {
|
|
id: number;
|
|
kind: string;
|
|
status?: string;
|
|
project_id?: number;
|
|
project_code?: string;
|
|
project_name?: string;
|
|
label: string;
|
|
};
|
|
|
|
type StockRow = Record<string, unknown> & {
|
|
row_key?: string;
|
|
material_id?: number;
|
|
material_code?: string;
|
|
material_name?: string;
|
|
warehouse_id?: number;
|
|
warehouse_label?: string;
|
|
qty_on_hand?: number;
|
|
stock_value?: number;
|
|
unit?: string;
|
|
unit_label?: string;
|
|
warehouse_count?: number;
|
|
};
|
|
|
|
type MovementRow = Record<string, unknown>;
|
|
type ProjectOption = { id: number; name: string; code?: string; status?: string; label: string };
|
|
|
|
const desk = useDesktop();
|
|
const { api } = useApi();
|
|
const toast = useToast();
|
|
const perms = usePermissions();
|
|
|
|
const tableQ = ref("");
|
|
const warehouses = ref<WarehouseRow[]>([]);
|
|
const projects = ref<ProjectOption[]>([]);
|
|
const materials = ref<Material[]>([]);
|
|
const stockRows = ref<StockRow[]>([]);
|
|
const consolidated = ref<StockRow[]>([]);
|
|
const movements = ref<MovementRow[]>([]);
|
|
const showEntry = ref(false);
|
|
const showExit = ref(false);
|
|
const showTransfer = ref(false);
|
|
const showOpen = ref(false);
|
|
const showClose = ref(false);
|
|
const showCatalog = ref(false);
|
|
const exitWarning = ref("");
|
|
const openProjectId = ref<number | null>(null);
|
|
const closeNotes = ref("");
|
|
const transferRemaining = ref(true);
|
|
|
|
const entryForm = reactive({
|
|
warehouse_id: 0,
|
|
material_id: 0,
|
|
quantity: 0,
|
|
unit_cost: 0,
|
|
movement_date: new Date().toISOString().slice(0, 10),
|
|
});
|
|
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,
|
|
notes: "",
|
|
});
|
|
|
|
const canUpdate = computed(() => perms.can("warehouse.update"));
|
|
const canCreate = computed(() => perms.can("warehouse.create"));
|
|
const openWarehouses = computed(() => warehouses.value.filter((w) => w.status !== "closed"));
|
|
const projectWarehouses = computed(() => openWarehouses.value.filter((w) => w.kind === "project"));
|
|
const centralWarehouse = computed(() => warehouses.value.find((w) => w.kind === "central") ?? null);
|
|
const isGlobalView = computed(() => !desk.treeKey.value || desk.treeKey.value === "global");
|
|
const selectedWarehouse = computed(() => {
|
|
if (isGlobalView.value) return centralWarehouse.value;
|
|
if (desk.treeKey.value.startsWith("w:")) {
|
|
const id = Number(desk.treeKey.value.slice(2));
|
|
return warehouses.value.find((w) => w.id === id) ?? null;
|
|
}
|
|
return null;
|
|
});
|
|
const currentLabel = computed(() =>
|
|
isGlobalView.value ? "Almacén global" : (selectedWarehouse.value?.label || "Almacén"),
|
|
);
|
|
const stockMode = computed(() => (isGlobalView.value ? "consolidated" : "detail"));
|
|
const exitProjectId = computed(() =>
|
|
projectWarehouses.value.find((w) => w.id === exitForm.warehouse_id)?.project_id ?? 0,
|
|
);
|
|
|
|
const projectsWithoutWarehouse = computed(() => {
|
|
const openIds = new Set(projectWarehouses.value.map((w) => w.project_id));
|
|
return projects.value.filter((p) => !openIds.has(p.id));
|
|
});
|
|
|
|
const materialSelectOptions = computed(() =>
|
|
materials.value.map((m) => ({
|
|
id: m.id,
|
|
label: `${m.code} — ${m.name}`,
|
|
})),
|
|
);
|
|
|
|
const selectedEntryMaterial = computed(() =>
|
|
materials.value.find((m) => m.id === entryForm.material_id) ?? null,
|
|
);
|
|
const selectedEntrySuppliers = computed(() =>
|
|
(selectedEntryMaterial.value?.suppliers ?? []).map((s) => s.name).join(", "),
|
|
);
|
|
|
|
const visibleStock = computed(() => {
|
|
const rows = isGlobalView.value ? consolidated.value : stockRows.value;
|
|
return filterStock(rows);
|
|
});
|
|
const filteredDetail = computed(() => filterStock(stockRows.value));
|
|
const stockValue = computed(() =>
|
|
visibleStock.value.reduce((s, r) => s + Number(r.stock_value || 0), 0),
|
|
);
|
|
const filteredMovements = computed(() => {
|
|
const q = tableQ.value.toLowerCase();
|
|
return movements.value.filter((r) =>
|
|
!q || [r.material_name, r.material_code, r.warehouse_label, r.destination_note].join(" ").toLowerCase().includes(q),
|
|
);
|
|
});
|
|
|
|
function filterStock(rows: StockRow[]) {
|
|
const q = tableQ.value.toLowerCase();
|
|
return rows.filter((r) =>
|
|
!q || [r.material_name, r.material_code, r.warehouse_label, r.project_code].join(" ").toLowerCase().includes(q),
|
|
);
|
|
}
|
|
|
|
function stockMaterialOptions(warehouseId: number) {
|
|
const seen = new Map<number, string>();
|
|
for (const row of stockRows.value) {
|
|
if (warehouseId && Number(row.warehouse_id) !== warehouseId && row.warehouse_id != null) continue;
|
|
const id = Number(row.material_id);
|
|
if (!id || seen.has(id)) continue;
|
|
seen.set(id, `${row.material_code} — ${row.material_name}`);
|
|
}
|
|
if (!seen.size) {
|
|
for (const m of materials.value) seen.set(m.id, `${m.code} — ${m.name}`);
|
|
}
|
|
return [...seen.entries()].map(([id, label]) => ({ id, label }));
|
|
}
|
|
|
|
function money(v: number) {
|
|
return new Intl.NumberFormat("es-MX", { style: "currency", currency: "MXN" }).format(Number(v || 0));
|
|
}
|
|
|
|
function movementLabel(type: unknown) {
|
|
switch (String(type || "")) {
|
|
case "entry": return "Entrada";
|
|
case "exit": return "Salida";
|
|
case "transfer_out": return "Traspaso salida";
|
|
case "transfer_in": return "Traspaso entrada";
|
|
case "adjustment": return "Ajuste";
|
|
default: return String(type || "—");
|
|
}
|
|
}
|
|
|
|
function warehouseLabel(w: Record<string, unknown>) {
|
|
if (w.kind === "central") return "Almacén global";
|
|
const code = String(w.project_code || "");
|
|
const name = String(w.project_name || "Obra");
|
|
return code ? `${code} — ${name}` : name;
|
|
}
|
|
|
|
function withRowKeys(rows: StockRow[], prefix: string) {
|
|
return rows.map((r, i) => ({
|
|
...r,
|
|
row_key: `${prefix}-${r.warehouse_id ?? "g"}-${r.material_id ?? i}`,
|
|
}));
|
|
}
|
|
|
|
const entryDate = computed({
|
|
get: () => {
|
|
const [y, m, d] = entryForm.movement_date.split("-").map(Number);
|
|
return y ? new Date(y, (m || 1) - 1, d || 1) : new Date();
|
|
},
|
|
set: (value: Date | null) => {
|
|
if (!value) return;
|
|
const y = value.getFullYear();
|
|
const m = String(value.getMonth() + 1).padStart(2, "0");
|
|
const d = String(value.getDate()).padStart(2, "0");
|
|
entryForm.movement_date = `${y}-${m}-${d}`;
|
|
},
|
|
});
|
|
|
|
async function loadProjects() {
|
|
const r = await api<{ projects?: Array<{ id: number; name: string; code?: string; status?: string }> }>(
|
|
"/v1/projects?status=activo,pausado",
|
|
);
|
|
projects.value = (r.projects ?? []).map((p) => ({
|
|
...p,
|
|
label: p.code ? `${p.code} — ${p.name}` : p.name,
|
|
}));
|
|
}
|
|
|
|
async function loadMaterials() {
|
|
try {
|
|
const r = await api<{ materials?: Material[] }>("/v1/warehouse-materials");
|
|
materials.value = r.materials ?? [];
|
|
} catch {
|
|
materials.value = [];
|
|
}
|
|
}
|
|
|
|
async function loadWarehouses() {
|
|
await api("/v1/warehouses/central/ensure", { method: "POST", json: {} }).catch(() => undefined);
|
|
const r = await api<{ warehouses?: Array<Record<string, unknown>> }>("/v1/warehouses");
|
|
const rows = r.warehouses ?? [];
|
|
warehouses.value = rows.map((w) => ({
|
|
id: Number(w.id),
|
|
kind: String(w.kind),
|
|
status: String(w.status ?? "open"),
|
|
project_id: w.project_id ? Number(w.project_id) : undefined,
|
|
project_code: String(w.project_code ?? ""),
|
|
project_name: String(w.project_name ?? ""),
|
|
label: warehouseLabel(w),
|
|
}));
|
|
}
|
|
|
|
function aggregateClient(rows: StockRow[]): StockRow[] {
|
|
const map = new Map<number, StockRow>();
|
|
for (const row of rows) {
|
|
const id = Number(row.material_id);
|
|
if (!id) continue;
|
|
const cur = map.get(id);
|
|
if (!cur) {
|
|
map.set(id, { ...row, warehouse_id: undefined, warehouse_count: 1 });
|
|
continue;
|
|
}
|
|
cur.qty_on_hand = Number(cur.qty_on_hand || 0) + Number(row.qty_on_hand || 0);
|
|
cur.stock_value = Number(cur.stock_value || 0) + Number(row.stock_value || 0);
|
|
cur.warehouse_count = Number(cur.warehouse_count || 1) + 1;
|
|
}
|
|
return [...map.values()];
|
|
}
|
|
|
|
async function loadStock() {
|
|
if (isGlobalView.value) {
|
|
const r = await api<{ stock?: StockRow[]; consolidated?: StockRow[] }>("/v1/warehouses/stock/global");
|
|
const detail = r.stock ?? [];
|
|
stockRows.value = withRowKeys(detail, "d");
|
|
const cons = r.consolidated ?? [];
|
|
consolidated.value = withRowKeys(cons.length ? cons : aggregateClient(detail), "c");
|
|
return;
|
|
}
|
|
const id = selectedWarehouse.value?.id;
|
|
if (!id) {
|
|
stockRows.value = [];
|
|
consolidated.value = [];
|
|
return;
|
|
}
|
|
const r = await api<{ stock?: StockRow[] }>(`/v1/warehouses/${id}/stock`);
|
|
stockRows.value = withRowKeys(r.stock ?? [], "w");
|
|
consolidated.value = [];
|
|
}
|
|
|
|
async function loadMovements() {
|
|
if (isGlobalView.value) {
|
|
const r = await api<{ movements?: MovementRow[] }>("/v1/warehouses/movements/global");
|
|
movements.value = r.movements ?? [];
|
|
return;
|
|
}
|
|
const id = selectedWarehouse.value?.id;
|
|
if (!id) {
|
|
movements.value = [];
|
|
return;
|
|
}
|
|
const r = await api<{ movements?: MovementRow[] }>(`/v1/warehouses/${id}/movements`);
|
|
movements.value = r.movements ?? [];
|
|
}
|
|
|
|
function defaultWarehouseId() {
|
|
return selectedWarehouse.value?.id || centralWarehouse.value?.id || 0;
|
|
}
|
|
|
|
function openEntry() {
|
|
entryForm.warehouse_id = defaultWarehouseId();
|
|
entryForm.material_id = materials.value[0]?.id ?? 0;
|
|
entryForm.quantity = 0;
|
|
entryForm.unit_cost = 0;
|
|
showEntry.value = true;
|
|
}
|
|
|
|
function openExitDlg() {
|
|
exitForm.warehouse_id = defaultWarehouseId();
|
|
exitForm.material_id = 0;
|
|
exitForm.quantity = 0;
|
|
exitForm.budget_item_id = null;
|
|
exitForm.destination_note = "";
|
|
exitWarning.value = "";
|
|
showExit.value = true;
|
|
}
|
|
|
|
function openTransferDlg() {
|
|
transferForm.from_warehouse_id = defaultWarehouseId();
|
|
transferForm.to_warehouse_id = isGlobalView.value
|
|
? (projectWarehouses.value[0]?.id ?? 0)
|
|
: (centralWarehouse.value?.id ?? 0);
|
|
transferForm.material_id = 0;
|
|
transferForm.quantity = 0;
|
|
transferForm.notes = "";
|
|
showTransfer.value = true;
|
|
}
|
|
|
|
async function saveEntry() {
|
|
if (!entryForm.warehouse_id || !entryForm.material_id || entryForm.quantity <= 0) {
|
|
toast.add({ severity: "warn", summary: "Seleccione almacén, material y una cantidad mayor a cero", life: 3000 });
|
|
return;
|
|
}
|
|
try {
|
|
const r = await api<{ message?: string }>(`/v1/warehouses/${entryForm.warehouse_id}/entries`, {
|
|
method: "POST",
|
|
json: {
|
|
warehouse_id: entryForm.warehouse_id,
|
|
material_id: entryForm.material_id,
|
|
quantity: entryForm.quantity,
|
|
unit_cost: entryForm.unit_cost,
|
|
movement_date: entryForm.movement_date,
|
|
},
|
|
});
|
|
toast.add({ severity: "success", summary: r.message || "Entrada registrada", life: 2000 });
|
|
showEntry.value = false;
|
|
await refresh();
|
|
} catch (e) {
|
|
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
|
}
|
|
}
|
|
|
|
async function saveExit() {
|
|
try {
|
|
const r = await api<{ budget_warning?: { message: string }; message?: string }>(
|
|
`/v1/warehouses/${exitForm.warehouse_id}/exits`,
|
|
{ method: "POST", json: exitForm },
|
|
);
|
|
exitWarning.value = r.budget_warning?.message ?? "";
|
|
toast.add({ severity: "success", summary: r.message || "Salida registrada", life: 2000 });
|
|
if (!exitWarning.value) showExit.value = false;
|
|
await refresh();
|
|
} 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 || "Traspaso completado", life: 2000 });
|
|
showTransfer.value = false;
|
|
await refresh();
|
|
} catch (e) {
|
|
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
|
}
|
|
}
|
|
|
|
async function openWarehouse() {
|
|
if (!openProjectId.value) return;
|
|
try {
|
|
const r = await api<{ message?: string }>(`/v1/projects/${openProjectId.value}/warehouse/open`, {
|
|
method: "POST",
|
|
json: {},
|
|
});
|
|
toast.add({ severity: "success", summary: r.message || "Almacén abierto", life: 2200 });
|
|
showOpen.value = false;
|
|
const projectId = openProjectId.value;
|
|
openProjectId.value = null;
|
|
await loadWarehouses();
|
|
const opened = warehouses.value.find((w) => w.project_id === projectId);
|
|
desk.treeKey.value = opened ? `w:${opened.id}` : "global";
|
|
await refresh();
|
|
syncChrome();
|
|
} catch (e) {
|
|
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
|
}
|
|
}
|
|
|
|
async function closeWarehouse() {
|
|
const projectId = selectedWarehouse.value?.project_id;
|
|
if (!projectId) return;
|
|
try {
|
|
const r = await api<{ message?: string }>(`/v1/projects/${projectId}/warehouse/close`, {
|
|
method: "POST",
|
|
json: { close_notes: closeNotes.value, transfer_remaining: transferRemaining.value },
|
|
});
|
|
toast.add({ severity: "success", summary: r.message || "Almacén cerrado", life: 2200 });
|
|
showClose.value = false;
|
|
closeNotes.value = "";
|
|
transferRemaining.value = true;
|
|
desk.treeKey.value = "global";
|
|
await loadWarehouses();
|
|
await refresh();
|
|
syncChrome();
|
|
} catch (e) {
|
|
toast.add({ severity: "error", summary: displayErrorMessage((e as { data?: unknown }).data ?? e), life: 4000 });
|
|
}
|
|
}
|
|
|
|
async function onMaterialSaved(material: Material) {
|
|
await loadMaterials();
|
|
if (material?.id) entryForm.material_id = material.id;
|
|
}
|
|
|
|
async function refresh() {
|
|
await Promise.all([loadStock(), loadMovements(), loadMaterials()]);
|
|
syncChrome();
|
|
}
|
|
|
|
function syncChrome() {
|
|
const projectNodes = projectWarehouses.value.map((w) => ({
|
|
key: `w:${w.id}`,
|
|
label: w.project_name || w.label,
|
|
icon: "pi pi-building",
|
|
}));
|
|
desk.configure({
|
|
tab: "Almacén",
|
|
treeTitle: "Almacenes",
|
|
showTree: true,
|
|
showDetail: false,
|
|
treeKey: desk.treeKey.value || "global",
|
|
countLabel: `${visibleStock.value.length} líneas`,
|
|
tree: [
|
|
{ key: "global", label: "Almacén global", icon: "pi pi-box", data: { count: consolidated.value.length || stockRows.value.length } },
|
|
...projectNodes,
|
|
],
|
|
onTreeSelect: () => {
|
|
void refresh();
|
|
},
|
|
ribbon: [
|
|
{
|
|
key: "almacenes",
|
|
label: "Almacenes",
|
|
items: [
|
|
{
|
|
key: "open",
|
|
label: "Abrir almacén",
|
|
icon: "pi pi-folder-open",
|
|
disabled: !canUpdate.value,
|
|
command: () => {
|
|
openProjectId.value = projectsWithoutWarehouse.value[0]?.id ?? null;
|
|
showOpen.value = true;
|
|
},
|
|
},
|
|
{
|
|
key: "close",
|
|
label: "Cerrar almacén",
|
|
icon: "pi pi-lock",
|
|
disabled: !canUpdate.value || isGlobalView.value || selectedWarehouse.value?.kind !== "project",
|
|
title: isGlobalView.value
|
|
? "El almacén global no se cierra"
|
|
: "Cerrar el almacén de la obra seleccionada",
|
|
command: () => { showClose.value = true; },
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: "wh",
|
|
label: "Movimientos",
|
|
items: [
|
|
{ key: "entry", label: "Entrada", icon: "pi pi-download", disabled: !canCreate.value, command: openEntry },
|
|
{ key: "exit", label: "Salida", icon: "pi pi-upload", disabled: !canUpdate.value, command: openExitDlg },
|
|
{ key: "xfer", label: "Transferir", icon: "pi pi-arrow-right-arrow-left", disabled: !canUpdate.value, command: openTransferDlg },
|
|
],
|
|
},
|
|
{
|
|
key: "cat",
|
|
label: "Catálogo",
|
|
items: [
|
|
{ key: "materials", label: "Materiales", icon: "pi pi-book", command: () => { showCatalog.value = true; } },
|
|
{ key: "refresh", label: "Actualizar", icon: "pi pi-refresh", command: () => { void refresh(); } },
|
|
],
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
onMounted(async () => {
|
|
desk.treeKey.value = "global";
|
|
syncChrome();
|
|
await Promise.all([loadProjects(), loadWarehouses(), loadMaterials()]);
|
|
await refresh();
|
|
});
|
|
</script>
|