panels-origin/web-panel/components/MaterialCatalogDialog.vue
Cursor Agent e528d217d3
feat: almacén global, abrir/cerrar por obra y catálogo de materiales
El árbol de Almacén deja de listar todas las obras. El almacén global
siempre está disponible y consolida el stock de los almacenes abiertos.
Las obras abren almacén a demanda y al cerrarlo pueden devolver el
remanente al global. Entradas y traspasos usan el catálogo de materiales
con unidades de medida y uno o varios proveedores.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
2026-09-08 20:41:18 +00:00

269 lines
8.7 KiB
Vue

<template>
<Dialog
v-model:visible="visible"
header="Catálogo de materiales"
modal
:style="{ width: '920px' }"
:breakpoints="{ '960px': '95vw' }"
>
<DeskTableBar v-model="query" placeholder="Buscar clave, material o proveedor">
<Chip :label="`${filtered.length} materiales`" icon="pi pi-box" />
<Button label="Nuevo material" icon="pi pi-plus" size="small" @click="openNew" />
</DeskTableBar>
<DataTable
:value="filtered"
data-key="id"
paginator
:rows="12"
scrollable
scroll-height="22rem"
empty-message="No hay materiales. Capture el catálogo para usarlo en entradas y traspasos."
@row-click="(e: { data: Material }) => openEdit(e.data)"
>
<Column field="code" header="Clave" style="width: 8rem">
<template #body="{ data }"><span class="identifier">{{ data.code }}</span></template>
</Column>
<Column field="name" header="Material" />
<Column field="unit_label" header="Unidad" style="width: 8rem">
<template #body="{ data }">{{ data.unit_label || data.unit || "—" }}</template>
</Column>
<Column header="Proveedores">
<template #body="{ data }">{{ supplierNames(data) }}</template>
</Column>
</DataTable>
<Dialog
v-model:visible="showForm"
:header="form.id ? 'Editar material' : 'Nuevo material'"
modal
:style="{ width: '640px' }"
>
<div class="form-grid form-grid-compact">
<FormField label="Clave" required>
<InputText v-model="form.code" class="w-full identifier-input" placeholder="CEM-001" />
</FormField>
<FormField label="Unidad" required>
<div class="flex gap-2 align-items-center">
<Select
v-model="form.unit_id"
:options="units"
option-label="label"
option-value="id"
placeholder="Catálogo de unidades"
filter
class="w-full"
/>
<Button icon="pi pi-plus" outlined aria-label="Nueva unidad" title="Nueva unidad" @click="showUnitForm = true" />
</div>
</FormField>
<FormField label="Nombre" required class="span-2">
<InputText v-model="form.name" class="w-full" placeholder="Cemento gris 50 kg" />
</FormField>
<FormField label="Proveedores" class="span-2">
<div class="flex gap-2 align-items-center">
<MultiSelect
v-model="form.supplier_ids"
:options="supplierOptions"
option-label="label"
option-value="id"
placeholder="Uno o varios proveedores"
filter
display="chip"
class="w-full"
/>
<Button icon="pi pi-plus" outlined aria-label="Nuevo proveedor" title="Nuevo proveedor" @click="showSupplierForm = true" />
</div>
</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" :loading="saving" @click="save" />
</template>
</Dialog>
<Dialog v-model:visible="showUnitForm" header="Nueva unidad" modal :style="{ width: '420px' }">
<div class="form-grid form-grid-compact">
<FormField label="Código" required>
<InputText v-model="unitForm.code" class="w-full identifier-input" placeholder="bulto" />
</FormField>
<FormField label="Nombre" required>
<InputText v-model="unitForm.label" class="w-full" placeholder="Bulto" />
</FormField>
</div>
<Message v-if="unitError" severity="error" class="mt-3" :closable="false">{{ unitError }}</Message>
<template #footer>
<Button label="Cancelar" text @click="showUnitForm = false" />
<Button label="Guardar" icon="pi pi-check" @click="saveUnit" />
</template>
</Dialog>
<SupplierFormDialog v-model="showSupplierForm" @saved="onSupplierSaved" />
</Dialog>
</template>
<script setup lang="ts">
import FormField from "~/components/padron/FormField.vue";
import SupplierFormDialog, { type Supplier } from "~/components/SupplierFormDialog.vue";
import { displayErrorMessage } from "~/composables/api-response";
export type Material = {
id: number;
code: string;
name: string;
unit?: string;
unit_id?: number | null;
unit_label?: string;
suppliers?: { id: number; name: string }[];
};
type MeasureUnit = { id: number; code: string; label: string };
const visible = defineModel<boolean>({ default: false });
const emit = defineEmits<{ saved: [material: Material] }>();
const { api } = useApi();
const toast = useToast();
const query = ref("");
const materials = ref<Material[]>([]);
const units = ref<MeasureUnit[]>([]);
const suppliers = ref<Supplier[]>([]);
const showForm = ref(false);
const showUnitForm = ref(false);
const showSupplierForm = ref(false);
const saving = ref(false);
const formError = ref("");
const unitError = ref("");
const unitForm = reactive({ code: "", label: "" });
const form = reactive({
id: 0,
code: "",
name: "",
unit_id: null as number | null,
supplier_ids: [] as number[],
});
const supplierOptions = computed(() =>
suppliers.value.map((s) => ({
id: s.id,
label: s.rfc ? `${s.name} · ${s.rfc}` : s.name,
})),
);
const filtered = computed(() => {
const q = query.value.trim().toLowerCase();
return materials.value.filter((m) =>
!q || [m.code, m.name, m.unit, m.unit_label, supplierNames(m)].join(" ").toLowerCase().includes(q),
);
});
function supplierNames(row: Material) {
return (row.suppliers ?? []).map((s) => s.name).join(", ") || "—";
}
function resetForm() {
form.id = 0;
form.code = "";
form.name = "";
form.unit_id = units.value[0]?.id ?? null;
form.supplier_ids = [];
formError.value = "";
}
function openNew() {
resetForm();
showForm.value = true;
}
function openEdit(row: Material) {
form.id = row.id;
form.code = row.code;
form.name = row.name;
form.unit_id = row.unit_id ?? null;
form.supplier_ids = (row.suppliers ?? []).map((s) => s.id);
formError.value = "";
showForm.value = true;
}
async function load() {
const [mats, un, sup] = await Promise.all([
api<{ materials?: Material[] }>("/v1/warehouse-materials"),
api<{ units?: MeasureUnit[] }>("/v1/measure-units"),
api<{ suppliers?: Supplier[] }>("/v1/suppliers?status=activo"),
]);
materials.value = mats.materials ?? [];
units.value = un.units ?? [];
suppliers.value = sup.suppliers ?? [];
}
async function save() {
if (!form.code.trim() || !form.name.trim()) {
formError.value = "Clave y nombre son obligatorios";
return;
}
if (!form.unit_id) {
formError.value = "Seleccione una unidad del catálogo";
return;
}
saving.value = true;
formError.value = "";
try {
const payload = {
code: form.code,
name: form.name,
unit_id: form.unit_id,
supplier_ids: form.supplier_ids,
};
const r = form.id
? await api<Material>(`/v1/warehouse-materials/${form.id}`, { method: "PATCH", json: payload })
: await api<Material>("/v1/warehouse-materials", { method: "POST", json: payload });
toast.add({ severity: "success", summary: "Material guardado", life: 2000 });
showForm.value = false;
await load();
const saved = materials.value.find((m) => m.id === Number(r.id)) ?? r;
emit("saved", saved);
} catch (e) {
formError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
} finally {
saving.value = false;
}
}
async function saveUnit() {
unitError.value = "";
if (!unitForm.code.trim()) {
unitError.value = "El código es obligatorio";
return;
}
try {
const r = await api<{ unit?: MeasureUnit }>("/v1/measure-units", {
method: "POST",
json: { code: unitForm.code, label: unitForm.label },
});
const created = r.unit;
if (created) {
units.value = [...units.value.filter((u) => u.id !== created.id), created]
.sort((a, b) => a.label.localeCompare(b.label, "es"));
form.unit_id = created.id;
}
unitForm.code = "";
unitForm.label = "";
showUnitForm.value = false;
} catch (e) {
unitError.value = displayErrorMessage((e as { data?: unknown }).data ?? e);
}
}
function onSupplierSaved(supplier: Supplier) {
suppliers.value = [...suppliers.value.filter((s) => s.id !== supplier.id), supplier]
.sort((a, b) => a.name.localeCompare(b.name, "es"));
if (!form.supplier_ids.includes(supplier.id)) form.supplier_ids = [...form.supplier_ids, supplier.id];
}
watch(visible, async (on) => {
if (on) await load();
});
defineExpose({ load, materials });
</script>