Files
Inventory/app/components/PieceItem.vue
2025-12-03 11:29:11 +01:00

1589 lines
45 KiB
Vue

<template>
<div class="border border-gray-200 rounded-lg p-4">
<DocumentPreviewModal
:document="previewDocument"
:visible="previewVisible"
@close="closePreview"
/>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<IconLucidePackage class="w-4 h-4 text-purple-500" aria-hidden="true" />
<input
v-if="isEditMode"
:id="`piece-name-${piece.id}`"
v-model="pieceData.name"
type="text"
class="font-semibold text-lg input input-sm input-bordered"
@blur="updatePiece"
/>
<div
v-else
class="font-semibold text-lg input input-sm input-bordered bg-base-200"
>
{{ pieceData.name }}
</div>
</div>
<div class="flex flex-wrap items-center gap-2 text-xs">
<span
v-if="piece.skeletonOnly"
class="badge badge-warning badge-sm"
>
Défini dans le catalogue
</span>
<span
v-if="piece.typeMachinePieceRequirement"
class="badge badge-outline badge-sm"
>
Groupe :
{{
piece.typeMachinePieceRequirement.label ||
piece.typeMachinePieceRequirement.typePiece?.name ||
"Non défini"
}}
</span>
<span
v-if="piece.parentComponentName"
class="badge badge-ghost badge-sm"
>
Rattachée à {{ piece.parentComponentName }}
</span>
<span
v-if="displayProductName"
class="badge badge-info badge-sm"
>
Produit&nbsp;: {{ displayProductName }}
</span>
</div>
</div>
<div class="space-y-2 text-sm">
<div>
<span class="font-medium">Référence:</span>
<input
v-if="isEditMode"
:id="`piece-reference-${piece.id}`"
v-model="pieceData.reference"
type="text"
class="input input-sm input-bordered ml-2"
@blur="updatePiece"
/>
<span v-else class="ml-2">{{
pieceData.reference || "Non définie"
}}</span>
</div>
<div>
<span class="font-medium">Fournisseur:</span>
<div v-if="!isEditMode" class="ml-2">
<div v-if="pieceConstructeursDisplay.length" class="space-y-1">
<div
v-for="constructeur in pieceConstructeursDisplay"
:key="constructeur.id"
class="flex flex-col"
>
<span class="font-medium">
{{ constructeur.name }}
</span>
<span
v-if="formatConstructeurContact(constructeur)"
class="text-xs text-gray-500"
>
{{ formatConstructeurContact(constructeur) }}
</span>
</div>
</div>
<span v-else class="font-medium">
Non défini
</span>
</div>
<ConstructeurSelect
v-else
class="w-full"
:model-value="pieceConstructeurIds"
:initial-options="pieceConstructeursDisplay"
placeholder="Sélectionner un ou plusieurs fournisseurs..."
@update:model-value="handleConstructeurChange"
/>
</div>
<div>
<span class="font-medium">Prix:</span>
<input
v-if="isEditMode"
:id="`piece-prix-${piece.id}`"
v-model="pieceData.prix"
type="number"
step="0.01"
class="input input-sm input-bordered ml-2"
@blur="updatePiece"
/>
<span v-else class="ml-2">{{
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 -->
<div
v-if="displayedCustomFields.length"
class="mt-4 pt-4 border-t border-gray-200"
>
<h5 class="text-sm font-medium text-gray-700 mb-3">
Champs personnalisés
</h5>
<div class="space-y-3">
<div
v-for="(field, index) in displayedCustomFields"
:key="resolveFieldKey(field, index)"
class="form-control"
>
<label class="label">
<span class="label-text text-sm">{{
resolveFieldName(field)
}}</span>
<span
v-if="resolveFieldRequired(field)"
class="label-text-alt text-error"
>*</span
>
</label>
<!-- Mode édition -->
<template v-if="isEditMode && !resolveFieldReadOnly(field)">
<!-- Champ de type TEXT -->
<input
v-if="resolveFieldType(field) === 'text'"
:value="field.value ?? ''"
type="text"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
@input="
setCustomFieldValue(
resolveFieldId(field),
$event.target.value,
field
)
"
@blur="updateCustomFieldValue(resolveFieldId(field), field)"
/>
<!-- Champ de type NUMBER -->
<input
v-else-if="resolveFieldType(field) === 'number'"
:value="field.value ?? ''"
type="number"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
@input="
setCustomFieldValue(
resolveFieldId(field),
$event.target.value,
field
)
"
@blur="updateCustomFieldValue(resolveFieldId(field), field)"
/>
<!-- Champ de type SELECT -->
<select
v-else-if="resolveFieldType(field) === 'select'"
:value="field.value ?? ''"
class="select select-bordered select-sm"
:required="resolveFieldRequired(field)"
@change="
(event) =>
setCustomFieldValue(
resolveFieldId(field),
event.target.value,
field
)
"
@blur="updateCustomFieldValue(resolveFieldId(field), field)"
>
<option value="">Sélectionner...</option>
<option
v-for="option in resolveFieldOptions(field)"
:key="option"
:value="option"
>
{{ option }}
</option>
</select>
<!-- Champ de type BOOLEAN -->
<div
v-else-if="resolveFieldType(field) === 'boolean'"
class="flex items-center gap-2"
>
<input
:value="field.value ?? ''"
type="checkbox"
class="checkbox checkbox-sm"
:checked="String(field.value).toLowerCase() === 'true'"
@change="
setCustomFieldValue(
resolveFieldId(field),
$event.target.checked ? 'true' : 'false',
field
)
"
@blur="updateCustomFieldValue(resolveFieldId(field), field)"
/>
<span class="text-sm">{{
String(field.value).toLowerCase() === "true" ? "Oui" : "Non"
}}</span>
</div>
<!-- Champ de type DATE -->
<input
v-else-if="resolveFieldType(field) === 'date'"
:value="field.value ?? ''"
type="date"
class="input input-bordered input-sm"
:required="resolveFieldRequired(field)"
@input="
setCustomFieldValue(
resolveFieldId(field),
$event.target.value,
field
)
"
@blur="updateCustomFieldValue(resolveFieldId(field), field)"
/>
</template>
<!-- Mode lecture seule -->
<template v-else>
<div class="input input-bordered input-sm bg-base-200">
{{ formatFieldDisplayValue(field) }}
</div>
</template>
</div>
</div>
</div>
<div class="mt-4 pt-4 border-t border-gray-200 space-y-3">
<div class="flex items-center justify-between">
<h5 class="text-sm font-medium text-gray-700">Documents</h5>
<span
v-if="isEditMode && selectedFiles.length"
class="badge badge-outline"
>
{{ selectedFiles.length }} fichier{{
selectedFiles.length > 1 ? "s" : ""
}}
sélectionné{{ selectedFiles.length > 1 ? "s" : "" }}
</span>
</div>
<p v-if="loadingDocuments" class="text-xs text-gray-500">
Chargement des documents...
</p>
<DocumentUpload
v-if="isEditMode"
v-model="selectedFiles"
title="Déposer des fichiers pour cette pièce"
subtitle="Formats acceptés : PDF, images, documents..."
@files-added="handleFilesAdded"
/>
<div v-if="pieceDocuments.length" class="space-y-2">
<div
v-for="document in pieceDocuments"
:key="document.id"
class="flex items-center justify-between rounded border border-base-200 bg-base-100 px-3 py-2"
>
<div class="flex items-center gap-3 text-sm">
<div
class="flex-shrink-0 overflow-hidden rounded-md border border-base-200 bg-base-200/70 flex items-center justify-center"
:class="documentThumbnailClass(document)"
>
<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-6 w-6"
:class="documentIcon(document).colorClass"
aria-hidden="true"
/>
</div>
<div>
<div class="font-medium">
{{ document.name }}
</div>
<div class="text-xs text-gray-500">
{{ document.mimeType || "Inconnu" }}
{{ formatSize(document.size) }}
</div>
</div>
</div>
<div class="flex items-center gap-2">
<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>
<button
v-if="isEditMode"
type="button"
class="btn btn-error btn-xs"
:disabled="uploadingDocuments"
@click="removeDocument(document.id)"
>
Supprimer
</button>
</div>
</div>
</div>
<p v-else-if="!loadingDocuments" class="text-xs text-gray-500">
Aucun document lié à cette pièce.
</p>
</div>
</div>
</template>
<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";
import { useDocuments } from "~/composables/useDocuments";
import { getFileIcon } from "~/utils/fileIcons";
import { canPreviewDocument, isImageDocument, isPdfDocument } from "~/utils/documentPreview";
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,
uniqueConstructeurIds,
} from "~/shared/constructeurUtils";
const props = defineProps({
piece: {
type: Object,
required: true,
},
isEditMode: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
"update",
"edit",
"custom-field-update",
]);
// Données locales isolées pour cette pièce
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([]);
const uploadingDocuments = ref(false);
const loadingDocuments = ref(false);
const documentsLoaded = ref(
!!(props.piece.documents && props.piece.documents.length)
);
const pieceDocuments = computed(() => props.piece.documents || []);
const documentIcon = (doc) =>
getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType });
const previewDocument = ref(null);
const previewVisible = ref(false);
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
const shouldInlinePdf = (document) => {
if (!document || !isPdfDocument(document) || !document.path) {
return false;
}
if (typeof document.size === "number" && document.size > PDF_PREVIEW_MAX_BYTES) {
return false;
}
return true;
};
const appendPdfViewerParams = (src) => {
if (!src || src.startsWith("data:")) {
return src || "";
}
if (src.includes("#")) {
return `${src}&toolbar=0&navpanes=0`;
}
return `${src}#toolbar=0&navpanes=0`;
};
const documentPreviewSrc = (document) => {
if (!document?.path) {
return "";
}
if (isPdfDocument(document)) {
return appendPdfViewerParams(document.path);
}
return document.path;
};
const documentThumbnailClass = (document) => {
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
return "h-24 w-20";
}
return "h-16 w-16";
};
const extractStructureCustomFields = (structure) => {
if (!structure || typeof structure !== "object") {
return [];
}
const customFields = structure.customFields;
return Array.isArray(customFields) ? customFields : [];
};
function fieldKeyFromNameAndType(name, type) {
const normalizedName =
typeof name === 'string' ? name.trim().toLowerCase() : '';
const normalizedType =
typeof type === 'string' ? type.trim().toLowerCase() : '';
return normalizedName ? `${normalizedName}::${normalizedType}` : null;
}
function resolveOrderIndex(field) {
if (!field || typeof field !== 'object') {
return 0;
}
if (typeof field.orderIndex === 'number') {
return field.orderIndex;
}
if (field.customField && typeof field.customField.orderIndex === 'number') {
return field.customField.orderIndex;
}
return 0;
}
function deduplicateFieldDefinitions(definitions) {
const result = [];
const seen = new Set();
const orderedDefinitions = (Array.isArray(definitions)
? definitions.slice()
: []
).sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b));
orderedDefinitions.forEach((field) => {
if (!field || typeof field !== 'object') {
return;
}
const id =
field.id ??
field.customFieldId ??
field.customField?.id ??
null;
const nameKey = fieldKeyFromNameAndType(field.name, field.type);
const key = id || nameKey;
if (key && seen.has(key)) {
return;
}
if (key) {
seen.add(key);
}
field.orderIndex = resolveOrderIndex(field);
result.push(field);
});
return result;
}
function mergeFieldDefinitionsWithValues(definitions, values) {
const definitionList = Array.isArray(definitions) ? definitions : [];
const valueList = Array.isArray(values) ? values : [];
const valueMap = new Map();
valueList.forEach((entry) => {
if (!entry || typeof entry !== 'object') {
return;
}
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null;
if (fieldId) {
valueMap.set(fieldId, entry);
}
const nameKey = fieldKeyFromNameAndType(
entry.customField?.name,
entry.customField?.type,
);
if (nameKey) {
valueMap.set(nameKey, entry);
}
});
const merged = definitionList.map((field) => {
if (!field || typeof field !== 'object') {
return field;
}
const fieldId = resolveCustomFieldId(field);
const nameKey = fieldKeyFromNameAndType(
resolveFieldName(field),
resolveFieldType(field),
);
const matchedValue =
(fieldId ? valueMap.get(fieldId) : undefined) ??
(nameKey ? valueMap.get(nameKey) : undefined);
if (!matchedValue) {
return {
...field,
value: field?.value ?? '',
orderIndex: resolveOrderIndex(field),
};
}
const resolvedOrder = Math.min(
resolveOrderIndex(field),
resolveOrderIndex(matchedValue.customField),
);
return {
...field,
customFieldValueId: matchedValue.id ?? field.customFieldValueId ?? null,
customFieldId:
matchedValue.customField?.id ??
matchedValue.customFieldId ??
fieldId ??
null,
customField: matchedValue.customField ?? field.customField ?? null,
value: matchedValue.value ?? field.value ?? '',
orderIndex: resolvedOrder,
};
});
valueList.forEach((entry) => {
if (!entry || typeof entry !== 'object') {
return;
}
const fieldId = entry.customField?.id ?? entry.customFieldId ?? null;
const nameKey = fieldKeyFromNameAndType(
entry.customField?.name,
entry.customField?.type,
);
const exists = merged.some((field) => {
if (!field || typeof field !== 'object') {
return false;
}
if (field.customFieldValueId && field.customFieldValueId === entry.id) {
return true;
}
const existingId = resolveCustomFieldId(field);
if (fieldId && existingId && existingId === fieldId) {
return true;
}
if (!fieldId && nameKey) {
return (
fieldKeyFromNameAndType(
resolveFieldName(field),
resolveFieldType(field),
) === nameKey
);
}
return false;
});
if (!exists) {
merged.push({
customFieldValueId: entry.id ?? null,
customFieldId: fieldId,
name: entry.customField?.name ?? '',
type: entry.customField?.type ?? 'text',
required: entry.customField?.required ?? false,
options: entry.customField?.options ?? [],
value: entry.value ?? '',
customField: entry.customField ?? null,
orderIndex: resolveOrderIndex(entry.customField),
});
}
});
return merged.sort(
(a, b) => resolveOrderIndex(a) - resolveOrderIndex(b),
);
}
function dedupeMergedFields(fields) {
if (!Array.isArray(fields) || fields.length <= 1) {
return Array.isArray(fields)
? fields.slice().sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b))
: [];
}
const seen = new Map();
const result = [];
const orderedFields = fields
.slice()
.sort((a, b) => resolveOrderIndex(a) - resolveOrderIndex(b));
orderedFields.forEach((field) => {
if (!field || typeof field !== 'object') {
return;
}
const rawName = resolveFieldName(field);
const normalizedName =
typeof rawName === 'string' ? rawName.trim() : '';
if (!normalizedName) {
return;
}
field.type = field.type || 'text';
if (typeof field.name === 'string') {
field.name = field.name.trim();
} else {
field.name = normalizedName;
}
const fieldId = resolveCustomFieldId(field);
const nameKey = fieldKeyFromNameAndType(
normalizedName,
resolveFieldType(field),
);
const key = fieldId || nameKey;
if (!key) {
field.orderIndex = resolveOrderIndex(field);
result.push(field);
return;
}
const existing = seen.get(key);
if (!existing) {
field.orderIndex = resolveOrderIndex(field);
seen.set(key, field);
result.push(field);
return;
}
const existingHasValue =
existing.value !== undefined &&
existing.value !== null &&
String(existing.value).trim().length > 0;
const incomingHasValue =
field.value !== undefined &&
field.value !== null &&
String(field.value).trim().length > 0;
if (!existingHasValue && incomingHasValue) {
Object.assign(existing, field);
existing.orderIndex = Math.min(
resolveOrderIndex(existing),
resolveOrderIndex(field),
);
seen.set(key, existing);
}
});
return result.sort(
(a, b) => resolveOrderIndex(a) - resolveOrderIndex(b),
);
}
const pieceDefinitionSources = computed(() => {
const requirement = props.piece.typeMachinePieceRequirement || {};
const type = requirement.typePiece || props.piece.typePiece || {};
const definitions = [];
const pushFields = (collection) => {
if (Array.isArray(collection)) {
definitions.push(...collection);
}
};
pushFields(props.piece.customFields);
pushFields(props.piece.definition?.customFields);
pushFields(props.piece.typePiece?.customFields);
pushFields(type.customFields);
pushFields(requirement.typePiece?.customFields);
pushFields(requirement.customFields);
pushFields(requirement.definition?.customFields);
[
props.piece.definition?.structure,
props.piece.typePiece?.structure,
type.structure,
type.pieceSkeleton,
props.piece.typePiece?.pieceSkeleton,
requirement.structure,
requirement.pieceSkeleton,
].forEach((structure) => {
const fields = extractStructureCustomFields(structure);
if (fields.length) {
definitions.push(...fields);
}
});
return deduplicateFieldDefinitions(definitions);
});
const displayedCustomFields = computed(() =>
dedupeMergedFields(
mergeFieldDefinitionsWithValues(
pieceDefinitionSources.value,
props.piece.customFieldValues,
),
),
);
const candidateCustomFields = computed(() => {
const map = new Map();
const register = (collection) => {
if (!Array.isArray(collection)) {
return;
}
collection.forEach((item) => {
if (!item || typeof item !== 'object') {
return;
}
const id = item.id || item.customFieldId;
const name = typeof item.name === 'string' ? item.name : null;
const key = id || (name ? `${name}::${item.type ?? ''}` : null);
if (!key || map.has(key)) {
return;
}
map.set(key, item);
});
};
register(props.piece.customFieldValues?.map((value) => value?.customField));
register(pieceDefinitionSources.value);
return Array.from(map.values());
});
const { constructeurs } = useConstructeurs();
const { products, loadProducts, getProduct } = useProducts();
const pieceConstructeurIds = computed(() =>
uniqueConstructeurIds(
props.piece,
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
props.piece.constructeur ? [props.piece.constructeur] : [],
),
);
const pieceConstructeursDisplay = computed(() =>
resolveConstructeurs(
pieceConstructeurIds.value,
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
props.piece.constructeur ? [props.piece.constructeur] : [],
constructeurs.value,
),
);
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];
props.piece.constructeurId = null;
props.piece.constructeur = null;
props.piece.constructeurs = resolveConstructeurs(
ids,
constructeurs.value,
Array.isArray(props.piece.constructeurs) ? props.piece.constructeurs : [],
);
updatePiece();
};
const { uploadDocuments, deleteDocument, loadDocumentsByPiece } =
useDocuments();
const {
updateCustomFieldValue: updateCustomFieldValueApi,
upsertCustomFieldValue,
} = useCustomFields();
const { showSuccess, showError } = useToast();
const refreshDocuments = async () => {
if (!props.piece?.id) {
return;
}
loadingDocuments.value = true;
try {
const result = await loadDocumentsByPiece(props.piece.id, {
updateStore: false,
});
if (result.success) {
props.piece.documents = result.data || [];
documentsLoaded.value = true;
}
} finally {
loadingDocuments.value = false;
}
};
const handleFilesAdded = async (files) => {
if (!files.length || !props.piece?.id) {
return;
}
uploadingDocuments.value = true;
try {
const result = await uploadDocuments(
{
files,
context: { pieceId: props.piece.id },
},
{ updateStore: false }
);
if (result.success) {
const newDocs = result.data || [];
props.piece.documents = [...newDocs, ...(props.piece.documents || [])];
documentsLoaded.value = true;
selectedFiles.value = [];
}
} finally {
uploadingDocuments.value = false;
}
};
const removeDocument = async (documentId) => {
if (!documentId) {
return;
}
const result = await deleteDocument(documentId, { updateStore: false });
if (result.success) {
props.piece.documents = (props.piece.documents || []).filter(
(doc) => doc.id !== documentId
);
}
};
const downloadDocument = (doc) => {
if (!doc?.path) {
return;
}
if (doc.path.startsWith("data:")) {
const link = document.createElement("a");
link.href = doc.path;
link.download = doc.filename || doc.name || "document";
link.click();
return;
}
window.open(doc.path, "_blank");
};
const openPreview = (doc) => {
if (!canPreviewDocument(doc)) {
return;
}
previewDocument.value = doc;
previewVisible.value = true;
};
const closePreview = () => {
previewVisible.value = false;
previewDocument.value = null;
};
const formatSize = (size) => {
if (size === undefined || size === null) {
return "—";
}
if (size === 0) {
return "0 B";
}
const units = ["B", "KB", "MB", "GB"];
const index = Math.min(
units.length - 1,
Math.floor(Math.log(size) / Math.log(1024))
);
const formatted = size / Math.pow(1024, index);
return `${formatted.toFixed(1)} ${units[index]}`;
};
watch(
() => props.piece.documents,
(docs) => {
documentsLoaded.value = !!(docs && docs.length);
}
);
// Méthodes pour gérer les champs personnalisés
function resolveFieldKey(field, index) {
return field?.id ??
field?.customFieldValueId ??
field?.customFieldId ??
field?.name ??
`field-${index}`
}
function resolveFieldId(field) {
return field?.customFieldValueId ?? null
}
function resolveFieldName(field) {
return field?.name ?? 'Champ'
}
function resolveFieldType(field) {
return field?.type ?? 'text'
}
function resolveFieldOptions(field) {
return field?.options ?? []
}
function resolveFieldRequired(field) {
return !!field?.required
}
function resolveFieldReadOnly(field) {
return !!field?.readOnly
}
function buildCustomFieldMetadata(field) {
return {
customFieldName: resolveFieldName(field),
customFieldType: resolveFieldType(field),
customFieldRequired: resolveFieldRequired(field),
customFieldOptions: resolveFieldOptions(field)
}
}
function resolveCustomFieldId(field) {
return field?.customFieldId ?? field?.id ?? field?.customField?.id ?? null;
}
function ensureCustomFieldId(field) {
const existingId = resolveCustomFieldId(field);
if (existingId) {
return existingId;
}
const name = resolveFieldName(field);
if (!name || name === "Champ") {
return null;
}
const matches = candidateCustomFields.value.filter((candidate) => {
if (!candidate || typeof candidate !== "object") {
return false;
}
const candidateId = candidate.id || candidate.customFieldId;
if (candidateId && (candidateId === field?.id || candidateId === field?.customFieldId)) {
return true;
}
return typeof candidate.name === "string" && candidate.name === name;
});
if (matches.length) {
const withId = matches.find((candidate) => candidate?.id || candidate?.customFieldId) || matches[0];
const id = withId?.id || withId?.customFieldId || null;
if (id) {
field.customFieldId = id;
}
if (!field.customField && typeof withId === "object") {
field.customField = withId;
}
return id;
}
return null;
}
watch(
candidateCustomFields,
() => {
const fields = displayedCustomFields.value || []
fields.forEach((field) => field && ensureCustomFieldId(field))
},
{ immediate: true, deep: true }
)
watch(
displayedCustomFields,
(fields) => {
(fields || []).forEach((field) => field && ensureCustomFieldId(field))
},
{ immediate: true, deep: true }
)
const formatFieldDisplayValue = (field) => {
const type = resolveFieldType(field);
const rawValue = field?.value ?? "";
if (type === "boolean") {
const normalized = String(rawValue).toLowerCase();
if (normalized === "true") return "Oui";
if (normalized === "false") return "Non";
}
return rawValue || "Non défini";
};
const setCustomFieldValue = (fieldValueId, value, field) => {
if (resolveFieldReadOnly(field)) {
return;
}
if (field && typeof field === "object") {
field.value = value;
}
if (!fieldValueId) {
return;
}
const fieldValue = props.piece.customFieldValues?.find(
(fv) => fv.id === fieldValueId
);
if (fieldValue) {
fieldValue.value = value;
}
};
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: parsedPrice,
productId: pieceData.productId || null,
product,
constructeurIds: pieceConstructeurIds.value,
});
};
const updateCustomFieldValue = async (fieldValueId, field) => {
if (!field || resolveFieldReadOnly(field)) {
return;
}
const fieldValue = fieldValueId
? props.piece.customFieldValues?.find((fv) => fv.id === fieldValueId)
: undefined;
if (fieldValue) {
const result = await updateCustomFieldValueApi(fieldValueId, {
value: fieldValue.value,
});
if (result.success) {
if (fieldValue.customField?.id) {
field.customFieldId = fieldValue.customField.id;
field.customField = fieldValue.customField;
}
showSuccess(
`Champ "${fieldValue.customField.name}" mis à jour avec succès`
);
emit("custom-field-update", {
fieldId: fieldValue.customField?.id ?? ensureCustomFieldId(field),
pieceId: props.piece.id,
value: fieldValue.value,
});
} else {
showError(
`Erreur lors de la mise à jour du champ "${fieldValue.customField.name}"`
);
}
return;
}
const customFieldId = ensureCustomFieldId(field);
const fieldName = resolveFieldName(field);
if (!props.piece?.id) {
showError("Impossible de créer la valeur pour ce champ de pièce");
return;
}
if (!customFieldId && (!fieldName || fieldName === "Champ")) {
showError("Impossible de créer la valeur pour ce champ de pièce");
return;
}
const metadata = customFieldId ? undefined : buildCustomFieldMetadata(field);
const result = await upsertCustomFieldValue(
customFieldId,
"piece",
props.piece.id,
field.value ?? "",
metadata
);
if (result.success) {
const created = result.data;
if (created?.id) {
field.customFieldValueId = created.id;
field.value = created.value ?? field.value ?? "";
if (created.customField?.id) {
field.customFieldId = created.customField.id;
field.customField = created.customField;
}
if (Array.isArray(props.piece.customFieldValues)) {
const index = props.piece.customFieldValues.findIndex(
(value) => value.id === created.id
);
if (index !== -1) {
props.piece.customFieldValues.splice(index, 1, created);
} else {
props.piece.customFieldValues.push(created);
}
} else {
props.piece.customFieldValues = [created];
}
}
showSuccess(`Champ "${resolveFieldName(field)}" créé avec succès`);
emit("custom-field-update", {
fieldId:
created?.customField?.id ??
created?.customFieldId ??
ensureCustomFieldId(field),
pieceId: props.piece.id,
value: field.value ?? "",
});
const definitions = Array.isArray(props.piece.customFields)
? [...props.piece.customFields]
: [];
const fieldIdentifier = ensureCustomFieldId(field);
const existingIndex = definitions.findIndex((definition) => {
const definitionId = ensureCustomFieldId(definition);
if (fieldIdentifier && definitionId) {
return definitionId === fieldIdentifier;
}
return definition?.name === resolveFieldName(field);
});
const updatedDefinition = {
...(existingIndex !== -1 ? definitions[existingIndex] : {}),
customFieldValueId: field.customFieldValueId,
customFieldId: fieldIdentifier,
name: resolveFieldName(field),
type: resolveFieldType(field),
required: resolveFieldRequired(field),
options: resolveFieldOptions(field),
value: field.value ?? "",
customField: field.customField ?? null,
};
if (existingIndex !== -1) {
definitions.splice(existingIndex, 1, updatedDefinition);
} else {
definitions.push(updatedDefinition);
}
props.piece.customFields = definitions;
} else {
showError(
`Erreur lors de la sauvegarde du champ "${resolveFieldName(field)}"`
);
}
};
// Surveiller les changements dans les champs personnalisés
watch(
() => [props.piece.name, props.piece.reference, props.piece.prix],
() => {
pieceData.name = props.piece.name || "";
pieceData.reference = props.piece.reference || "";
pieceData.prix = props.piece.prix || "";
}
);
onMounted(() => {
// Initialiser les données avec les props
pieceData.name = props.piece.name || "";
pieceData.reference = props.piece.reference || "";
pieceData.prix = props.piece.prix || "";
if (!documentsLoaded.value) {
refreshDocuments();
}
});
</script>