mirror of
https://origin.cursor.com/mrdevmx/panels.git
synced 2026-10-09 20:23:17 +00:00
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>
269 lines
8.7 KiB
Vue
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>
|