feat: add product catalogue and product-aware UI
- introduce product catalogue pages, management view entries and shared product composables\n- wire product selection into component/piece flows and machine skeleton requirements\n- display linked product metadata and documents across machine, component and piece views\n- generalize model type tooling to handle PRODUCT category
This commit is contained in:
@@ -48,6 +48,12 @@
|
||||
>
|
||||
Rattachée à {{ piece.parentComponentName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="displayProductName"
|
||||
class="badge badge-info badge-sm"
|
||||
>
|
||||
Produit : {{ displayProductName }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -113,6 +119,120 @@
|
||||
pieceData.prix ? `${pieceData.prix}€` : "Non défini"
|
||||
}}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="font-medium">Produit catalogue:</span>
|
||||
<div v-if="isEditMode" class="mt-2 space-y-2">
|
||||
<ProductSelect
|
||||
:model-value="pieceData.productId"
|
||||
placeholder="Associer un produit…"
|
||||
helper-text="Optionnel : reliez cette pièce à un produit catalogue."
|
||||
@update:modelValue="handleProductChange"
|
||||
/>
|
||||
<div
|
||||
v-if="selectedProduct"
|
||||
class="rounded-md border border-base-200 bg-base-100 p-3 text-xs space-y-1"
|
||||
>
|
||||
<p class="text-sm font-semibold text-base-content">
|
||||
{{ selectedProduct.name }}
|
||||
</p>
|
||||
<p
|
||||
v-for="info in productInfoRows"
|
||||
:key="info.label"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<span class="font-semibold">{{ info.label }} :</span>
|
||||
<span>{{ info.value }}</span>
|
||||
</p>
|
||||
<NuxtLink
|
||||
v-if="selectedProduct.id"
|
||||
:to="`/product/${selectedProduct.id}/edit`"
|
||||
class="link link-primary text-xs"
|
||||
>
|
||||
Ouvrir la fiche produit
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<p v-else class="text-xs text-base-content/60">
|
||||
Aucun produit associé.
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
<div v-if="displayProduct" class="space-y-1">
|
||||
<p class="font-medium text-base-content">
|
||||
{{ displayProductName || 'Produit catalogue' }}
|
||||
</p>
|
||||
<p
|
||||
v-for="info in productInfoRows"
|
||||
:key="info.label"
|
||||
class="text-xs text-base-content/70"
|
||||
>
|
||||
<span class="font-semibold">{{ info.label }} :</span>
|
||||
<span class="ml-1">{{ info.value }}</span>
|
||||
</p>
|
||||
<div
|
||||
v-if="productDocuments.length"
|
||||
class="mt-2 space-y-2 rounded-md border border-base-200 bg-base-100 p-3 text-xs"
|
||||
>
|
||||
<h5 class="font-medium text-base-content">Documents du produit</h5>
|
||||
<div
|
||||
v-for="document in productDocuments"
|
||||
:key="document.id || document.path || document.name"
|
||||
class="flex items-center justify-between gap-3 rounded border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center h-10 w-8"
|
||||
>
|
||||
<img
|
||||
v-if="isImageDocument(document) && document.path"
|
||||
:src="document.path"
|
||||
class="h-full w-full object-cover"
|
||||
:alt="`Aperçu de ${document.name}`"
|
||||
>
|
||||
<iframe
|
||||
v-else-if="shouldInlinePdf(document)"
|
||||
:src="documentPreviewSrc(document)"
|
||||
class="h-full w-full border-0 bg-white"
|
||||
title="Aperçu PDF"
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
:is="documentIcon(document).component"
|
||||
class="h-5 w-5"
|
||||
:class="documentIcon(document).colorClass"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-medium text-base-content">
|
||||
{{ document.name }}
|
||||
</div>
|
||||
<div class="text-xs text-base-content/70">
|
||||
{{ document.mimeType || 'Inconnu' }} • {{ formatSize(document.size) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
:disabled="!canPreviewDocument(document)"
|
||||
:title="canPreviewDocument(document) ? 'Consulter le document' : 'Aucun aperçu disponible pour ce type'"
|
||||
@click="openPreview(document)"
|
||||
>
|
||||
Consulter
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-xs" @click="downloadDocument(document)">
|
||||
Télécharger
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="font-medium">
|
||||
Non défini
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Champs personnalisés de la pièce -->
|
||||
@@ -364,6 +484,7 @@
|
||||
<script setup>
|
||||
import { reactive, onMounted, watch, ref, computed } from "vue";
|
||||
import ConstructeurSelect from "./ConstructeurSelect.vue";
|
||||
import ProductSelect from "~/components/ProductSelect.vue";
|
||||
import { useConstructeurs } from "~/composables/useConstructeurs";
|
||||
import { useCustomFields } from "~/composables/useCustomFields";
|
||||
import { useToast } from "~/composables/useToast";
|
||||
@@ -373,6 +494,7 @@ import { canPreviewDocument, isImageDocument, isPdfDocument } from "~/utils/docu
|
||||
import DocumentUpload from "~/components/DocumentUpload.vue";
|
||||
import DocumentPreviewModal from "~/components/DocumentPreviewModal.vue";
|
||||
import IconLucidePackage from "~icons/lucide/package";
|
||||
import { useProducts } from "~/composables/useProducts";
|
||||
import {
|
||||
formatConstructeurContact as formatConstructeurContactSummary,
|
||||
resolveConstructeurs,
|
||||
@@ -401,6 +523,7 @@ const pieceData = reactive({
|
||||
name: props.piece.name || "",
|
||||
reference: props.piece.reference || "",
|
||||
prix: props.piece.prix || "",
|
||||
productId: props.piece.product?.id || props.piece.productId || null,
|
||||
});
|
||||
|
||||
const selectedFiles = ref([]);
|
||||
@@ -779,6 +902,7 @@ const candidateCustomFields = computed(() => {
|
||||
});
|
||||
|
||||
const { constructeurs } = useConstructeurs();
|
||||
const { products, loadProducts, getProduct } = useProducts();
|
||||
|
||||
const pieceConstructeurIds = computed(() =>
|
||||
uniqueConstructeurIds(
|
||||
@@ -800,6 +924,237 @@ const pieceConstructeursDisplay = computed(() =>
|
||||
const formatConstructeurContact = (constructeur) =>
|
||||
formatConstructeurContactSummary(constructeur);
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("fr-FR", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
currencyDisplay: "narrowSymbol",
|
||||
});
|
||||
|
||||
const selectedProduct = computed(() => {
|
||||
const id = pieceData.productId;
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const list = Array.isArray(products.value) ? products.value : [];
|
||||
const cached = list.find((product) => product && product.id === id) || null;
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const current = props.piece.product;
|
||||
if (current && current.id === id) {
|
||||
return current;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const productConstructeurs = computed(() => {
|
||||
const product = selectedProduct.value;
|
||||
if (!product) {
|
||||
return [];
|
||||
}
|
||||
const list = Array.isArray(product.constructeurs) ? product.constructeurs : [];
|
||||
return list.filter((item) => item && typeof item === "object");
|
||||
});
|
||||
|
||||
const productConstructeurNames = computed(() => {
|
||||
const list = productConstructeurs.value;
|
||||
if (!list.length) {
|
||||
return "";
|
||||
}
|
||||
return list
|
||||
.map((constructeur) => constructeur?.name)
|
||||
.filter((name) => typeof name === "string" && name.trim().length > 0)
|
||||
.join(", ");
|
||||
});
|
||||
|
||||
const productSupplierPrice = computed(() => {
|
||||
const product = selectedProduct.value;
|
||||
if (!product || product.supplierPrice === undefined || product.supplierPrice === null) {
|
||||
return null;
|
||||
}
|
||||
const number = Number(product.supplierPrice);
|
||||
if (Number.isNaN(number)) {
|
||||
return null;
|
||||
}
|
||||
return currencyFormatter.format(number);
|
||||
});
|
||||
|
||||
const displayProduct = computed(() => selectedProduct.value || props.piece.__productDisplay || null);
|
||||
|
||||
const displayProductName = computed(() => {
|
||||
if (!displayProduct.value) {
|
||||
return null
|
||||
}
|
||||
const product = displayProduct.value
|
||||
return (
|
||||
product.name ||
|
||||
product.label ||
|
||||
product.reference ||
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
const displayProductCategory = computed(() =>
|
||||
displayProduct.value
|
||||
? displayProduct.value.typeProduct?.name ||
|
||||
displayProduct.value.category ||
|
||||
null
|
||||
: null,
|
||||
);
|
||||
|
||||
const displayProductReference = computed(() =>
|
||||
displayProduct.value ? displayProduct.value.reference || null : null,
|
||||
);
|
||||
|
||||
const displayProductSuppliers = computed(() => {
|
||||
if (selectedProduct.value) {
|
||||
return productConstructeurNames.value;
|
||||
}
|
||||
if (displayProduct.value) {
|
||||
return (
|
||||
displayProduct.value.suppliers ||
|
||||
displayProduct.value.supplierLabel ||
|
||||
null
|
||||
);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const displayProductPrice = computed(() => {
|
||||
if (selectedProduct.value) {
|
||||
return productSupplierPrice.value;
|
||||
}
|
||||
if (displayProduct.value) {
|
||||
const price =
|
||||
displayProduct.value.price ||
|
||||
displayProduct.value.priceLabel ||
|
||||
displayProduct.value.priceDisplay ||
|
||||
displayProduct.value.priceLabel;
|
||||
return price || null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const productInfoRows = computed(() => {
|
||||
if (!displayProduct.value) {
|
||||
return [];
|
||||
}
|
||||
const rows = [];
|
||||
if (displayProductReference.value) {
|
||||
rows.push({ label: "Référence", value: displayProductReference.value });
|
||||
}
|
||||
if (displayProductPrice.value) {
|
||||
rows.push({ label: "Prix indicatif", value: displayProductPrice.value });
|
||||
}
|
||||
if (displayProductSuppliers.value) {
|
||||
rows.push({ label: "Fournisseur(s)", value: displayProductSuppliers.value });
|
||||
}
|
||||
if (displayProductCategory.value) {
|
||||
rows.push({ label: "Catégorie", value: displayProductCategory.value });
|
||||
}
|
||||
return rows;
|
||||
});
|
||||
|
||||
const productDocuments = computed(() => {
|
||||
const product =
|
||||
selectedProduct.value ||
|
||||
props.piece.product ||
|
||||
null;
|
||||
return Array.isArray(product?.documents) ? product.documents : [];
|
||||
});
|
||||
|
||||
const ensureProductLoaded = async (id) => {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
const list = Array.isArray(products.value) ? products.value : [];
|
||||
const cached = list.find((product) => product && product.id === id);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const result = await getProduct(id, { force: true });
|
||||
if (result.success && result.data) {
|
||||
return result.data;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadProducts().catch(() => {});
|
||||
if (pieceData.productId) {
|
||||
ensureProductLoaded(pieceData.productId);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.piece.product?.id || props.piece.productId || null,
|
||||
async (id, prevId) => {
|
||||
if (pieceData.productId === id) {
|
||||
if (id && !selectedProduct.value) {
|
||||
const resolved = await ensureProductLoaded(id);
|
||||
if (resolved) {
|
||||
props.piece.product = resolved;
|
||||
}
|
||||
}
|
||||
if (!id) {
|
||||
props.piece.product = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
pieceData.productId = id;
|
||||
if (id) {
|
||||
const resolved = await ensureProductLoaded(id);
|
||||
if (resolved) {
|
||||
props.piece.product = resolved;
|
||||
const supplierPrice = resolved.supplierPrice;
|
||||
if (
|
||||
(pieceData.prix === "" || pieceData.prix === null || pieceData.prix === undefined) &&
|
||||
supplierPrice !== null &&
|
||||
supplierPrice !== undefined
|
||||
) {
|
||||
const number = Number(supplierPrice);
|
||||
if (!Number.isNaN(number)) {
|
||||
pieceData.prix = String(number);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
props.piece.product = null;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const handleProductChange = async (value) => {
|
||||
const nextId = value || null;
|
||||
pieceData.productId = nextId;
|
||||
props.piece.productId = nextId;
|
||||
|
||||
if (!nextId) {
|
||||
props.piece.product = null;
|
||||
updatePiece();
|
||||
return;
|
||||
}
|
||||
|
||||
const resolved = await ensureProductLoaded(nextId);
|
||||
if (resolved) {
|
||||
props.piece.product = resolved;
|
||||
const supplierPrice = resolved.supplierPrice;
|
||||
if (
|
||||
(pieceData.prix === "" || pieceData.prix === null || pieceData.prix === undefined) &&
|
||||
supplierPrice !== null &&
|
||||
supplierPrice !== undefined
|
||||
) {
|
||||
const number = Number(supplierPrice);
|
||||
if (!Number.isNaN(number)) {
|
||||
pieceData.prix = String(number);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updatePiece();
|
||||
};
|
||||
|
||||
const handleConstructeurChange = (value) => {
|
||||
const ids = uniqueConstructeurIds(value);
|
||||
props.piece.constructeurIds = [...ids];
|
||||
@@ -1060,10 +1415,24 @@ const setCustomFieldValue = (fieldValueId, value, field) => {
|
||||
|
||||
const updatePiece = () => {
|
||||
const prixValue = pieceData.prix;
|
||||
let parsedPrice = null;
|
||||
if (
|
||||
prixValue !== null &&
|
||||
prixValue !== undefined &&
|
||||
String(prixValue).trim().length > 0
|
||||
) {
|
||||
const numeric = Number(prixValue);
|
||||
if (!Number.isNaN(numeric)) {
|
||||
parsedPrice = numeric;
|
||||
}
|
||||
}
|
||||
const product = selectedProduct.value ? { ...selectedProduct.value } : null;
|
||||
emit("update", {
|
||||
...props.piece,
|
||||
...pieceData,
|
||||
prix: prixValue && prixValue !== "" ? parseFloat(prixValue) : null,
|
||||
prix: parsedPrice,
|
||||
productId: pieceData.productId || null,
|
||||
product,
|
||||
constructeurIds: pieceConstructeurIds.value,
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user