Compare commits
16 Commits
2f3d4c5260
...
v1.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3705b8daed | |||
|
|
202b964b24 | ||
|
|
a1d15c23a4 | ||
|
|
a7101c7e77 | ||
|
|
adccfa9b46 | ||
|
|
5f54acdfac | ||
|
|
94239031d6 | ||
|
|
b27662d2bc | ||
|
|
55739fe50f | ||
|
|
1f5f1509a9 | ||
|
|
a8cb4d1ac0 | ||
| 8af8374282 | |||
| 9cc7ac10f0 | |||
|
|
86d15faa01 | ||
|
|
603c03ca00 | ||
|
|
155cd9b358 |
16
app/app.vue
16
app/app.vue
@@ -297,15 +297,17 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="avatar placeholder">
|
||||
<div
|
||||
class="bg-primary text-primary-content rounded-lg w-10 grid place-items-center"
|
||||
>
|
||||
<IconLucideBoxes class="w-6 h-6" aria-hidden="true" />
|
||||
<div class="avatar">
|
||||
<div class="w-14">
|
||||
<img
|
||||
:src="logoSrc"
|
||||
alt="Logo Malio"
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<NuxtLink to="/" class="btn btn-ghost text-xl">
|
||||
Inventaire Pro
|
||||
Inventory
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
@@ -705,13 +707,13 @@ import { useRoute, navigateTo, useRuntimeConfig } from "#imports";
|
||||
import { useProfileSession } from "~/composables/useProfileSession";
|
||||
import IconLucideMenu from "~icons/lucide/menu";
|
||||
import IconLucideSettings from "~icons/lucide/settings";
|
||||
import IconLucideBoxes from "~icons/lucide/boxes";
|
||||
import IconLucidePlus from "~icons/lucide/plus";
|
||||
import IconLucideCpu from "~icons/lucide/cpu";
|
||||
import IconLucideFilePlus from "~icons/lucide/file-plus";
|
||||
import IconLucideMapPin from "~icons/lucide/map-pin";
|
||||
import IconLucideChevronRight from "~icons/lucide/chevron-right";
|
||||
import IconLucideLogOut from "~icons/lucide/log-out";
|
||||
import logoSrc from "~/assets/LOGO_CARRE_BLANC.png";
|
||||
|
||||
// État du modal des paramètres d'affichage
|
||||
const displaySettingsOpen = ref(false);
|
||||
|
||||
BIN
app/assets/LOGO_CARRE_BLANC.png
Normal file
BIN
app/assets/LOGO_CARRE_BLANC.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
@@ -10,6 +10,7 @@
|
||||
:locked-type-label="displayedRootTypeLabel"
|
||||
:allow-subcomponents="allowSubcomponents"
|
||||
:max-subcomponent-depth="maxSubcomponentDepth"
|
||||
:restricted-mode="restrictedMode"
|
||||
is-root
|
||||
/>
|
||||
</div>
|
||||
@@ -55,6 +56,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
default: Infinity,
|
||||
},
|
||||
restrictedMode: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
@@ -17,12 +17,13 @@
|
||||
<SearchSelect
|
||||
:model-value="assignment.selectedComponentId || ''"
|
||||
:options="componentOptions"
|
||||
:loading="componentsLoading"
|
||||
:loading="componentsLoading || componentLoadingByPath[assignment.path]"
|
||||
size="sm"
|
||||
placeholder="Rechercher un composant..."
|
||||
:empty-text="componentOptions.length ? 'Aucun résultat' : 'Aucun composant disponible'"
|
||||
:option-label="componentOptionLabel"
|
||||
:option-description="componentOptionDescription"
|
||||
@search="fetchComponentOptions"
|
||||
@update:modelValue="(value) => { assignment.selectedComponentId = normalizeSelectionValue(value); }"
|
||||
/>
|
||||
</div>
|
||||
@@ -45,22 +46,23 @@
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium text-base-content">
|
||||
{{ describePieceRequirement(pieceAssignment.definition) }}
|
||||
{{ describePieceRequirement(pieceAssignment) }}
|
||||
</p>
|
||||
<p v-if="!getPieceOptions(pieceAssignment.definition).length" class="text-[11px] text-error">
|
||||
<p v-if="!getPieceOptions(pieceAssignment).length" class="text-[11px] text-error">
|
||||
Aucune pièce disponible pour cette famille.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchSelect
|
||||
:model-value="pieceAssignment.selectedPieceId || ''"
|
||||
:options="getPieceOptions(pieceAssignment.definition)"
|
||||
:loading="piecesLoading"
|
||||
:options="getPieceOptions(pieceAssignment)"
|
||||
:loading="piecesLoading || pieceLoadingByPath[pieceAssignment.path]"
|
||||
size="xs"
|
||||
placeholder="Rechercher une pièce..."
|
||||
:empty-text="getPieceOptions(pieceAssignment.definition).length ? 'Aucun résultat' : 'Aucune pièce disponible'"
|
||||
:empty-text="getPieceOptions(pieceAssignment).length ? 'Aucun résultat' : 'Aucune pièce disponible'"
|
||||
:option-label="pieceOptionLabel"
|
||||
:option-description="pieceOptionDescription"
|
||||
@search="(term) => fetchPieceOptions(pieceAssignment, term)"
|
||||
@update:modelValue="(value) => { pieceAssignment.selectedPieceId = normalizeSelectionValue(value); }"
|
||||
/>
|
||||
</div>
|
||||
@@ -83,22 +85,23 @@
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-medium text-base-content">
|
||||
{{ describeProductRequirement(productAssignment.definition) }}
|
||||
{{ describeProductRequirement(productAssignment) }}
|
||||
</p>
|
||||
<p v-if="!getProductOptions(productAssignment.definition).length" class="text-[11px] text-error">
|
||||
<p v-if="!getProductOptions(productAssignment).length" class="text-[11px] text-error">
|
||||
Aucun produit disponible pour cette catégorie.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchSelect
|
||||
:model-value="productAssignment.selectedProductId || ''"
|
||||
:options="getProductOptions(productAssignment.definition)"
|
||||
:loading="productsLoading"
|
||||
:options="getProductOptions(productAssignment)"
|
||||
:loading="productsLoading || productLoadingByPath[productAssignment.path]"
|
||||
size="xs"
|
||||
placeholder="Rechercher un produit..."
|
||||
:empty-text="getProductOptions(productAssignment.definition).length ? 'Aucun résultat' : 'Aucun produit disponible'"
|
||||
:empty-text="getProductOptions(productAssignment).length ? 'Aucun résultat' : 'Aucun produit disponible'"
|
||||
:option-label="productOptionLabel"
|
||||
:option-description="productOptionDescription"
|
||||
@search="(term) => fetchProductOptions(productAssignment, term)"
|
||||
@update:modelValue="(value) => { productAssignment.selectedProductId = normalizeSelectionValue(value); }"
|
||||
/>
|
||||
</div>
|
||||
@@ -131,8 +134,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue';
|
||||
import { useApi } from '~/composables/useApi';
|
||||
import type {
|
||||
ComponentModelPiece,
|
||||
ComponentModelProduct,
|
||||
@@ -206,6 +210,9 @@ const props = withDefaults(
|
||||
componentsLoading?: boolean;
|
||||
piecesLoading?: boolean;
|
||||
productsLoading?: boolean;
|
||||
pieceTypeLabelMap?: Record<string, string>;
|
||||
productTypeLabelMap?: Record<string, string>;
|
||||
componentTypeLabelMap?: Record<string, string>;
|
||||
}>(),
|
||||
{
|
||||
depth: 0,
|
||||
@@ -215,6 +222,9 @@ const props = withDefaults(
|
||||
componentsLoading: false,
|
||||
piecesLoading: false,
|
||||
productsLoading: false,
|
||||
pieceTypeLabelMap: () => ({}),
|
||||
productTypeLabelMap: () => ({}),
|
||||
componentTypeLabelMap: () => ({}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -225,10 +235,42 @@ const wrapperClass = computed(() =>
|
||||
depth.value === 0 ? 'space-y-6' : 'space-y-6 border-l border-base-300 pl-4',
|
||||
);
|
||||
|
||||
const { get } = useApi();
|
||||
const pieceOptionsByPath = ref<Record<string, PieceOption[]>>({});
|
||||
const productOptionsByPath = ref<Record<string, ProductOption[]>>({});
|
||||
const componentOptionsByPath = ref<Record<string, ComponentOption[]>>({});
|
||||
const pieceLoadingByPath = ref<Record<string, boolean>>({});
|
||||
const productLoadingByPath = ref<Record<string, boolean>>({});
|
||||
const componentLoadingByPath = ref<Record<string, boolean>>({});
|
||||
|
||||
const extractCollection = (payload: any): any[] => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member;
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member'];
|
||||
}
|
||||
if (Array.isArray(payload?.data)) {
|
||||
return payload.data;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const setLoading = (target: Record<string, boolean>, key: string, value: boolean) => {
|
||||
target[key] = value;
|
||||
};
|
||||
|
||||
const componentOptions = computed(() => {
|
||||
if (isRoot.value) {
|
||||
return [];
|
||||
}
|
||||
const cached = componentOptionsByPath.value[props.assignment.path];
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const definition = props.assignment.definition || {};
|
||||
const requiredTypeId =
|
||||
definition.typeComposantId || definition.modelId || null;
|
||||
@@ -274,6 +316,104 @@ const componentOptionDescription = (component?: ComponentOption | null) => {
|
||||
return parts.join(' • ');
|
||||
};
|
||||
|
||||
const typeIri = (id: string) => `/api/model_types/${id}`;
|
||||
const primedPiecePaths = new Set<string>();
|
||||
const primedProductPaths = new Set<string>();
|
||||
const primedComponentPaths = new Set<string>();
|
||||
|
||||
const fetchComponentOptions = async (term = '') => {
|
||||
if (isRoot.value) {
|
||||
return;
|
||||
}
|
||||
const key = props.assignment.path;
|
||||
if (componentLoadingByPath.value[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const definition = props.assignment.definition || {};
|
||||
const requiredTypeId =
|
||||
definition.typeComposantId || definition.modelId || definition.typeComposant?.id || null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('itemsPerPage', '50');
|
||||
if (term.trim()) {
|
||||
params.set('name', term.trim());
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
params.set('typeComposant', typeIri(requiredTypeId));
|
||||
}
|
||||
|
||||
setLoading(componentLoadingByPath.value, key, true);
|
||||
try {
|
||||
const result = await get(`/composants?${params.toString()}`);
|
||||
if (result.success) {
|
||||
componentOptionsByPath.value[key] = extractCollection(result.data);
|
||||
}
|
||||
} finally {
|
||||
setLoading(componentLoadingByPath.value, key, false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPieceOptions = async (assignment: StructurePieceAssignment, term = '') => {
|
||||
const key = assignment.path;
|
||||
if (pieceLoadingByPath.value[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const definition = assignment.definition || {};
|
||||
const requiredTypeId =
|
||||
definition.typePieceId || (definition as any).typePiece?.id || null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('itemsPerPage', '50');
|
||||
if (term.trim()) {
|
||||
params.set('name', term.trim());
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
params.set('typePiece', typeIri(requiredTypeId));
|
||||
}
|
||||
|
||||
setLoading(pieceLoadingByPath.value, key, true);
|
||||
try {
|
||||
const result = await get(`/pieces?${params.toString()}`);
|
||||
if (result.success) {
|
||||
pieceOptionsByPath.value[key] = extractCollection(result.data);
|
||||
}
|
||||
} finally {
|
||||
setLoading(pieceLoadingByPath.value, key, false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProductOptions = async (assignment: StructureProductAssignment, term = '') => {
|
||||
const key = assignment.path;
|
||||
if (productLoadingByPath.value[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const definition = assignment.definition || {};
|
||||
const requiredTypeId =
|
||||
definition.typeProductId || (definition as any).typeProduct?.id || null;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('itemsPerPage', '50');
|
||||
if (term.trim()) {
|
||||
params.set('name', term.trim());
|
||||
}
|
||||
if (requiredTypeId) {
|
||||
params.set('typeProduct', typeIri(requiredTypeId));
|
||||
}
|
||||
|
||||
setLoading(productLoadingByPath.value, key, true);
|
||||
try {
|
||||
const result = await get(`/products?${params.toString()}`);
|
||||
if (result.success) {
|
||||
productOptionsByPath.value[key] = extractCollection(result.data);
|
||||
}
|
||||
} finally {
|
||||
setLoading(productLoadingByPath.value, key, false);
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
componentOptions,
|
||||
(options) => {
|
||||
@@ -290,7 +430,8 @@ watch(
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const describePieceRequirement = (definition: ComponentModelPiece) => {
|
||||
const describePieceRequirement = (assignment: StructurePieceAssignment) => {
|
||||
const definition = assignment.definition;
|
||||
const parts: string[] = [];
|
||||
const addPart = (value?: string | null) => {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||
@@ -299,16 +440,17 @@ const describePieceRequirement = (definition: ComponentModelPiece) => {
|
||||
}
|
||||
};
|
||||
|
||||
const options = getPieceOptions(definition);
|
||||
const options = getPieceOptions(assignment);
|
||||
const fallbackPiece = options[0] || null;
|
||||
const fallbackType = fallbackPiece?.typePiece || null;
|
||||
|
||||
addPart(definition.role);
|
||||
addPart(
|
||||
const explicitLabel =
|
||||
definition.typePieceLabel ||
|
||||
(definition as any).typePiece?.name ||
|
||||
fallbackType?.name,
|
||||
);
|
||||
(definition as any).typePiece?.name ||
|
||||
(definition.typePieceId ? props.pieceTypeLabelMap[definition.typePieceId] : null) ||
|
||||
fallbackType?.name;
|
||||
addPart(explicitLabel);
|
||||
|
||||
const family =
|
||||
definition.familyCode ||
|
||||
@@ -333,7 +475,12 @@ const describePieceRequirement = (definition: ComponentModelPiece) => {
|
||||
return parts.length ? parts.join(' • ') : 'Pièce du squelette';
|
||||
};
|
||||
|
||||
const getProductOptions = (definition: ComponentModelProduct) => {
|
||||
const getProductOptions = (assignment: StructureProductAssignment) => {
|
||||
const cached = productOptionsByPath.value[assignment.path];
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const definition = assignment.definition;
|
||||
const requiredTypeId =
|
||||
definition.typeProductId ||
|
||||
(definition as any).typeProduct?.id ||
|
||||
@@ -386,7 +533,8 @@ const productOptionDescription = (product?: ProductOption | null) => {
|
||||
return parts.join(' • ');
|
||||
};
|
||||
|
||||
const describeProductRequirement = (definition: ComponentModelProduct) => {
|
||||
const describeProductRequirement = (assignment: StructureProductAssignment) => {
|
||||
const definition = assignment.definition;
|
||||
const parts: string[] = [];
|
||||
const addPart = (value?: string | null) => {
|
||||
const trimmed = typeof value === 'string' ? value.trim() : '';
|
||||
@@ -395,16 +543,17 @@ const describeProductRequirement = (definition: ComponentModelProduct) => {
|
||||
}
|
||||
};
|
||||
|
||||
const options = getProductOptions(definition);
|
||||
const options = getProductOptions(assignment);
|
||||
const fallbackProduct = options[0] || null;
|
||||
const fallbackType = fallbackProduct?.typeProduct || null;
|
||||
|
||||
addPart(definition.role);
|
||||
addPart(
|
||||
const explicitLabel =
|
||||
definition.typeProductLabel ||
|
||||
(definition as any).typeProduct?.name ||
|
||||
fallbackType?.name,
|
||||
);
|
||||
(definition as any).typeProduct?.name ||
|
||||
(definition.typeProductId ? props.productTypeLabelMap[definition.typeProductId] : null) ||
|
||||
fallbackType?.name;
|
||||
addPart(explicitLabel);
|
||||
|
||||
const family =
|
||||
definition.familyCode ||
|
||||
@@ -435,6 +584,9 @@ const requirementLabel = computed(() => {
|
||||
if (alias) {
|
||||
return alias;
|
||||
}
|
||||
if (definition.typeComposantId && props.componentTypeLabelMap[definition.typeComposantId]) {
|
||||
return props.componentTypeLabelMap[definition.typeComposantId];
|
||||
}
|
||||
if (definition.typeComposant?.name) {
|
||||
return definition.typeComposant.name;
|
||||
}
|
||||
@@ -448,6 +600,7 @@ const requirementDescription = computed(() => {
|
||||
const definition = props.assignment.definition || {};
|
||||
const family =
|
||||
definition.typeComposantLabel ||
|
||||
(definition.typeComposantId ? props.componentTypeLabelMap[definition.typeComposantId] : null) ||
|
||||
definition.typeComposant?.name ||
|
||||
definition.familyCode;
|
||||
if (family) {
|
||||
@@ -456,7 +609,12 @@ const requirementDescription = computed(() => {
|
||||
return 'Sélectionnez un composant enfant conforme à cette position.';
|
||||
});
|
||||
|
||||
const getPieceOptions = (definition: ComponentModelPiece) => {
|
||||
const getPieceOptions = (assignment: StructurePieceAssignment) => {
|
||||
const cached = pieceOptionsByPath.value[assignment.path];
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const definition = assignment.definition;
|
||||
const requiredTypeId =
|
||||
definition.typePieceId ||
|
||||
(definition as any).typePiece?.id ||
|
||||
@@ -526,13 +684,17 @@ watch(
|
||||
() => [props.pieces, props.assignment.pieces],
|
||||
() => {
|
||||
for (const pieceAssignment of props.assignment.pieces) {
|
||||
const options = getPieceOptions(pieceAssignment.definition);
|
||||
const options = getPieceOptions(pieceAssignment);
|
||||
if (
|
||||
pieceAssignment.selectedPieceId &&
|
||||
!options.some((piece) => piece.id === pieceAssignment.selectedPieceId)
|
||||
) {
|
||||
pieceAssignment.selectedPieceId = '';
|
||||
}
|
||||
if (!primedPiecePaths.has(pieceAssignment.path) && !pieceOptionsByPath.value[pieceAssignment.path]) {
|
||||
primedPiecePaths.add(pieceAssignment.path);
|
||||
fetchPieceOptions(pieceAssignment).catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
@@ -542,15 +704,34 @@ watch(
|
||||
() => [props.products, props.assignment.products],
|
||||
() => {
|
||||
for (const productAssignment of props.assignment.products) {
|
||||
const options = getProductOptions(productAssignment.definition);
|
||||
const options = getProductOptions(productAssignment);
|
||||
if (
|
||||
productAssignment.selectedProductId &&
|
||||
!options.some((product) => product.id === productAssignment.selectedProductId)
|
||||
) {
|
||||
productAssignment.selectedProductId = '';
|
||||
}
|
||||
if (!primedProductPaths.has(productAssignment.path) && !productOptionsByPath.value[productAssignment.path]) {
|
||||
primedProductPaths.add(productAssignment.path);
|
||||
fetchProductOptions(productAssignment).catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.assignment.definition,
|
||||
() => {
|
||||
if (isRoot.value) {
|
||||
return;
|
||||
}
|
||||
const key = props.assignment.path;
|
||||
if (!primedComponentPaths.has(key) && !componentOptionsByPath.value[key]) {
|
||||
primedComponentPaths.add(key);
|
||||
fetchComponentOptions().catch(() => {});
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -12,12 +12,6 @@
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
>
|
||||
<iframe
|
||||
v-else-if="canRenderPdf"
|
||||
:src="previewSrc"
|
||||
class="h-full w-full border-0 bg-white"
|
||||
title="Aperçu PDF"
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
:is="icon.component"
|
||||
@@ -54,8 +48,6 @@ const props = defineProps<{
|
||||
alt?: string;
|
||||
}>();
|
||||
|
||||
const PDF_PREVIEW_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
const normalizedDocument = computed(() => props.document ?? null);
|
||||
|
||||
const canRenderImage = computed(() => {
|
||||
@@ -64,14 +56,9 @@ const canRenderImage = computed(() => {
|
||||
});
|
||||
|
||||
const canRenderPdf = computed(() => {
|
||||
const doc = normalizedDocument.value;
|
||||
if (!doc || !isPdfDocument(doc) || !doc.path) {
|
||||
return false;
|
||||
}
|
||||
if (typeof doc.size === 'number' && doc.size > PDF_PREVIEW_MAX_BYTES) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
// Rendering many PDF iframes in a list is very heavy for the browser.
|
||||
// We intentionally disable inline PDF previews and fall back to an icon.
|
||||
return false;
|
||||
});
|
||||
|
||||
const appendPdfViewerParams = (src: string) => {
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
Produits inclus par défaut
|
||||
</h3>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Ces produits s’afficheront lors de la création d’une pièce basée sur cette catégorie.
|
||||
Ces produits s'afficheront lors de la création d'une pièce basée sur cette catégorie.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
@@ -35,6 +35,7 @@
|
||||
<select
|
||||
v-model="product.typeProductId"
|
||||
class="select select-bordered select-xs"
|
||||
:disabled="isProductLocked(product)"
|
||||
@change="handleProductTypeSelect(product)"
|
||||
>
|
||||
<option value="">
|
||||
@@ -51,12 +52,22 @@
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isProductLocked(product)"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="removeProduct(index)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else class="tooltip tooltip-left" data-tip="Ce produit ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -107,8 +118,9 @@
|
||||
type="text"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Nom du champ"
|
||||
:disabled="isFieldLocked(field)"
|
||||
>
|
||||
<select v-model="field.type" class="select select-bordered select-xs">
|
||||
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isFieldLocked(field)">
|
||||
<option value="text">
|
||||
Texte
|
||||
</option>
|
||||
@@ -128,7 +140,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs">
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isFieldLocked(field)">
|
||||
Obligatoire
|
||||
</div>
|
||||
|
||||
@@ -137,16 +149,27 @@
|
||||
v-model="field.optionsText"
|
||||
class="textarea textarea-bordered textarea-xs h-20"
|
||||
placeholder="Option 1 Option 2"
|
||||
:disabled="isFieldLocked(field)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!isFieldLocked(field)"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="removeField(index)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -181,6 +204,7 @@ type EditorProduct = {
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue?: PieceModelStructure | null
|
||||
restrictedMode?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -330,6 +354,19 @@ const fields = ref<EditorField[]>(hydrateFields(props.modelValue))
|
||||
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
|
||||
const restState = ref<Record<string, unknown>>(extractRest(props.modelValue))
|
||||
|
||||
const initialFieldUids = ref<Set<string>>(new Set(fields.value.map(f => f.uid)))
|
||||
const initialProductUids = ref<Set<string>>(new Set(products.value.map(p => p.uid)))
|
||||
|
||||
const isFieldLocked = (field: EditorField): boolean => {
|
||||
return props.restrictedMode === true && initialFieldUids.value.has(field.uid)
|
||||
}
|
||||
|
||||
const isProductLocked = (product: EditorProduct): boolean => {
|
||||
return props.restrictedMode === true && initialProductUids.value.has(product.uid)
|
||||
}
|
||||
|
||||
const restrictedMode = computed(() => props.restrictedMode === true)
|
||||
|
||||
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
|
||||
list.map((field, index) => ({
|
||||
...field,
|
||||
@@ -438,6 +475,8 @@ watch(
|
||||
products.value = hydrateProducts(value)
|
||||
products.value.forEach((product) => updateProductTypeMetadata(product))
|
||||
lastEmitted = incomingSerialized
|
||||
initialFieldUids.value = new Set(fields.value.map(f => f.uid))
|
||||
initialProductUids.value = new Set(products.value.map(p => p.uid))
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<select
|
||||
v-model="node.typeComposantId"
|
||||
class="select select-bordered select-sm w-full"
|
||||
:disabled="isLocked"
|
||||
@change="handleComponentTypeSelect(node)"
|
||||
>
|
||||
<option value="">
|
||||
@@ -42,6 +43,7 @@
|
||||
type="text"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Alias du sous-composant"
|
||||
:disabled="isLocked"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -52,13 +54,18 @@
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isRoot"
|
||||
v-if="!isRoot && !isLocked"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else-if="!isRoot && isLocked" class="tooltip tooltip-left" data-tip="Ce sous-composant ne peut pas être supprimé">
|
||||
<button type="button" class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed" disabled>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-4 py-4 space-y-5">
|
||||
@@ -107,8 +114,9 @@
|
||||
type="text"
|
||||
class="input input-bordered input-xs"
|
||||
placeholder="Nom du champ"
|
||||
:disabled="isCustomFieldLocked(index)"
|
||||
/>
|
||||
<select v-model="field.type" class="select select-bordered select-xs">
|
||||
<select v-model="field.type" class="select select-bordered select-xs" :disabled="isCustomFieldLocked(index)">
|
||||
<option value="text">Texte</option>
|
||||
<option value="number">Nombre</option>
|
||||
<option value="select">Liste</option>
|
||||
@@ -117,7 +125,7 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" />
|
||||
<input v-model="field.required" type="checkbox" class="checkbox checkbox-xs" :disabled="isCustomFieldLocked(index)" />
|
||||
Obligatoire
|
||||
</div>
|
||||
<textarea
|
||||
@@ -125,15 +133,26 @@
|
||||
v-model="field.optionsText"
|
||||
class="textarea textarea-bordered textarea-xs h-20"
|
||||
placeholder="Option 1 Option 2"
|
||||
:disabled="isCustomFieldLocked(index)"
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isCustomFieldLocked(index)"
|
||||
type="button"
|
||||
class="btn btn-error btn-xs btn-square"
|
||||
@click="removeCustomField(index)"
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else class="tooltip tooltip-left" data-tip="Ce champ ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,7 +163,7 @@
|
||||
<h4 :class="headingClass">
|
||||
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
|
||||
</h4>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addProduct">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
@@ -179,6 +198,7 @@
|
||||
<select
|
||||
v-model="product.typeProductId"
|
||||
class="select select-bordered select-xs"
|
||||
:disabled="isProductLocked(index)"
|
||||
@change="handleProductTypeSelect(product)"
|
||||
>
|
||||
<option value="">
|
||||
@@ -194,9 +214,18 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-error btn-xs btn-square" @click="removeProduct(index)">
|
||||
<button v-if="!isProductLocked(index)" type="button" class="btn btn-error btn-xs btn-square" @click="removeProduct(index)">
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else class="tooltip tooltip-left" data-tip="Ce produit ne peut pas être supprimé car des éléments utilisent cette catégorie">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed"
|
||||
disabled
|
||||
>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,7 +236,7 @@
|
||||
<h4 :class="headingClass">
|
||||
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
|
||||
</h4>
|
||||
<button type="button" class="btn btn-outline btn-xs" @click="addPiece">
|
||||
<button v-if="!restrictedMode" type="button" class="btn btn-outline btn-xs" @click="addPiece">
|
||||
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||
Ajouter
|
||||
</button>
|
||||
@@ -243,6 +272,7 @@
|
||||
<select
|
||||
v-model="piece.typePieceId"
|
||||
class="select select-bordered select-xs"
|
||||
:disabled="isPieceLocked(index)"
|
||||
@change="handlePieceTypeSelect(piece)"
|
||||
>
|
||||
<option value="">
|
||||
@@ -262,9 +292,14 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
|
||||
<button v-if="!isPieceLocked(index)" type="button" class="btn btn-error btn-xs btn-square" @click="removePiece(index)">
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
<div v-else class="tooltip tooltip-left" data-tip="Cette pièce ne peut pas être supprimée">
|
||||
<button type="button" class="btn btn-ghost btn-xs btn-square opacity-30 cursor-not-allowed" disabled>
|
||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,7 +309,7 @@
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h4 :class="headingClass">Sous-composants</h4>
|
||||
<button
|
||||
v-if="canManageSubcomponents"
|
||||
v-if="canManageSubcomponents && !restrictedMode"
|
||||
type="button"
|
||||
class="btn btn-outline btn-xs"
|
||||
@click="addSubComponent"
|
||||
@@ -317,6 +352,8 @@
|
||||
:product-types="productTypes"
|
||||
:allow-subcomponents="childAllowSubcomponents"
|
||||
:max-subcomponent-depth="maxSubcomponentDepth"
|
||||
:restricted-mode="restrictedMode"
|
||||
:is-locked="isSubcomponentLocked(index)"
|
||||
@remove="removeSubComponent(index)"
|
||||
/>
|
||||
</div>
|
||||
@@ -359,6 +396,8 @@ const props = withDefaults(defineProps<{
|
||||
lockedTypeLabel?: string
|
||||
allowSubcomponents?: boolean
|
||||
maxSubcomponentDepth?: number
|
||||
restrictedMode?: boolean
|
||||
isLocked?: boolean
|
||||
}>(), {
|
||||
depth: 0,
|
||||
componentTypes: () => [],
|
||||
@@ -369,10 +408,52 @@ const props = withDefaults(defineProps<{
|
||||
lockedTypeLabel: '',
|
||||
allowSubcomponents: true,
|
||||
maxSubcomponentDepth: Infinity,
|
||||
restrictedMode: false,
|
||||
isLocked: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['remove'])
|
||||
|
||||
const initialCustomFieldIndices = ref<Set<number>>(new Set())
|
||||
const initialPieceIndices = ref<Set<number>>(new Set())
|
||||
const initialProductIndices = ref<Set<number>>(new Set())
|
||||
const initialSubcomponentIndices = ref<Set<number>>(new Set())
|
||||
|
||||
const initializeLockedIndices = () => {
|
||||
if (props.restrictedMode) {
|
||||
const customFieldsLength = Array.isArray(props.node.customFields) ? props.node.customFields.length : 0
|
||||
const piecesLength = Array.isArray(props.node.pieces) ? props.node.pieces.length : 0
|
||||
const productsLength = Array.isArray(props.node.products) ? props.node.products.length : 0
|
||||
const subcomponentsLength = Array.isArray(props.node.subcomponents) ? props.node.subcomponents.length : 0
|
||||
|
||||
initialCustomFieldIndices.value = new Set(Array.from({ length: customFieldsLength }, (_, i) => i))
|
||||
initialPieceIndices.value = new Set(Array.from({ length: piecesLength }, (_, i) => i))
|
||||
initialProductIndices.value = new Set(Array.from({ length: productsLength }, (_, i) => i))
|
||||
initialSubcomponentIndices.value = new Set(Array.from({ length: subcomponentsLength }, (_, i) => i))
|
||||
}
|
||||
}
|
||||
|
||||
initializeLockedIndices()
|
||||
|
||||
const isCustomFieldLocked = (index: number): boolean => {
|
||||
return props.restrictedMode === true && initialCustomFieldIndices.value.has(index)
|
||||
}
|
||||
|
||||
const isPieceLocked = (index: number): boolean => {
|
||||
return props.restrictedMode === true && initialPieceIndices.value.has(index)
|
||||
}
|
||||
|
||||
const isProductLocked = (index: number): boolean => {
|
||||
return props.restrictedMode === true && initialProductIndices.value.has(index)
|
||||
}
|
||||
|
||||
const isSubcomponentLocked = (index: number): boolean => {
|
||||
return props.restrictedMode === true && initialSubcomponentIndices.value.has(index)
|
||||
}
|
||||
|
||||
const isLocked = computed(() => props.isLocked === true)
|
||||
const restrictedMode = computed(() => props.restrictedMode === true)
|
||||
|
||||
const componentTypes = computed(() => props.componentTypes ?? [])
|
||||
const pieceTypes = computed(() => props.pieceTypes ?? [])
|
||||
const productTypes = computed(() => props.productTypes ?? [])
|
||||
|
||||
@@ -62,19 +62,30 @@ const deepClone = value => JSON.parse(JSON.stringify(value))
|
||||
|
||||
const createFieldKey = () => `cf-${Math.random().toString(16).slice(2)}-${Date.now()}`
|
||||
|
||||
const normalizeCustomField = (field = {}, index = 0) => {
|
||||
const clone = deepClone(field)
|
||||
if (clone.type === 'select') {
|
||||
if (typeof clone.optionsText !== 'string' || !clone.optionsText.length) {
|
||||
if (Array.isArray(clone.options)) {
|
||||
clone.optionsText = clone.options.map(option => String(option).trim()).filter(Boolean).join('\n')
|
||||
} else {
|
||||
clone.optionsText = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
const currentOrder =
|
||||
typeof clone?.orderIndex === 'number' ? clone.orderIndex : index
|
||||
clone.orderIndex = currentOrder
|
||||
if (typeof clone?.__key !== 'string' || !clone.__key) {
|
||||
clone.__key = createFieldKey()
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
const withNormalizedOrder = (items = []) => {
|
||||
if (!Array.isArray(items)) { return [] }
|
||||
return items
|
||||
.map((item, index) => {
|
||||
const clone = deepClone(item)
|
||||
const currentOrder =
|
||||
typeof clone?.orderIndex === 'number' ? clone.orderIndex : index
|
||||
clone.orderIndex = currentOrder
|
||||
if (typeof clone?.__key !== 'string' || !clone.__key) {
|
||||
clone.__key = createFieldKey()
|
||||
}
|
||||
return clone
|
||||
})
|
||||
.map((item, index) => normalizeCustomField(item, index))
|
||||
.sort((a, b) => (a.orderIndex ?? 0) - (b.orderIndex ?? 0))
|
||||
.map((item, index) => ({ ...item, orderIndex: index }))
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
|
||||
@@ -91,5 +91,11 @@ onMounted(async () => {
|
||||
if (!pieceTypes.value.length) {
|
||||
await loadPieceTypes()
|
||||
}
|
||||
console.log('[PieceRequirementsSection] pieceTypes loaded:', pieceTypes.value.map(t => ({ id: t.id, name: t.name })))
|
||||
console.log('[PieceRequirementsSection] requirements on mount:', props.modelValue.map(r => ({ id: r.id, typePieceId: r.typePieceId })))
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
console.log('[PieceRequirementsSection] requirements updated:', newVal.map(r => ({ id: r.id, typePieceId: r.typePieceId })))
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
128
app/components/common/Pagination.vue
Normal file
128
app/components/common/Pagination.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div v-if="totalPages > 1" class="flex items-center justify-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="goToPage(1)"
|
||||
>
|
||||
<IconLucideChevronFirst class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
<IconLucideChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<template v-for="page in visiblePages" :key="page">
|
||||
<span v-if="page === 'ellipsis-start' || page === 'ellipsis-end'" class="px-2">...</span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
:class="page === currentPage ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="goToPage(page)"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
<IconLucideChevronRight class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="goToPage(totalPages)"
|
||||
>
|
||||
<IconLucideChevronLast class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import IconLucideChevronFirst from '~icons/lucide/chevrons-left'
|
||||
import IconLucideChevronLeft from '~icons/lucide/chevron-left'
|
||||
import IconLucideChevronRight from '~icons/lucide/chevron-right'
|
||||
import IconLucideChevronLast from '~icons/lucide/chevrons-right'
|
||||
|
||||
const props = defineProps({
|
||||
currentPage: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
totalPages: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
maxVisiblePages: {
|
||||
type: Number,
|
||||
default: 5
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:currentPage'])
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const pages = []
|
||||
const total = props.totalPages
|
||||
const current = props.currentPage
|
||||
const maxVisible = props.maxVisiblePages
|
||||
|
||||
if (total <= maxVisible + 2) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
return pages
|
||||
}
|
||||
|
||||
// Always show first page
|
||||
pages.push(1)
|
||||
|
||||
const half = Math.floor(maxVisible / 2)
|
||||
let start = Math.max(2, current - half)
|
||||
let end = Math.min(total - 1, current + half)
|
||||
|
||||
// Adjust if near start
|
||||
if (current <= half + 1) {
|
||||
end = maxVisible
|
||||
}
|
||||
// Adjust if near end
|
||||
if (current >= total - half) {
|
||||
start = total - maxVisible + 1
|
||||
}
|
||||
|
||||
if (start > 2) {
|
||||
pages.push('ellipsis-start')
|
||||
}
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (end < total - 1) {
|
||||
pages.push('ellipsis-end')
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
pages.push(total)
|
||||
|
||||
return pages
|
||||
})
|
||||
|
||||
const goToPage = (page) => {
|
||||
if (page >= 1 && page <= props.totalPages && page !== props.currentPage) {
|
||||
emit('update:currentPage', page)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -122,7 +122,7 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const emit = defineEmits(['update:modelValue', 'search'])
|
||||
|
||||
const searchTerm = ref('')
|
||||
const openDropdown = ref(false)
|
||||
@@ -184,11 +184,13 @@ watch(
|
||||
|
||||
watch(
|
||||
baseOptions,
|
||||
() => {
|
||||
if (!openDropdown.value) {
|
||||
searchTerm.value = selectedOption.value ? resolveLabel(selectedOption.value) : searchTerm.value
|
||||
(newOptions) => {
|
||||
console.log('[SearchSelect] baseOptions changed, count:', newOptions.length, 'modelValue:', props.modelValue, 'selectedOption:', selectedOption.value?.id)
|
||||
if (!openDropdown.value && selectedOption.value) {
|
||||
searchTerm.value = resolveLabel(selectedOption.value)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(openDropdown, (isOpen) => {
|
||||
@@ -265,6 +267,7 @@ function handleInput () {
|
||||
if (!openDropdown.value) {
|
||||
openDropdown.value = true
|
||||
}
|
||||
emit('search', searchTerm.value)
|
||||
}
|
||||
|
||||
function closeDropdown () {
|
||||
|
||||
@@ -29,10 +29,65 @@
|
||||
:total="total"
|
||||
:limit="limit"
|
||||
:offset="offset"
|
||||
@related="openRelatedModal"
|
||||
@edit="openEditPage"
|
||||
@delete="confirmDelete"
|
||||
@update:offset="onOffsetChange"
|
||||
/>
|
||||
|
||||
<dialog class="modal" :class="{ 'modal-open': relatedModalOpen }">
|
||||
<div class="modal-box max-w-3xl">
|
||||
<h3 class="text-lg font-bold text-base-content">
|
||||
{{ relatedModalTitle }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-base-content/70">
|
||||
{{ relatedModalSubtitle }}
|
||||
</p>
|
||||
|
||||
<div class="mt-4 rounded-xl border border-base-200 bg-base-100">
|
||||
<div v-if="relatedLoading" class="flex items-center gap-2 px-4 py-6 text-sm text-info">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||
Chargement des éléments liés…
|
||||
</div>
|
||||
|
||||
<div v-else-if="relatedError" class="px-4 py-6 text-sm text-error">
|
||||
{{ relatedError }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="relatedItems.length === 0"
|
||||
class="px-4 py-6 text-sm text-base-content/60"
|
||||
>
|
||||
Aucun élément lié à cette catégorie.
|
||||
</div>
|
||||
|
||||
<ul v-else class="max-h-96 divide-y divide-base-200 overflow-y-auto">
|
||||
<li
|
||||
v-for="entry in relatedItems"
|
||||
:key="entry.id"
|
||||
class="px-2 py-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full flex-col gap-1 rounded-lg px-2 py-2 text-left hover:bg-base-200 focus:bg-base-200 focus:outline-none"
|
||||
@click="openRelatedEdit(entry)"
|
||||
>
|
||||
<span class="font-medium text-base-content">{{ entry.name }}</span>
|
||||
<span v-if="entry.reference" class="text-xs text-base-content/60">
|
||||
Référence: {{ entry.reference }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" @click="closeRelatedModal">
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -41,6 +96,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useHead, useRouter } from "#imports";
|
||||
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
||||
import ModelTypesTable from "~/components/model-types/Table.vue";
|
||||
import { useApi } from "~/composables/useApi";
|
||||
import {
|
||||
deleteModelType,
|
||||
listModelTypes,
|
||||
@@ -82,6 +138,7 @@ let activeController: AbortController | null = null;
|
||||
|
||||
const router = useRouter();
|
||||
const { showError, showSuccess } = useToast();
|
||||
const { get } = useApi();
|
||||
|
||||
const headingText = computed(() => props.heading);
|
||||
const descriptionText = computed(
|
||||
@@ -257,6 +314,165 @@ const confirmDelete = async (item: ModelType) => {
|
||||
}
|
||||
};
|
||||
|
||||
type RelatedEntry = {
|
||||
id: string;
|
||||
name: string;
|
||||
reference?: string | null;
|
||||
};
|
||||
|
||||
const relatedModalOpen = ref(false);
|
||||
const relatedLoading = ref(false);
|
||||
const relatedError = ref<string | null>(null);
|
||||
const relatedItems = ref<RelatedEntry[]>([]);
|
||||
const relatedType = ref<ModelType | null>(null);
|
||||
|
||||
const relatedCategoryLabels: Record<
|
||||
ModelCategory,
|
||||
{ plural: string; singular: string }
|
||||
> = {
|
||||
COMPONENT: { plural: "composants", singular: "composant" },
|
||||
PIECE: { plural: "pièces", singular: "pièce" },
|
||||
PRODUCT: { plural: "produits", singular: "produit" },
|
||||
};
|
||||
|
||||
const relatedModalTitle = computed(() => {
|
||||
const current = relatedType.value;
|
||||
if (!current) {
|
||||
return "Éléments liés";
|
||||
}
|
||||
return `Éléments liés à « ${current.name} »`;
|
||||
});
|
||||
|
||||
const relatedModalSubtitle = computed(() => {
|
||||
const current = relatedType.value;
|
||||
if (!current) {
|
||||
return "";
|
||||
}
|
||||
const labels =
|
||||
relatedCategoryLabels[current.category] ?? relatedCategoryLabels.COMPONENT;
|
||||
const count = relatedItems.value.length;
|
||||
if (relatedLoading.value) {
|
||||
return `Chargement des ${labels.plural}…`;
|
||||
}
|
||||
if (count === 0) {
|
||||
return `Aucun ${labels.singular} lié.`;
|
||||
}
|
||||
if (count === 1) {
|
||||
return `1 ${labels.singular} lié.`;
|
||||
}
|
||||
return `${count} ${labels.plural} liés.`;
|
||||
});
|
||||
|
||||
const extractCollection = (payload: any): any[] => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload;
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member;
|
||||
}
|
||||
if (Array.isArray(payload?.["hydra:member"])) {
|
||||
return payload["hydra:member"];
|
||||
}
|
||||
if (Array.isArray(payload?.items)) {
|
||||
return payload.items;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const buildModelTypeIri = (id: string) => `/api/model_types/${id}`;
|
||||
|
||||
const resolveRelatedConfig = (category: ModelCategory) => {
|
||||
if (category === "COMPONENT") {
|
||||
return { endpoint: "/composants", filterKey: "typeComposant" };
|
||||
}
|
||||
if (category === "PIECE") {
|
||||
return { endpoint: "/pieces", filterKey: "typePiece" };
|
||||
}
|
||||
return { endpoint: "/products", filterKey: "typeProduct" };
|
||||
};
|
||||
|
||||
const resolveRelatedEditBasePath = (category: ModelCategory) => {
|
||||
if (category === "COMPONENT") {
|
||||
return "/component";
|
||||
}
|
||||
if (category === "PIECE") {
|
||||
return "/pieces";
|
||||
}
|
||||
return "/product";
|
||||
};
|
||||
|
||||
const mapRelatedEntry = (item: any): RelatedEntry | null => {
|
||||
if (!item || typeof item !== "object" || typeof item.id !== "string") {
|
||||
return null;
|
||||
}
|
||||
const name =
|
||||
typeof item.name === "string" && item.name.trim()
|
||||
? item.name
|
||||
: "Sans nom";
|
||||
const reference =
|
||||
typeof item.reference === "string" && item.reference.trim()
|
||||
? item.reference
|
||||
: typeof item.code === "string" && item.code.trim()
|
||||
? item.code
|
||||
: null;
|
||||
return {
|
||||
id: item.id,
|
||||
name,
|
||||
reference,
|
||||
};
|
||||
};
|
||||
|
||||
const loadRelatedItems = async (item: ModelType) => {
|
||||
const { endpoint, filterKey } = resolveRelatedConfig(item.category);
|
||||
const params = new URLSearchParams();
|
||||
params.set("itemsPerPage", "200");
|
||||
params.set(filterKey, buildModelTypeIri(item.id));
|
||||
params.set("order[name]", "asc");
|
||||
|
||||
relatedLoading.value = true;
|
||||
relatedError.value = null;
|
||||
relatedItems.value = [];
|
||||
|
||||
try {
|
||||
const result = await get(`${endpoint}?${params.toString()}`);
|
||||
if (!result.success) {
|
||||
relatedError.value =
|
||||
result.error ?? "Impossible de charger les éléments liés.";
|
||||
return;
|
||||
}
|
||||
const collection = extractCollection(result.data);
|
||||
relatedItems.value = collection
|
||||
.map(mapRelatedEntry)
|
||||
.filter((entry): entry is RelatedEntry => Boolean(entry));
|
||||
} catch (error) {
|
||||
relatedError.value = extractErrorMessage(error);
|
||||
} finally {
|
||||
relatedLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openRelatedModal = (item: ModelType) => {
|
||||
relatedType.value = item;
|
||||
relatedModalOpen.value = true;
|
||||
void loadRelatedItems(item);
|
||||
};
|
||||
|
||||
const openRelatedEdit = (entry: RelatedEntry) => {
|
||||
const current = relatedType.value;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
const basePath = resolveRelatedEditBasePath(current.category);
|
||||
relatedModalOpen.value = false;
|
||||
router.push(`${basePath}/${entry.id}/edit`).catch(() => {
|
||||
showError("Navigation impossible vers la fiche d'édition.");
|
||||
});
|
||||
};
|
||||
|
||||
const closeRelatedModal = () => {
|
||||
relatedModalOpen.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => searchInput.value,
|
||||
(value) => {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
minlength="2"
|
||||
maxlength="120"
|
||||
required
|
||||
:disabled="restrictedMode"
|
||||
/>
|
||||
<p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p>
|
||||
</div>
|
||||
@@ -47,6 +48,7 @@
|
||||
rows="4"
|
||||
name="notes"
|
||||
maxlength="2000"
|
||||
:disabled="restrictedMode"
|
||||
></textarea>
|
||||
<p class="mt-1 text-xs text-base-content/70">Saisissez des informations complémentaires (facultatif).</p>
|
||||
</div>
|
||||
@@ -81,6 +83,7 @@
|
||||
v-model="componentStructure"
|
||||
:allow-subcomponents="allowComponentSubcomponents"
|
||||
:max-subcomponent-depth="componentSubcomponentMaxDepth"
|
||||
:restricted-mode="restrictedMode"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -92,7 +95,7 @@
|
||||
Aperçu :
|
||||
<span class="font-medium text-base-content">{{ pieceStructurePreview }}</span>
|
||||
</p>
|
||||
<PieceModelStructureEditor v-model="pieceStructure" />
|
||||
<PieceModelStructureEditor v-model="pieceStructure" :restricted-mode="restrictedMode" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -103,11 +106,30 @@
|
||||
Aperçu :
|
||||
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
|
||||
</p>
|
||||
<PieceModelStructureEditor v-model="productStructure" />
|
||||
<PieceModelStructureEditor v-model="productStructure" :restricted-mode="restrictedMode" />
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<div
|
||||
v-if="restrictedMode && restrictedModeMessage"
|
||||
class="alert alert-info"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
<span>{{ restrictedModeMessage }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="disableSubmit"
|
||||
class="alert alert-warning"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span>{{ disableSubmitMessage }}</span>
|
||||
</div>
|
||||
|
||||
<footer class="flex flex-col gap-3 border-t border-base-300 pt-4 sm:flex-row sm:justify-end">
|
||||
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
||||
Annuler
|
||||
@@ -150,6 +172,10 @@ const props = withDefaults(defineProps<{
|
||||
structureLoading?: boolean
|
||||
allowComponentSubcomponents?: boolean
|
||||
componentSubcomponentMaxDepth?: number
|
||||
disableSubmit?: boolean
|
||||
disableSubmitMessage?: string
|
||||
restrictedMode?: boolean
|
||||
restrictedModeMessage?: string
|
||||
}>(), {
|
||||
initialData: null,
|
||||
saving: false,
|
||||
@@ -157,6 +183,10 @@ const props = withDefaults(defineProps<{
|
||||
structureLoading: false,
|
||||
allowComponentSubcomponents: true,
|
||||
componentSubcomponentMaxDepth: 1,
|
||||
disableSubmit: false,
|
||||
disableSubmitMessage: '',
|
||||
restrictedMode: false,
|
||||
restrictedModeMessage: '',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -173,6 +203,18 @@ const componentSubcomponentMaxDepth = computed(() =>
|
||||
? props.componentSubcomponentMaxDepth
|
||||
: 1,
|
||||
)
|
||||
const disableSubmit = computed(() => props.disableSubmit === true)
|
||||
const disableSubmitMessage = computed(() =>
|
||||
(props.disableSubmitMessage && props.disableSubmitMessage.trim())
|
||||
? props.disableSubmitMessage
|
||||
: 'Cette catégorie ne peut pas être modifiée car des éléments y sont déjà liés.',
|
||||
)
|
||||
const restrictedMode = computed(() => props.restrictedMode === true)
|
||||
const restrictedModeMessage = computed(() =>
|
||||
(props.restrictedModeMessage && props.restrictedModeMessage.trim())
|
||||
? props.restrictedModeMessage
|
||||
: '',
|
||||
)
|
||||
|
||||
const form = reactive<ModelTypePayload>({
|
||||
name: '',
|
||||
@@ -248,7 +290,7 @@ const resetForm = () => {
|
||||
}
|
||||
|
||||
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
|
||||
const isSubmitDisabled = computed(() => saving.value || structureLoading.value)
|
||||
const isSubmitDisabled = computed(() => saving.value || structureLoading.value || disableSubmit.value)
|
||||
|
||||
const validate = () => {
|
||||
errors.name = undefined
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<tr class="text-base-content/70">
|
||||
<th scope="col">Nom</th>
|
||||
<th scope="col">Notes</th>
|
||||
<th scope="col" class="w-32 text-right">Actions</th>
|
||||
<th scope="col" class="w-48 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -45,6 +45,9 @@
|
||||
<span v-else class="text-base-content/50">—</span>
|
||||
</td>
|
||||
<td class="text-right space-x-2">
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||
Liés
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||
Éditer
|
||||
</button>
|
||||
@@ -72,6 +75,9 @@
|
||||
<p class="mt-3 text-sm text-base-content/80" v-if="item.notes">{{ item.notes }}</p>
|
||||
<p class="mt-3 text-sm text-base-content/50" v-else>Pas de notes</p>
|
||||
<footer class="mt-4 flex flex-wrap items-center gap-2 justify-end">
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('related', item)">
|
||||
Liés
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||
Éditer
|
||||
</button>
|
||||
@@ -123,6 +129,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'related', item: ModelType): void;
|
||||
(e: 'edit', item: ModelType): void;
|
||||
(e: 'delete', item: ModelType): void;
|
||||
(e: 'update:offset', offset: number): void;
|
||||
|
||||
114
app/composables/useCategoryEditGuard.ts
Normal file
114
app/composables/useCategoryEditGuard.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
type GuardLabels = {
|
||||
singular: string
|
||||
plural: string
|
||||
verifying: string
|
||||
}
|
||||
|
||||
type GuardConfig = {
|
||||
endpoint: string
|
||||
filterKey: string
|
||||
labels: GuardLabels
|
||||
}
|
||||
|
||||
const extractTotal = (payload: any, fallbackLength: number) => {
|
||||
if (typeof payload?.totalItems === 'number') {
|
||||
return payload.totalItems
|
||||
}
|
||||
if (typeof payload?.['hydra:totalItems'] === 'number') {
|
||||
return payload['hydra:totalItems']
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member.length
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member'].length
|
||||
}
|
||||
return fallbackLength
|
||||
}
|
||||
|
||||
export function useCategoryEditGuard (config: GuardConfig) {
|
||||
const { get } = useApi()
|
||||
const { showInfo } = useToast()
|
||||
|
||||
const linkedCount = ref(0)
|
||||
const linkedLoading = ref(false)
|
||||
|
||||
const loadLinkedCount = async (modelTypeId: string) => {
|
||||
linkedLoading.value = true
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '1')
|
||||
params.set(config.filterKey, `/api/model_types/${modelTypeId}`)
|
||||
|
||||
const result = await get(`${config.endpoint}?${params.toString()}`)
|
||||
if (!result.success) {
|
||||
linkedCount.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
const fallbackLength = Array.isArray(result.data?.member)
|
||||
? result.data.member.length
|
||||
: Array.isArray(result.data?.['hydra:member'])
|
||||
? result.data['hydra:member'].length
|
||||
: 0
|
||||
|
||||
linkedCount.value = extractTotal(result.data, fallbackLength)
|
||||
} catch (error) {
|
||||
linkedCount.value = 0
|
||||
} finally {
|
||||
linkedLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isRestrictedMode = computed(
|
||||
() => !linkedLoading.value && linkedCount.value > 0,
|
||||
)
|
||||
|
||||
const isSubmitBlocked = computed(
|
||||
() => linkedLoading.value,
|
||||
)
|
||||
|
||||
const restrictedModeMessage = computed(() => {
|
||||
if (linkedLoading.value) {
|
||||
return config.labels.verifying
|
||||
}
|
||||
if (linkedCount.value <= 0) {
|
||||
return ''
|
||||
}
|
||||
if (linkedCount.value === 1) {
|
||||
return `Mode restreint : 1 ${config.labels.singular} est déjà lié à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés, mais pas modifier ou supprimer les existants.`
|
||||
}
|
||||
return `Mode restreint : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie. Vous pouvez ajouter de nouveaux champs personnalisés, mais pas modifier ou supprimer les existants.`
|
||||
})
|
||||
|
||||
const submitBlockMessage = computed(() => {
|
||||
if (linkedLoading.value) {
|
||||
return config.labels.verifying
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const guardSubmitOrNotify = () => {
|
||||
if (!isSubmitBlocked.value) {
|
||||
return false
|
||||
}
|
||||
showInfo(submitBlockMessage.value || 'Veuillez patienter...')
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
linkedCount,
|
||||
linkedLoading,
|
||||
isRestrictedMode,
|
||||
isSubmitBlocked,
|
||||
restrictedModeMessage,
|
||||
submitBlockMessage,
|
||||
loadLinkedCount,
|
||||
guardSubmitOrNotify,
|
||||
}
|
||||
}
|
||||
|
||||
67
app/composables/useComponentHistory.ts
Normal file
67
app/composables/useComponentHistory.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
export type ComponentHistoryActor = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type ComponentHistoryEntry = {
|
||||
id: string
|
||||
action: 'create' | 'update' | 'delete' | string
|
||||
createdAt: string
|
||||
actor: ComponentHistoryActor | null
|
||||
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||
snapshot: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const extractItems = (payload: any): ComponentHistoryEntry[] => {
|
||||
if (Array.isArray(payload?.items)) {
|
||||
return payload.items
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member']
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function useComponentHistory () {
|
||||
const { get } = useApi()
|
||||
|
||||
const history = ref<ComponentHistoryEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const loadHistory = async (componentId: string) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await get(`/composants/${componentId}/history`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger l’historique.'
|
||||
history.value = []
|
||||
return result
|
||||
}
|
||||
history.value = extractItems(result.data) as ComponentHistoryEntry[]
|
||||
return { success: true, data: history.value }
|
||||
} catch (err: any) {
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
history.value = []
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
history,
|
||||
loading,
|
||||
error,
|
||||
loadHistory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useConstructeurs } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
|
||||
const composants = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const extractCollection = (payload) => {
|
||||
@@ -24,6 +25,16 @@ const extractCollection = (payload) => {
|
||||
return []
|
||||
}
|
||||
|
||||
const extractTotal = (payload, fallbackLength) => {
|
||||
if (typeof payload?.totalItems === 'number') {
|
||||
return payload.totalItems
|
||||
}
|
||||
if (typeof payload?.['hydra:totalItems'] === 'number') {
|
||||
return payload['hydra:totalItems']
|
||||
}
|
||||
return fallbackLength
|
||||
}
|
||||
|
||||
export function useComposants () {
|
||||
const { showSuccess, showError, showInfo } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
@@ -65,18 +76,56 @@ export function useComposants () {
|
||||
return composant
|
||||
}
|
||||
|
||||
const loadComposants = async () => {
|
||||
/**
|
||||
* Load composants with pagination and search support
|
||||
* @param {Object} options - Query options
|
||||
* @param {string} [options.search] - Search term for name/reference
|
||||
* @param {number} [options.page=1] - Current page (1-based)
|
||||
* @param {number} [options.itemsPerPage=30] - Items per page
|
||||
* @param {string} [options.orderBy='name'] - Field to order by
|
||||
* @param {string} [options.orderDir='asc'] - Order direction (asc/desc)
|
||||
*/
|
||||
const loadComposants = async (options = {}) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/composants')
|
||||
const {
|
||||
search = '',
|
||||
page = 1,
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc'
|
||||
} = options
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
params.set('page', String(page))
|
||||
|
||||
if (search && search.trim()) {
|
||||
params.set('name', search.trim())
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/composants?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||
composants.value = enrichedItems
|
||||
showInfo(`Chargement de ${composants.value.length} composant(s) réussi`)
|
||||
total.value = extractTotal(result.data, items.length)
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: enrichedItems,
|
||||
total: total.value,
|
||||
page,
|
||||
itemsPerPage
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des composants:', error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -89,7 +138,8 @@ export function useComposants () {
|
||||
const result = await post('/composants', normalizedPayload)
|
||||
if (result.success) {
|
||||
const enriched = await withResolvedConstructeurs(result.data)
|
||||
composants.value.push(enriched)
|
||||
composants.value.unshift(enriched)
|
||||
total.value += 1
|
||||
const displayName = result.data?.name
|
||||
|| composantData?.definition?.name
|
||||
|| composantData?.name
|
||||
@@ -134,6 +184,7 @@ export function useComposants () {
|
||||
if (result.success) {
|
||||
const deletedComposant = composants.value.find(comp => comp.id === id)
|
||||
composants.value = composants.value.filter(comp => comp.id !== id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
showSuccess(`Composant "${deletedComposant?.name || 'inconnu'}" supprimé avec succès`)
|
||||
}
|
||||
return result
|
||||
@@ -150,6 +201,7 @@ export function useComposants () {
|
||||
|
||||
return {
|
||||
composants,
|
||||
total,
|
||||
loading,
|
||||
loadComposants,
|
||||
createComposant,
|
||||
|
||||
@@ -10,18 +10,28 @@ const normalizeRequirementList = (value, relationKey) => {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
return value.map((entry) => {
|
||||
return value.map((entry, index) => {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return entry
|
||||
}
|
||||
const normalized = { ...entry }
|
||||
const relationField = relationKey.replace('Id', '')
|
||||
const relationValue = normalized[relationField]
|
||||
console.log(`[normalizeRequirementList] Entry ${index}:`, {
|
||||
relationKey,
|
||||
relationField,
|
||||
hasRelationKey: !!normalized[relationKey],
|
||||
relationValue,
|
||||
relationValueType: typeof relationValue
|
||||
})
|
||||
if (relationKey && !normalized[relationKey]) {
|
||||
const relationValue = normalized[relationKey.replace('Id', '')]
|
||||
const relationId = extractRelationId(relationValue)
|
||||
console.log(`[normalizeRequirementList] Extracted ID:`, relationId)
|
||||
if (relationId) {
|
||||
normalized[relationKey] = relationId
|
||||
}
|
||||
}
|
||||
console.log(`[normalizeRequirementList] Normalized entry:`, normalized)
|
||||
return normalized
|
||||
})
|
||||
}
|
||||
@@ -56,7 +66,7 @@ const extractCollection = (payload) => {
|
||||
|
||||
export function useMachineTypesApi () {
|
||||
const { showSuccess, showError, showInfo } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
const { get, post, put, delete: del } = useApi()
|
||||
|
||||
const loadMachineTypes = async () => {
|
||||
loading.value = true
|
||||
@@ -94,7 +104,7 @@ export function useMachineTypesApi () {
|
||||
const updateMachineType = async (id, typeData) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await patch(`/type_machines/${id}`, typeData)
|
||||
const result = await put(`/type_machines/${id}`, typeData)
|
||||
if (result.success) {
|
||||
const normalized = normalizeMachineType(result.data)
|
||||
const index = machineTypes.value.findIndex(type => type.id === id)
|
||||
@@ -130,19 +140,28 @@ export function useMachineTypesApi () {
|
||||
}
|
||||
}
|
||||
|
||||
const getMachineTypeById = async (id) => {
|
||||
// D'abord chercher dans le cache local
|
||||
const localType = machineTypes.value.find(type => type.id === id)
|
||||
if (localType) {
|
||||
return { success: true, data: localType }
|
||||
const getMachineTypeById = async (id, forceRefresh = false) => {
|
||||
// D'abord chercher dans le cache local (sauf si forceRefresh)
|
||||
if (!forceRefresh) {
|
||||
const localType = machineTypes.value.find(type => type.id === id)
|
||||
if (localType) {
|
||||
return { success: true, data: localType }
|
||||
}
|
||||
}
|
||||
|
||||
// Si pas trouvé localement, récupérer depuis l'API
|
||||
// Récupérer depuis l'API
|
||||
try {
|
||||
const result = await get(`/type_machines/${id}`)
|
||||
if (result.success) {
|
||||
// Ajouter au cache local
|
||||
machineTypes.value.push(normalizeMachineType(result.data))
|
||||
const normalized = normalizeMachineType(result.data)
|
||||
// Mettre à jour le cache local
|
||||
const index = machineTypes.value.findIndex(type => type.id === id)
|
||||
if (index !== -1) {
|
||||
machineTypes.value[index] = normalized
|
||||
} else {
|
||||
machineTypes.value.push(normalized)
|
||||
}
|
||||
return { success: true, data: normalized }
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
|
||||
67
app/composables/usePieceHistory.ts
Normal file
67
app/composables/usePieceHistory.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
export type PieceHistoryActor = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type PieceHistoryEntry = {
|
||||
id: string
|
||||
action: 'create' | 'update' | 'delete' | string
|
||||
createdAt: string
|
||||
actor: PieceHistoryActor | null
|
||||
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||
snapshot: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const extractItems = (payload: any): PieceHistoryEntry[] => {
|
||||
if (Array.isArray(payload?.items)) {
|
||||
return payload.items
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member']
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function usePieceHistory () {
|
||||
const { get } = useApi()
|
||||
|
||||
const history = ref<PieceHistoryEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const loadHistory = async (pieceId: string) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await get(`/pieces/${pieceId}/history`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger l’historique.'
|
||||
history.value = []
|
||||
return result
|
||||
}
|
||||
history.value = extractItems(result.data) as PieceHistoryEntry[]
|
||||
return { success: true, data: history.value }
|
||||
} catch (err: any) {
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
history.value = []
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
history,
|
||||
loading,
|
||||
error,
|
||||
loadHistory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useConstructeurs } from './useConstructeurs'
|
||||
import { extractRelationId, normalizeRelationIds } from '~/shared/apiRelations'
|
||||
|
||||
const pieces = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
|
||||
const extractCollection = (payload) => {
|
||||
@@ -24,6 +25,16 @@ const extractCollection = (payload) => {
|
||||
return []
|
||||
}
|
||||
|
||||
const extractTotal = (payload, fallbackLength) => {
|
||||
if (typeof payload?.totalItems === 'number') {
|
||||
return payload.totalItems
|
||||
}
|
||||
if (typeof payload?.['hydra:totalItems'] === 'number') {
|
||||
return payload['hydra:totalItems']
|
||||
}
|
||||
return fallbackLength
|
||||
}
|
||||
|
||||
export function usePieces () {
|
||||
const { showSuccess, showError, showInfo } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
@@ -45,6 +56,15 @@ export function usePieces () {
|
||||
piece.productId = productId
|
||||
}
|
||||
}
|
||||
const productIds = Array.isArray(piece.productIds) ? piece.productIds.filter(Boolean) : []
|
||||
if (productIds.length === 0 && piece.productId) {
|
||||
piece.productIds = [piece.productId]
|
||||
} else if (productIds.length > 0) {
|
||||
piece.productIds = productIds.map((id) => String(id))
|
||||
if (!piece.productId) {
|
||||
piece.productId = piece.productIds[0] || null
|
||||
}
|
||||
}
|
||||
const ids = uniqueConstructeurIds(
|
||||
piece.constructeurIds,
|
||||
piece.constructeurs,
|
||||
@@ -65,18 +85,58 @@ export function usePieces () {
|
||||
return piece
|
||||
}
|
||||
|
||||
const loadPieces = async () => {
|
||||
/**
|
||||
* Load pieces with pagination and search support
|
||||
* @param {Object} options - Query options
|
||||
* @param {string} [options.search] - Search term for name/reference
|
||||
* @param {number} [options.page=1] - Current page (1-based)
|
||||
* @param {number} [options.itemsPerPage=30] - Items per page
|
||||
* @param {string} [options.orderBy='name'] - Field to order by
|
||||
* @param {string} [options.orderDir='asc'] - Order direction (asc/desc)
|
||||
*/
|
||||
const loadPieces = async (options = {}) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get('/pieces')
|
||||
const {
|
||||
search = '',
|
||||
page = 1,
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc'
|
||||
} = options
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
params.set('page', String(page))
|
||||
|
||||
if (search && search.trim()) {
|
||||
// API Platform uses property filters
|
||||
params.set('name', search.trim())
|
||||
}
|
||||
|
||||
// API Platform OrderFilter syntax: order[field]=direction
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/pieces?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||
pieces.value = enrichedItems
|
||||
showInfo(`Chargement de ${pieces.value.length} pièce(s) réussi`)
|
||||
total.value = extractTotal(result.data, items.length)
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: enrichedItems,
|
||||
total: total.value,
|
||||
page,
|
||||
itemsPerPage
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du chargement des pièces:', error)
|
||||
return { success: false, error: error.message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -89,7 +149,8 @@ export function usePieces () {
|
||||
const result = await post('/pieces', normalizedPayload)
|
||||
if (result.success) {
|
||||
const enriched = await withResolvedConstructeurs(result.data)
|
||||
pieces.value.push(enriched)
|
||||
pieces.value.unshift(enriched)
|
||||
total.value += 1
|
||||
const displayName = result.data?.name
|
||||
|| pieceData?.definition?.name
|
||||
|| pieceData?.name
|
||||
@@ -134,6 +195,7 @@ export function usePieces () {
|
||||
if (result.success) {
|
||||
const deletedPiece = pieces.value.find(piece => piece.id === id)
|
||||
pieces.value = pieces.value.filter(piece => piece.id !== id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
showSuccess(`Pièce "${deletedPiece?.name || 'inconnu'}" supprimée avec succès`)
|
||||
}
|
||||
return result
|
||||
@@ -150,6 +212,7 @@ export function usePieces () {
|
||||
|
||||
return {
|
||||
pieces,
|
||||
total,
|
||||
loading,
|
||||
loadPieces,
|
||||
createPiece,
|
||||
|
||||
67
app/composables/useProductHistory.ts
Normal file
67
app/composables/useProductHistory.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { ref } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
export type ProductHistoryActor = {
|
||||
id: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export type ProductHistoryEntry = {
|
||||
id: string
|
||||
action: 'create' | 'update' | 'delete' | string
|
||||
createdAt: string
|
||||
actor: ProductHistoryActor | null
|
||||
diff: Record<string, { from: unknown; to: unknown }> | null
|
||||
snapshot: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const extractItems = (payload: any): ProductHistoryEntry[] => {
|
||||
if (Array.isArray(payload?.items)) {
|
||||
return payload.items
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member']
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function useProductHistory () {
|
||||
const { get } = useApi()
|
||||
|
||||
const history = ref<ProductHistoryEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const loadHistory = async (productId: string) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await get(`/products/${productId}/history`)
|
||||
if (!result.success) {
|
||||
error.value = result.error ?? 'Impossible de charger l’historique.'
|
||||
history.value = []
|
||||
return result
|
||||
}
|
||||
history.value = extractItems(result.data) as ProductHistoryEntry[]
|
||||
return { success: true, data: history.value }
|
||||
} catch (err: any) {
|
||||
const message = err?.message ?? 'Erreur inconnue'
|
||||
error.value = message
|
||||
history.value = []
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
history,
|
||||
loading,
|
||||
error,
|
||||
loadHistory,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +42,16 @@ const extractCollection = (payload) => {
|
||||
return []
|
||||
}
|
||||
|
||||
const extractTotal = (payload, fallbackLength) => {
|
||||
if (typeof payload?.totalItems === 'number') {
|
||||
return payload.totalItems
|
||||
}
|
||||
if (typeof payload?.['hydra:totalItems'] === 'number') {
|
||||
return payload['hydra:totalItems']
|
||||
}
|
||||
return fallbackLength
|
||||
}
|
||||
|
||||
export function useProducts () {
|
||||
const { showError } = useToast()
|
||||
const { get, post, patch, delete: del } = useApi()
|
||||
@@ -77,32 +87,62 @@ export function useProducts () {
|
||||
return product
|
||||
}
|
||||
|
||||
/**
|
||||
* Load products with pagination and search support
|
||||
* @param {Object} options - Query options
|
||||
* @param {string} [options.search] - Search term for name/reference
|
||||
* @param {number} [options.page=1] - Current page (1-based)
|
||||
* @param {number} [options.itemsPerPage=30] - Items per page
|
||||
* @param {string} [options.orderBy='name'] - Field to order by
|
||||
* @param {string} [options.orderDir='asc'] - Order direction (asc/desc)
|
||||
* @param {boolean} [options.force=false] - Force reload even if already loaded
|
||||
*/
|
||||
const loadProducts = async (options = {}) => {
|
||||
const {
|
||||
search = '',
|
||||
page = 1,
|
||||
itemsPerPage = 30,
|
||||
orderBy = 'name',
|
||||
orderDir = 'asc',
|
||||
force = false
|
||||
} = options
|
||||
|
||||
if (loading.value) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: products.value, total: total.value },
|
||||
}
|
||||
}
|
||||
if (loaded.value && !options.force) {
|
||||
return {
|
||||
success: true,
|
||||
data: { items: products.value, total: total.value },
|
||||
data: { items: products.value, total: total.value, page, itemsPerPage },
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const result = await get('/products?itemsPerPage=100')
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', String(itemsPerPage))
|
||||
params.set('page', String(page))
|
||||
|
||||
if (search && search.trim()) {
|
||||
params.set('name', search.trim())
|
||||
}
|
||||
|
||||
params.set(`order[${orderBy}]`, orderDir)
|
||||
|
||||
const result = await get(`/products?${params.toString()}`)
|
||||
if (result.success) {
|
||||
const items = extractCollection(result.data)
|
||||
const enrichedItems = await Promise.all(items.map((item) => withResolvedConstructeurs(item)))
|
||||
products.value = enrichedItems
|
||||
total.value = typeof result.data?.totalItems === 'number'
|
||||
? result.data.totalItems
|
||||
: items.length
|
||||
total.value = extractTotal(result.data, items.length)
|
||||
loaded.value = true
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: enrichedItems,
|
||||
total: total.value,
|
||||
page,
|
||||
itemsPerPage
|
||||
}
|
||||
}
|
||||
} else if (result.error) {
|
||||
error.value = result.error
|
||||
showError(`Impossible de charger les produits: ${result.error}`)
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full mt-1"
|
||||
placeholder="Nom ou référence…"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -48,6 +49,7 @@
|
||||
id="component-catalog-sort"
|
||||
v-model="sortField"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleSortChange"
|
||||
>
|
||||
<option value="name">Nom</option>
|
||||
<option value="createdAt">Date de création</option>
|
||||
@@ -64,14 +66,33 @@
|
||||
id="component-catalog-dir"
|
||||
v-model="sortDirection"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleSortChange"
|
||||
>
|
||||
<option value="asc">Ascendant</option>
|
||||
<option value="desc">Descendant</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="component-catalog-per-page"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="component-catalog-per-page"
|
||||
v-model.number="itemsPerPage"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handlePerPageChange"
|
||||
>
|
||||
<option :value="20">20</option>
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50 lg:text-right">
|
||||
{{ visibleComposants.length }} / {{ composantsTotal }} résultat{{ visibleComposants.length > 1 ? 's' : '' }}
|
||||
{{ composantsOnPage }} / {{ composantsTotal }} résultat{{ composantsTotal > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -83,54 +104,62 @@
|
||||
Aucun composant n'a encore été créé.
|
||||
</p>
|
||||
|
||||
<p v-else-if="!visibleComposants.length" class="text-sm text-base-content/70">
|
||||
<p v-else-if="!composantsList.length" class="text-sm text-base-content/70">
|
||||
Aucun composant ne correspond à votre recherche.
|
||||
</p>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="table table-sm md:table-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-24">Aperçu</th>
|
||||
<th>Nom</th>
|
||||
<th>Référence</th>
|
||||
<th>Type de composant</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="component in visibleComposants" :key="component.id">
|
||||
<td class="align-middle">
|
||||
<DocumentThumbnail
|
||||
:document="resolvePrimaryDocument(component)"
|
||||
:alt="resolvePreviewAlt(component)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ component.name || 'Composant sans nom' }}</td>
|
||||
<td>{{ component.reference || '—' }}</td>
|
||||
<td>{{ resolveComponentType(component) }}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink
|
||||
:to="`/component/${component.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="loadingComposants"
|
||||
@click="handleDeleteComponent(component)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm md:table-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-24">Aperçu</th>
|
||||
<th>Nom</th>
|
||||
<th>Référence</th>
|
||||
<th>Type de composant</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="component in composantsList" :key="component.id">
|
||||
<td class="align-middle">
|
||||
<DocumentThumbnail
|
||||
:document="resolvePrimaryDocument(component)"
|
||||
:alt="resolvePreviewAlt(component)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ component.name || 'Composant sans nom' }}</td>
|
||||
<td>{{ component.reference || '—' }}</td>
|
||||
<td>{{ resolveComponentType(component) }}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink
|
||||
:to="`/component/${component.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="loadingComposants"
|
||||
@click="handleDeleteComponent(component)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@update:current-page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -144,13 +173,41 @@ import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { usePersistedSort } from '~/composables/usePersistedSort'
|
||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
|
||||
const { showError } = useToast()
|
||||
const { composants, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
||||
const { composants, total, loadComposants, loading: loadingComposantsRef, deleteComposant } = useComposants()
|
||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const loadingComposants = computed(() => loadingComposantsRef.value)
|
||||
|
||||
// Pagination state
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = ref(30)
|
||||
const composantsTotal = computed(() => total.value)
|
||||
const composantsOnPage = computed(() => composants.value.length)
|
||||
const totalPages = computed(() => Math.ceil(composantsTotal.value / itemsPerPage.value) || 1)
|
||||
|
||||
// Search state with debounce
|
||||
const searchTerm = ref('')
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const debouncedSearch = () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
fetchComposants()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Sort state
|
||||
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
||||
'component-catalog',
|
||||
{ field: 'name', direction: 'asc' },
|
||||
)
|
||||
|
||||
// Enrichir les composants avec les types de composants complets
|
||||
const composantsList = computed(() => {
|
||||
return (composants.value || []).map((composant) => {
|
||||
@@ -161,13 +218,31 @@ const composantsList = computed(() => {
|
||||
}
|
||||
})
|
||||
})
|
||||
const composantsTotal = computed(() => composantsList.value.length)
|
||||
|
||||
const searchTerm = ref('')
|
||||
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
||||
'component-catalog',
|
||||
{ field: 'name', direction: 'asc' },
|
||||
)
|
||||
const fetchComposants = async () => {
|
||||
await loadComposants({
|
||||
search: searchTerm.value,
|
||||
page: currentPage.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
orderBy: sortField.value,
|
||||
orderDir: sortDirection.value
|
||||
})
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
const handleSortChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
const handlePerPageChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
const resolvePrimaryDocument = (component: Record<string, any>) => {
|
||||
const documents = Array.isArray(component?.documents) ? component.documents : []
|
||||
@@ -230,58 +305,6 @@ const resolveDeleteGuard = (component: Record<string, any>) => {
|
||||
}
|
||||
}
|
||||
|
||||
const resolveComparableName = (component: Record<string, any>) => {
|
||||
const toComparable = (value?: string | null) =>
|
||||
(value ?? '').toString().trim().toLowerCase()
|
||||
|
||||
return (
|
||||
toComparable(component?.name) ||
|
||||
toComparable(component?.reference) ||
|
||||
toComparable(component?.id)
|
||||
)
|
||||
}
|
||||
|
||||
const resolveComparableDate = (component: Record<string, any>) => {
|
||||
const raw = component?.createdAt ?? component?.created_at ?? null
|
||||
if (!raw) {
|
||||
return 0
|
||||
}
|
||||
const parsed = new Date(raw).getTime()
|
||||
return Number.isNaN(parsed) ? 0 : parsed
|
||||
}
|
||||
|
||||
const visibleComposants = computed(() => {
|
||||
const term = searchTerm.value.trim().toLowerCase()
|
||||
const source = composantsList.value || []
|
||||
|
||||
const filtered = term
|
||||
? source.filter((component) => {
|
||||
const name = (component?.name || '').toLowerCase()
|
||||
const reference = (component?.reference || '').toLowerCase()
|
||||
return (
|
||||
name.includes(term) ||
|
||||
reference.includes(term)
|
||||
)
|
||||
})
|
||||
: [...source]
|
||||
|
||||
const direction = sortDirection.value === 'asc' ? 1 : -1
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
if (sortField.value === 'name') {
|
||||
return (
|
||||
resolveComparableName(a).localeCompare(
|
||||
resolveComparableName(b),
|
||||
'fr',
|
||||
{ sensitivity: 'base' }
|
||||
) * direction
|
||||
)
|
||||
}
|
||||
|
||||
return (resolveComparableDate(a) - resolveComparableDate(b)) * direction
|
||||
})
|
||||
})
|
||||
|
||||
const handleDeleteComponent = async (component: Record<string, any>) => {
|
||||
const { blockingReasons, hasCustomFields } = resolveDeleteGuard(component)
|
||||
|
||||
@@ -310,11 +333,13 @@ const handleDeleteComponent = async (component: Record<string, any>) => {
|
||||
}
|
||||
|
||||
await deleteComposant(component.id)
|
||||
// Reload current page after deletion
|
||||
fetchComposants()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadComposants(),
|
||||
fetchComposants(),
|
||||
loadComponentTypes()
|
||||
])
|
||||
})
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
:initial-data="initialData"
|
||||
:lock-category="true"
|
||||
:saving="saving"
|
||||
:disable-submit="isSubmitBlocked"
|
||||
:disable-submit-message="submitBlockMessage"
|
||||
:restricted-mode="isRestrictedMode"
|
||||
:restricted-mode-message="restrictedModeMessage"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
@@ -38,6 +42,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useHead, useRoute, useRouter } from '#imports'
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -48,6 +53,23 @@ const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
||||
|
||||
const {
|
||||
isRestrictedMode,
|
||||
isSubmitBlocked,
|
||||
restrictedModeMessage,
|
||||
submitBlockMessage,
|
||||
loadLinkedCount,
|
||||
guardSubmitOrNotify,
|
||||
} = useCategoryEditGuard({
|
||||
endpoint: '/composants',
|
||||
filterKey: 'typeComposant',
|
||||
labels: {
|
||||
singular: 'composant',
|
||||
plural: 'composants',
|
||||
verifying: 'Vérification des composants liés en cours…',
|
||||
},
|
||||
})
|
||||
|
||||
const title = computed(() =>
|
||||
initialData.value?.name
|
||||
? `Modifier « ${initialData.value.name} »`
|
||||
@@ -88,6 +110,8 @@ const loadCategory = async () => {
|
||||
notes: response.notes ?? response.description ?? '',
|
||||
structure: response.structure ?? undefined,
|
||||
}
|
||||
|
||||
await loadLinkedCount(id)
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
await navigateBackToList()
|
||||
@@ -101,6 +125,9 @@ const handleCancel = () => {
|
||||
}
|
||||
|
||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
if (guardSubmitOrNotify()) {
|
||||
return
|
||||
}
|
||||
const id = String(route.params.id)
|
||||
saving.value = true
|
||||
try {
|
||||
|
||||
@@ -176,6 +176,18 @@
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="getStructureProducts(selectedTypeStructure).length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Produits imposés</h3>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li
|
||||
v-for="(product, index) in getStructureProducts(selectedTypeStructure)"
|
||||
:key="product.role || product.typeProductId || product.familyCode || index"
|
||||
>
|
||||
{{ resolveProductLabel(product) }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="getStructureSubcomponents(selectedTypeStructure).length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Sous-composants</h3>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
@@ -189,7 +201,7 @@
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!getStructureCustomFields(selectedTypeStructure).length && !getStructurePieces(selectedTypeStructure).length && !getStructureSubcomponents(selectedTypeStructure).length"
|
||||
v-if="!getStructureCustomFields(selectedTypeStructure).length && !getStructurePieces(selectedTypeStructure).length && !getStructureProducts(selectedTypeStructure).length && !getStructureSubcomponents(selectedTypeStructure).length"
|
||||
class="text-xs text-gray-500"
|
||||
>
|
||||
Ce squelette ne définit pas encore de pièces, sous-composants ou valeurs par défaut.
|
||||
@@ -198,6 +210,50 @@
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="structureSelections.hasAny"
|
||||
class="space-y-3 rounded-lg border border-base-200 bg-base-200/30 p-4"
|
||||
>
|
||||
<header class="space-y-1">
|
||||
<h2 class="font-semibold text-base-content">Sélections actuelles</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Voici les pièces, produits et sous-composants réellement choisis pour ce composant.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div v-if="structureSelections.pieces.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Pièces choisies</h3>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li v-for="entry in structureSelections.pieces" :key="`selected-piece-${entry.path}-${entry.id}`">
|
||||
<span class="font-medium">{{ entry.resolvedName }}</span>
|
||||
<span class="text-xs text-base-content/70"> — {{ entry.requirementLabel }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="structureSelections.products.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Produits choisis</h3>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li v-for="entry in structureSelections.products" :key="`selected-product-${entry.path}-${entry.id}`">
|
||||
<span class="font-medium">{{ entry.resolvedName }}</span>
|
||||
<span class="text-xs text-base-content/70"> — {{ entry.requirementLabel }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="structureSelections.components.length" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm text-base-content">Sous-composants choisis</h3>
|
||||
<ul class="list-disc list-inside space-y-1 text-sm">
|
||||
<li v-for="entry in structureSelections.components" :key="`selected-component-${entry.path}-${entry.id}`">
|
||||
<span class="font-medium">{{ entry.resolvedName }}</span>
|
||||
<span class="text-xs text-base-content/70"> — {{ entry.requirementLabel }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="customFieldInputs.length" class="space-y-4 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<header class="space-y-1">
|
||||
<h2 class="font-semibold text-base-content">Champs personnalisés</h2>
|
||||
@@ -378,6 +434,74 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-semibold text-base-content">Historique</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Qui a changé quoi, et quand.
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="historyEntries.length" class="badge badge-outline">
|
||||
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||
Chargement de l’historique…
|
||||
</div>
|
||||
|
||||
<div v-else-if="historyError" class="alert alert-warning">
|
||||
<span>{{ historyError }}</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
|
||||
Aucun changement enregistré pour le moment.
|
||||
</p>
|
||||
|
||||
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
<li
|
||||
v-for="entry in historyEntries"
|
||||
:key="entry.id"
|
||||
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
|
||||
<span class="font-medium text-base-content">
|
||||
{{ historyActionLabel(entry.action) }}
|
||||
</span>
|
||||
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
Par {{ entry.actor?.label || 'Inconnu' }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="historyDiffEntries(entry).length"
|
||||
class="mt-2 space-y-1 text-xs"
|
||||
>
|
||||
<li
|
||||
v-for="diffEntry in historyDiffEntries(entry)"
|
||||
:key="`${entry.id}-${diffEntry.field}`"
|
||||
class="flex flex-col gap-0.5"
|
||||
>
|
||||
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
|
||||
<span class="text-base-content/60">
|
||||
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p
|
||||
v-else-if="entry.snapshot?.name"
|
||||
class="mt-2 text-xs text-base-content/70"
|
||||
>
|
||||
{{ entry.snapshot.name }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||
Annuler
|
||||
@@ -400,12 +524,17 @@ import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useComponentHistory, type ComponentHistoryEntry } from '~/composables/useComponentHistory'
|
||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||
@@ -434,11 +563,21 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { get } = useApi()
|
||||
const { componentTypes, loadComponentTypes } = useComponentTypes()
|
||||
const { updateComposant } = useComposants()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
const { updateComposant, loadComposants, composants: componentCatalogRef } = useComposants()
|
||||
const { pieces, loadPieces } = usePieces()
|
||||
const { products, loadProducts } = useProducts()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
||||
const toast = useToast()
|
||||
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
||||
const {
|
||||
history,
|
||||
loading: historyLoading,
|
||||
error: historyError,
|
||||
loadHistory,
|
||||
} = useComponentHistory()
|
||||
|
||||
const component = ref<any | null>(null)
|
||||
const loading = ref(true)
|
||||
@@ -449,6 +588,88 @@ const loadingDocuments = ref(false)
|
||||
const componentDocuments = ref<any[]>([])
|
||||
const previewDocument = ref<any | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const historyEntries = computed<ComponentHistoryEntry[]>(() => history.value)
|
||||
|
||||
const historyFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
reference: 'Référence',
|
||||
prix: 'Prix',
|
||||
structure: 'Structure',
|
||||
typeComposant: 'Catégorie',
|
||||
product: 'Produit lié',
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
const historyActionLabel = (action: string) => {
|
||||
if (action === 'create') {
|
||||
return 'Création'
|
||||
}
|
||||
if (action === 'delete') {
|
||||
return 'Suppression'
|
||||
}
|
||||
return 'Modification'
|
||||
}
|
||||
|
||||
const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
const formatHistoryDate = (value: string) => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
return historyDateFormatter.format(date)
|
||||
}
|
||||
|
||||
const formatHistoryValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—'
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return '—'
|
||||
}
|
||||
return value.map((item) => formatHistoryValue(item)).join(', ')
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const maybeRecord = value as Record<string, unknown>
|
||||
const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
|
||||
const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
|
||||
if (name && id) {
|
||||
return `${name} (#${id})`
|
||||
}
|
||||
if (name) {
|
||||
return name
|
||||
}
|
||||
if (id) {
|
||||
return `#${id}`
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch (error) {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const historyDiffEntries = (entry: ComponentHistoryEntry) => {
|
||||
const diff = entry.diff ?? {}
|
||||
return Object.entries(diff).map(([field, change]) => {
|
||||
const label = historyFieldLabels[field] ?? field
|
||||
const fromLabel = formatHistoryValue(change?.from)
|
||||
const toLabel = formatHistoryValue(change?.to)
|
||||
return {
|
||||
field,
|
||||
label,
|
||||
fromLabel,
|
||||
toLabel,
|
||||
}
|
||||
})
|
||||
}
|
||||
const selectedTypeId = ref<string>('')
|
||||
const editionForm = reactive({
|
||||
name: '' as string,
|
||||
@@ -500,6 +721,46 @@ const documentPreviewSrc = (document: any) => {
|
||||
}
|
||||
return document.path
|
||||
}
|
||||
|
||||
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||
const pieceTypeLabelMap = computed(() => ({
|
||||
...Object.fromEntries(
|
||||
(pieceTypes.value || [])
|
||||
.filter((type: any) => type?.id)
|
||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||
),
|
||||
...fetchedPieceTypeMap.value,
|
||||
}))
|
||||
const fetchedProductTypeMap = ref<Record<string, string>>({})
|
||||
const productTypeLabelMap = computed(() => ({
|
||||
...Object.fromEntries(
|
||||
(productTypes.value || [])
|
||||
.filter((type: any) => type?.id)
|
||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||
),
|
||||
...fetchedProductTypeMap.value,
|
||||
}))
|
||||
const pieceCatalogMap = computed(() =>
|
||||
new Map(
|
||||
(pieces.value || [])
|
||||
.filter((item: any) => item?.id)
|
||||
.map((item: any) => [String(item.id), item]),
|
||||
),
|
||||
)
|
||||
const productCatalogMap = computed(() =>
|
||||
new Map(
|
||||
(products.value || [])
|
||||
.filter((item: any) => item?.id)
|
||||
.map((item: any) => [String(item.id), item]),
|
||||
),
|
||||
)
|
||||
const componentCatalogMap = computed(() =>
|
||||
new Map(
|
||||
(componentCatalogRef.value || [])
|
||||
.filter((item: any) => item?.id)
|
||||
.map((item: any) => [String(item.id), item]),
|
||||
),
|
||||
)
|
||||
const documentThumbnailClass = (document: any) => {
|
||||
if (shouldInlinePdf(document) || (isImageDocument(document) && document?.path)) {
|
||||
return 'h-24 w-20'
|
||||
@@ -594,6 +855,15 @@ const selectedTypeStructure = computed<ComponentModelStructure | null>(() => {
|
||||
return structure ? normalizeStructureForEditor(structure) : null
|
||||
})
|
||||
|
||||
const refreshCustomFieldInputs = (
|
||||
structureOverride?: ComponentModelStructure | null,
|
||||
valuesOverride?: any[] | null,
|
||||
) => {
|
||||
const structure = structureOverride ?? selectedTypeStructure.value ?? null
|
||||
const values = valuesOverride ?? component.value?.customFieldValues ?? null
|
||||
customFieldInputs.value = buildCustomFieldInputs(structure, values)
|
||||
}
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
customFieldInputs.value.every((field) => {
|
||||
if (!field.required) {
|
||||
@@ -641,7 +911,9 @@ const fetchComponent = async () => {
|
||||
const customValues = await getCustomFieldValuesByEntity('composant', result.data.id)
|
||||
if (customValues.success && Array.isArray(customValues.data)) {
|
||||
component.value.customFieldValues = customValues.data
|
||||
refreshCustomFieldInputs(undefined, customValues.data)
|
||||
}
|
||||
await loadHistory(result.data.id)
|
||||
} else {
|
||||
component.value = null
|
||||
componentDocuments.value = []
|
||||
@@ -677,10 +949,7 @@ watch(
|
||||
void ensureConstructeurs(editionForm.constructeurIds)
|
||||
}
|
||||
|
||||
customFieldInputs.value = buildCustomFieldInputs(
|
||||
currentStructure,
|
||||
currentComponent.customFieldValues,
|
||||
)
|
||||
refreshCustomFieldInputs(currentStructure, currentComponent.customFieldValues)
|
||||
|
||||
initialized = true
|
||||
},
|
||||
@@ -691,10 +960,7 @@ watch(selectedTypeStructure, (currentStructure) => {
|
||||
if (!component.value) {
|
||||
return
|
||||
}
|
||||
customFieldInputs.value = buildCustomFieldInputs(
|
||||
currentStructure,
|
||||
component.value.customFieldValues,
|
||||
)
|
||||
refreshCustomFieldInputs(currentStructure, component.value.customFieldValues)
|
||||
})
|
||||
|
||||
const submitEdition = async () => {
|
||||
@@ -719,7 +985,7 @@ const submitEdition = async () => {
|
||||
if (rawPrice) {
|
||||
const parsed = Number(rawPrice)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
payload.prix = parsed
|
||||
payload.prix = String(parsed)
|
||||
}
|
||||
} else {
|
||||
payload.prix = null
|
||||
@@ -1002,6 +1268,10 @@ const getStructurePieces = (structure: ComponentModelStructure | null) => {
|
||||
return Array.isArray(structure?.pieces) ? structure.pieces : []
|
||||
}
|
||||
|
||||
const getStructureProducts = (structure: ComponentModelStructure | null) => {
|
||||
return Array.isArray(structure?.products) ? structure.products : []
|
||||
}
|
||||
|
||||
const getStructureSubcomponents = (structure: ComponentModelStructure | null) => {
|
||||
if (Array.isArray(structure?.subcomponents)) {
|
||||
return structure.subcomponents
|
||||
@@ -1010,6 +1280,9 @@ const getStructureSubcomponents = (structure: ComponentModelStructure | null) =>
|
||||
return Array.isArray(legacy) ? legacy : []
|
||||
}
|
||||
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0
|
||||
|
||||
const resolvePieceLabel = (piece: Record<string, any>) => {
|
||||
const parts: string[] = []
|
||||
if (piece.role) {
|
||||
@@ -1019,6 +1292,8 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
|
||||
parts.push(piece.typePiece.name)
|
||||
} else if (piece.typePieceLabel) {
|
||||
parts.push(piece.typePieceLabel)
|
||||
} else if (piece.typePieceId && pieceTypeLabelMap.value[piece.typePieceId]) {
|
||||
parts.push(pieceTypeLabelMap.value[piece.typePieceId])
|
||||
} else if (piece.typePiece?.code) {
|
||||
parts.push(`Famille ${piece.typePiece.code}`)
|
||||
} else if (piece.familyCode) {
|
||||
@@ -1029,6 +1304,91 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
|
||||
return parts.length ? parts.join(' • ') : 'Pièce'
|
||||
}
|
||||
|
||||
const fetchPieceTypeNames = async (ids: string[]) => {
|
||||
const missing = ids.filter((id) => id && !pieceTypeLabelMap.value[id])
|
||||
if (!missing.length) {
|
||||
return
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
missing.map((id) => get(`/model_types/${id}`)),
|
||||
)
|
||||
const next = { ...fetchedPieceTypeMap.value }
|
||||
results.forEach((result, index) => {
|
||||
if (result.status !== 'fulfilled') {
|
||||
return
|
||||
}
|
||||
const data = result.value?.data
|
||||
const name = data?.name || data?.code
|
||||
if (name) {
|
||||
next[missing[index]] = name
|
||||
}
|
||||
})
|
||||
fetchedPieceTypeMap.value = next
|
||||
}
|
||||
|
||||
const resolveProductLabel = (product: Record<string, any>) => {
|
||||
const parts: string[] = []
|
||||
if (product.role) {
|
||||
parts.push(product.role)
|
||||
}
|
||||
if (product.typeProduct?.name) {
|
||||
parts.push(product.typeProduct.name)
|
||||
} else if (product.typeProductLabel) {
|
||||
parts.push(product.typeProductLabel)
|
||||
} else if (product.typeProductId && productTypeLabelMap.value[product.typeProductId]) {
|
||||
parts.push(productTypeLabelMap.value[product.typeProductId])
|
||||
} else if (product.typeProduct?.code) {
|
||||
parts.push(`Catégorie ${product.typeProduct.code}`)
|
||||
} else if (product.familyCode) {
|
||||
parts.push(`Catégorie ${product.familyCode}`)
|
||||
} else if (product.typeProductId) {
|
||||
parts.push(`#${product.typeProductId}`)
|
||||
}
|
||||
return parts.length ? parts.join(' • ') : 'Produit'
|
||||
}
|
||||
|
||||
const fetchProductTypeNames = async (ids: string[]) => {
|
||||
const missing = ids.filter((id) => id && !productTypeLabelMap.value[id])
|
||||
if (!missing.length) {
|
||||
return
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
missing.map((id) => get(`/model_types/${id}`)),
|
||||
)
|
||||
const next = { ...fetchedProductTypeMap.value }
|
||||
results.forEach((result, index) => {
|
||||
if (result.status !== 'fulfilled') {
|
||||
return
|
||||
}
|
||||
const data = result.value?.data
|
||||
const name = data?.name || data?.code
|
||||
if (name) {
|
||||
next[missing[index]] = name
|
||||
}
|
||||
})
|
||||
fetchedProductTypeMap.value = next
|
||||
}
|
||||
|
||||
watch(
|
||||
selectedTypeStructure,
|
||||
(structure) => {
|
||||
const pieceIds = getStructurePieces(structure)
|
||||
.map((piece: any) => piece?.typePieceId)
|
||||
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||
if (pieceIds.length) {
|
||||
fetchPieceTypeNames(Array.from(new Set(pieceIds))).catch(() => {})
|
||||
}
|
||||
|
||||
const productIds = getStructureProducts(structure)
|
||||
.map((product: any) => product?.typeProductId)
|
||||
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||
if (productIds.length) {
|
||||
fetchProductTypeNames(Array.from(new Set(productIds))).catch(() => {})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const resolveSubcomponentLabel = (node: Record<string, any>) => {
|
||||
const parts: string[] = []
|
||||
if (node.alias) {
|
||||
@@ -1055,6 +1415,104 @@ const resolveSubcomponentLabel = (node: Record<string, any>) => {
|
||||
return parts.length ? parts.join(' • ') : 'Sous-composant'
|
||||
}
|
||||
|
||||
type SelectionEntry = {
|
||||
id: string
|
||||
path: string
|
||||
requirementLabel: string
|
||||
resolvedName: string
|
||||
}
|
||||
|
||||
const collectStructureSelections = (root: any): {
|
||||
pieces: SelectionEntry[]
|
||||
products: SelectionEntry[]
|
||||
components: SelectionEntry[]
|
||||
} => {
|
||||
const piecesSelected: SelectionEntry[] = []
|
||||
const productsSelected: SelectionEntry[] = []
|
||||
const componentsSelected: SelectionEntry[] = []
|
||||
|
||||
if (!root || typeof root !== 'object') {
|
||||
return { pieces: piecesSelected, products: productsSelected, components: componentsSelected }
|
||||
}
|
||||
|
||||
const visitNode = (node: any, fallbackPath = 'racine') => {
|
||||
if (!node || typeof node !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const nodePath = isNonEmptyString(node.path) ? node.path : fallbackPath
|
||||
|
||||
const nodePieces = Array.isArray(node.pieces) ? node.pieces : []
|
||||
nodePieces.forEach((entry: any, index: number) => {
|
||||
const selectedId = entry?.selectedPieceId
|
||||
if (!isNonEmptyString(selectedId)) {
|
||||
return
|
||||
}
|
||||
const definition = entry?.definition ?? entry
|
||||
const catalogPiece = pieceCatalogMap.value.get(selectedId)
|
||||
piecesSelected.push({
|
||||
id: selectedId,
|
||||
path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:piece-${index + 1}`,
|
||||
requirementLabel: resolvePieceLabel(definition),
|
||||
resolvedName: catalogPiece?.name || selectedId,
|
||||
})
|
||||
})
|
||||
|
||||
const nodeProducts = Array.isArray(node.products) ? node.products : []
|
||||
nodeProducts.forEach((entry: any, index: number) => {
|
||||
const selectedId = entry?.selectedProductId
|
||||
if (!isNonEmptyString(selectedId)) {
|
||||
return
|
||||
}
|
||||
const definition = entry?.definition ?? entry
|
||||
const catalogProduct = productCatalogMap.value.get(selectedId)
|
||||
productsSelected.push({
|
||||
id: selectedId,
|
||||
path: isNonEmptyString(entry?.path) ? entry.path : `${nodePath}:product-${index + 1}`,
|
||||
requirementLabel: resolveProductLabel(definition),
|
||||
resolvedName: catalogProduct?.name || selectedId,
|
||||
})
|
||||
})
|
||||
|
||||
const nodeChildren = Array.isArray(node.subcomponents)
|
||||
? node.subcomponents
|
||||
: Array.isArray(node.subComponents)
|
||||
? node.subComponents
|
||||
: []
|
||||
|
||||
nodeChildren.forEach((child: any, index: number) => {
|
||||
const selectedId = child?.selectedComponentId
|
||||
if (isNonEmptyString(selectedId)) {
|
||||
const definition = child?.definition ?? child
|
||||
const catalogComponent = componentCatalogMap.value.get(selectedId)
|
||||
componentsSelected.push({
|
||||
id: selectedId,
|
||||
path: isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`,
|
||||
requirementLabel: resolveSubcomponentLabel(definition),
|
||||
resolvedName: catalogComponent?.name || selectedId,
|
||||
})
|
||||
}
|
||||
|
||||
visitNode(child, isNonEmptyString(child?.path) ? child.path : `${nodePath}:subcomponent-${index + 1}`)
|
||||
})
|
||||
}
|
||||
|
||||
visitNode(root, isNonEmptyString(root?.path) ? root.path : 'racine')
|
||||
|
||||
return { pieces: piecesSelected, products: productsSelected, components: componentsSelected }
|
||||
}
|
||||
|
||||
const structureSelections = computed(() => {
|
||||
const selections = collectStructureSelections(component.value?.structure)
|
||||
const total =
|
||||
selections.pieces.length + selections.products.length + selections.components.length
|
||||
return {
|
||||
...selections,
|
||||
total,
|
||||
hasAny: total > 0,
|
||||
}
|
||||
})
|
||||
|
||||
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
|
||||
customFieldName: field.name,
|
||||
customFieldType: field.type,
|
||||
@@ -1154,7 +1612,15 @@ const saveCustomFieldValues = async (updatedComponent: any) => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.allSettled([loadComponentTypes(), fetchComponent()])
|
||||
await Promise.allSettled([
|
||||
loadComponentTypes(),
|
||||
loadPieceTypes(),
|
||||
loadProductTypes(),
|
||||
loadPieces({ itemsPerPage: 500 }),
|
||||
loadProducts({ itemsPerPage: 500, force: true }),
|
||||
loadComposants({ itemsPerPage: 500 }),
|
||||
fetchComponent(),
|
||||
])
|
||||
loading.value = false
|
||||
if (component.value?.id) {
|
||||
await refreshDocuments()
|
||||
|
||||
@@ -212,6 +212,9 @@
|
||||
:pieces-loading="piecesLoading"
|
||||
:products-loading="productsLoading"
|
||||
:components-loading="componentsLoading"
|
||||
:piece-type-label-map="pieceTypeLabelMap"
|
||||
:product-type-label-map="productTypeLabelMap"
|
||||
:component-type-label-map="componentTypeLabelMap"
|
||||
/>
|
||||
<p v-else class="text-xs text-error">
|
||||
Impossible de générer les emplacements définis par le squelette.
|
||||
@@ -349,7 +352,10 @@ import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useProductTypes } from '~/composables/useProductTypes'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
@@ -370,22 +376,22 @@ interface ComponentCatalogType extends ModelType {
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { get } = useApi()
|
||||
|
||||
const { componentTypes, loadComponentTypes, loadingComponentTypes } = useComponentTypes()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { productTypes, loadProductTypes } = useProductTypes()
|
||||
const {
|
||||
createComposant,
|
||||
composants: componentCatalogRef,
|
||||
loadComposants,
|
||||
loading: componentsLoading,
|
||||
} = useComposants()
|
||||
const {
|
||||
pieces: pieceCatalogRef,
|
||||
loadPieces,
|
||||
loading: piecesLoading,
|
||||
} = usePieces()
|
||||
const {
|
||||
products: productCatalogRef,
|
||||
loadProducts,
|
||||
loading: productsLoading,
|
||||
} = useProducts()
|
||||
const toast = useToast()
|
||||
@@ -414,6 +420,30 @@ const structureDataLoading = computed(
|
||||
() => piecesLoading.value || componentsLoading.value || productsLoading.value,
|
||||
)
|
||||
|
||||
const fetchedPieceTypeMap = ref<Record<string, string>>({})
|
||||
const pieceTypeLabelMap = computed(() => ({
|
||||
...Object.fromEntries(
|
||||
(pieceTypes.value || [])
|
||||
.filter((type: any) => type?.id)
|
||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||
),
|
||||
...fetchedPieceTypeMap.value,
|
||||
}))
|
||||
const productTypeLabelMap = computed(() =>
|
||||
Object.fromEntries(
|
||||
(productTypes.value || [])
|
||||
.filter((type: any) => type?.id)
|
||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||
),
|
||||
)
|
||||
const componentTypeLabelMap = computed(() =>
|
||||
Object.fromEntries(
|
||||
(componentTypes.value || [])
|
||||
.filter((type: any) => type?.id)
|
||||
.map((type: any) => [type.id, type.name || type.code || '']),
|
||||
),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.query.typeId,
|
||||
(value) => {
|
||||
@@ -778,6 +808,8 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
|
||||
parts.push(piece.typePiece.name)
|
||||
} else if (piece.typePieceLabel) {
|
||||
parts.push(piece.typePieceLabel)
|
||||
} else if (piece.typePieceId && pieceTypeLabelMap.value[piece.typePieceId]) {
|
||||
parts.push(pieceTypeLabelMap.value[piece.typePieceId])
|
||||
} else if (piece.typePiece?.code) {
|
||||
parts.push(`Famille ${piece.typePiece.code}`)
|
||||
} else if (piece.familyCode) {
|
||||
@@ -788,6 +820,42 @@ const resolvePieceLabel = (piece: Record<string, any>) => {
|
||||
return parts.length ? parts.join(' • ') : 'Pièce'
|
||||
}
|
||||
|
||||
const fetchPieceTypeNames = async (ids: string[]) => {
|
||||
const missing = ids.filter((id) => id && !pieceTypeLabelMap.value[id])
|
||||
if (!missing.length) {
|
||||
return
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
missing.map((id) => get(`/model_types/${id}`)),
|
||||
)
|
||||
const next = { ...fetchedPieceTypeMap.value }
|
||||
results.forEach((result, index) => {
|
||||
if (result.status !== 'fulfilled') {
|
||||
return
|
||||
}
|
||||
const data = result.value?.data
|
||||
const name = data?.name || data?.code
|
||||
if (name) {
|
||||
next[missing[index]] = name
|
||||
}
|
||||
})
|
||||
fetchedPieceTypeMap.value = next
|
||||
}
|
||||
|
||||
watch(
|
||||
selectedTypeStructure,
|
||||
(structure) => {
|
||||
const ids = getStructurePieces(structure)
|
||||
.map((piece: any) => piece?.typePieceId)
|
||||
.filter((id: any): id is string => typeof id === 'string' && id.trim().length > 0)
|
||||
if (!ids.length) {
|
||||
return
|
||||
}
|
||||
fetchPieceTypeNames(Array.from(new Set(ids))).catch(() => {})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const resolveProductLabel = (product: Record<string, any>) => {
|
||||
const parts: string[] = []
|
||||
if (product.role) {
|
||||
@@ -870,7 +938,7 @@ const submitCreation = async () => {
|
||||
if (rawPrice) {
|
||||
const parsed = Number(rawPrice)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
payload.prix = parsed
|
||||
payload.prix = String(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -934,9 +1002,8 @@ const submitCreation = async () => {
|
||||
onMounted(async () => {
|
||||
await Promise.allSettled([
|
||||
loadComponentTypes(),
|
||||
loadPieces(),
|
||||
loadComposants(),
|
||||
loadProducts(),
|
||||
loadPieceTypes(),
|
||||
loadProductTypes(),
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -472,10 +472,11 @@ import IconLucideChevronDown from '~icons/lucide/chevron-down'
|
||||
import IconLucideSettings2 from '~icons/lucide/settings-2'
|
||||
import IconLucideTag from '~icons/lucide/tag'
|
||||
import { formatPhone } from '~/utils/formatters/phone'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
|
||||
const { sites, loading, loadSites, createSite } = useSites()
|
||||
const { machineTypes, loadMachineTypes } = useMachineTypesApi()
|
||||
const { createMachineFromType, deleteMachine } = useMachines()
|
||||
const { machines, loadMachines, createMachineFromType, deleteMachine } = useMachines()
|
||||
|
||||
// Data
|
||||
const showAddSiteModal = ref(false)
|
||||
@@ -517,8 +518,50 @@ const categories = computed(() => {
|
||||
return Array.from(cats)
|
||||
})
|
||||
|
||||
const machinesWithType = computed(() => {
|
||||
return machines.value.map((machine) => {
|
||||
const resolvedTypeMachineId = machine.typeMachineId || extractRelationId(machine.typeMachine)
|
||||
const resolvedTypeMachine = resolvedTypeMachineId
|
||||
? machineTypes.value.find(type => type.id === resolvedTypeMachineId) || null
|
||||
: null
|
||||
|
||||
return {
|
||||
...machine,
|
||||
typeMachineId: resolvedTypeMachineId || machine.typeMachineId,
|
||||
typeMachine:
|
||||
machine.typeMachine && typeof machine.typeMachine === 'object'
|
||||
? machine.typeMachine
|
||||
: resolvedTypeMachine
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const machinesBySiteId = computed(() => {
|
||||
const map = new Map()
|
||||
|
||||
machinesWithType.value.forEach((machine) => {
|
||||
const siteId = machine.siteId || extractRelationId(machine.site)
|
||||
if (!siteId) { return }
|
||||
|
||||
if (!map.has(siteId)) {
|
||||
map.set(siteId, [])
|
||||
}
|
||||
|
||||
map.get(siteId).push(machine)
|
||||
})
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const sitesWithMachines = computed(() => {
|
||||
return sites.value.map((site) => ({
|
||||
...site,
|
||||
machines: machinesBySiteId.value.get(site.id) || []
|
||||
}))
|
||||
})
|
||||
|
||||
const totalMachines = computed(() => {
|
||||
return sites.value.reduce((total, site) => {
|
||||
return sitesWithMachines.value.reduce((total, site) => {
|
||||
return total + (site.machines?.length || 0)
|
||||
}, 0)
|
||||
})
|
||||
@@ -532,7 +575,7 @@ const formatPhoneDisplay = (value) => {
|
||||
}
|
||||
|
||||
const filteredSites = computed(() => {
|
||||
let filtered = sites.value
|
||||
let filtered = sitesWithMachines.value
|
||||
|
||||
// Filtrer par terme de recherche
|
||||
if (searchTerm.value) {
|
||||
@@ -551,9 +594,11 @@ const filteredSites = computed(() => {
|
||||
})
|
||||
|
||||
const machineMatches = site.machines?.some(
|
||||
machine =>
|
||||
machine.name.toLowerCase().includes(lowerTerm) ||
|
||||
machine.reference?.toLowerCase().includes(lowerTerm)
|
||||
machine => {
|
||||
const name = (machine.name || '').toLowerCase()
|
||||
const reference = (machine.reference || '').toLowerCase()
|
||||
return name.includes(lowerTerm) || reference.includes(lowerTerm)
|
||||
}
|
||||
)
|
||||
|
||||
return siteMatches || machineMatches
|
||||
@@ -637,6 +682,7 @@ const handleCreateMachine = async () => {
|
||||
newMachine.typeMachineId = ''
|
||||
newMachine.reference = ''
|
||||
showAddMachineModal.value = false
|
||||
await loadMachines()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -671,6 +717,7 @@ const confirmDeleteMachine = async (machine) => {
|
||||
const result = await deleteMachine(machine.id)
|
||||
if (result.success) {
|
||||
showSuccess(`Machine "${machine.name}" supprimée avec succès`)
|
||||
await loadMachines()
|
||||
} else {
|
||||
showError(`Erreur lors de la suppression: ${result.error}`)
|
||||
}
|
||||
@@ -698,6 +745,6 @@ const getCategoryBadgeClass = (category) => {
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadSites(), loadMachineTypes()])
|
||||
await Promise.all([loadSites(), loadMachineTypes(), loadMachines()])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
import IconLucideClipboardList from '~icons/lucide/clipboard-list'
|
||||
import IconLucideList from '~icons/lucide/list'
|
||||
@@ -142,6 +143,20 @@ const parseOptions = (field = {}) => {
|
||||
return []
|
||||
}
|
||||
|
||||
const toModelTypeIri = (value) => {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
if (typeof value === 'string' && value.startsWith('/api/model_types/')) {
|
||||
return value
|
||||
}
|
||||
const relationId = extractRelationId(value)
|
||||
if (relationId) {
|
||||
return `/api/model_types/${relationId}`
|
||||
}
|
||||
return typeof value === 'string' ? `/api/model_types/${value}` : undefined
|
||||
}
|
||||
|
||||
const normalizeCustomFields = (fields = []) =>
|
||||
fields
|
||||
.filter(field => field?.name && field.name.trim() !== '')
|
||||
@@ -165,9 +180,9 @@ const toIntegerOrNull = (value, fallback = null) => {
|
||||
|
||||
const normalizeComponentRequirements = (requirements = []) =>
|
||||
requirements
|
||||
.filter(req => req?.typeComposantId)
|
||||
.filter(req => req?.typeComposantId || req?.typeComposant)
|
||||
.map((req, index) => ({
|
||||
typeComposantId: req.typeComposantId,
|
||||
typeComposant: toModelTypeIri(req.typeComposantId || req.typeComposant),
|
||||
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||
minCount: toIntegerOrNull(req.minCount, 1),
|
||||
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||
@@ -180,9 +195,9 @@ const normalizeComponentRequirements = (requirements = []) =>
|
||||
|
||||
const normalizePieceRequirements = (requirements = []) =>
|
||||
requirements
|
||||
.filter(req => req?.typePieceId)
|
||||
.filter(req => req?.typePieceId || req?.typePiece)
|
||||
.map((req, index) => ({
|
||||
typePieceId: req.typePieceId,
|
||||
typePiece: toModelTypeIri(req.typePieceId || req.typePiece),
|
||||
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||
minCount: toIntegerOrNull(req.minCount, 0),
|
||||
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||
@@ -195,9 +210,9 @@ const normalizePieceRequirements = (requirements = []) =>
|
||||
|
||||
const normalizeProductRequirements = (requirements = []) =>
|
||||
requirements
|
||||
.filter(req => req?.typeProductId)
|
||||
.filter(req => req?.typeProductId || req?.typeProduct)
|
||||
.map((req, index) => ({
|
||||
typeProductId: req.typeProductId,
|
||||
typeProduct: toModelTypeIri(req.typeProductId || req.typeProduct),
|
||||
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||
minCount: toIntegerOrNull(req.minCount, 0),
|
||||
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||
|
||||
@@ -273,18 +273,19 @@
|
||||
</label>
|
||||
<SearchSelect
|
||||
:model-value="entry.pieceId || ''"
|
||||
:options="getPieceOptions(requirement, entry)"
|
||||
:loading="piecesLoading"
|
||||
:options="getPieceOptions(requirement, entry, entryIndex)"
|
||||
:loading="piecesLoading || pieceLoadingByKey[getPieceKey(requirement, entryIndex)]"
|
||||
size="sm"
|
||||
placeholder="Rechercher une pièce…"
|
||||
empty-text="Aucune pièce disponible"
|
||||
:option-label="pieceOptionLabel"
|
||||
:option-description="pieceOptionDescription"
|
||||
@search="(term) => fetchPieceOptions(requirement, entryIndex, term)"
|
||||
@update:modelValue="setPieceRequirementPiece(requirement, entryIndex, $event || '')"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="getPieceOptions(requirement, entry).length === 0"
|
||||
v-if="getPieceOptions(requirement, entry, entryIndex).length === 0"
|
||||
class="text-xs text-error"
|
||||
>
|
||||
Aucune pièce disponible pour cette famille.
|
||||
@@ -743,6 +744,7 @@ import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||
import { useComposants } from '~/composables/useComposants'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { useProducts } from '~/composables/useProducts'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { sanitizeDefinitionOverrides } from '~/shared/modelUtils'
|
||||
import SearchSelect from '~/components/common/SearchSelect.vue'
|
||||
@@ -754,12 +756,13 @@ import IconLucideAlertTriangle from '~icons/lucide/alert-triangle'
|
||||
import IconLucideCheckCircle2 from '~icons/lucide/check-circle-2'
|
||||
import IconLucideCircle from '~icons/lucide/circle'
|
||||
|
||||
const { createMachine, createMachineFromType } = useMachines()
|
||||
const { createMachine, createMachineFromType, reconfigureSkeleton } = useMachines()
|
||||
const { sites, loadSites } = useSites()
|
||||
const { machineTypes, loadMachineTypes, loading: machineTypesLoading } = useMachineTypesApi()
|
||||
const { composants, loadComposants, loading: composantsLoading } = useComposants()
|
||||
const { pieces, loadPieces, loading: piecesLoading } = usePieces()
|
||||
const { products, loadProducts, loading: productsLoading } = useProducts()
|
||||
const { get } = useApi()
|
||||
const toast = useToast()
|
||||
|
||||
const submitting = ref(false)
|
||||
@@ -842,6 +845,85 @@ const productById = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
const pieceOptionsByKey = ref({})
|
||||
const pieceLoadingByKey = ref({})
|
||||
|
||||
const extractCollection = (payload) => {
|
||||
if (Array.isArray(payload)) {
|
||||
return payload
|
||||
}
|
||||
if (Array.isArray(payload?.member)) {
|
||||
return payload.member
|
||||
}
|
||||
if (Array.isArray(payload?.['hydra:member'])) {
|
||||
return payload['hydra:member']
|
||||
}
|
||||
if (Array.isArray(payload?.data)) {
|
||||
return payload.data
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const getPieceKey = (requirement, entryIndex) => `${requirement?.id || 'req'}:${entryIndex}`
|
||||
|
||||
const findPieceInCachedOptions = (id) => {
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
const buckets = Object.values(pieceOptionsByKey.value || {})
|
||||
for (const bucket of buckets) {
|
||||
if (!Array.isArray(bucket)) {
|
||||
continue
|
||||
}
|
||||
const found = bucket.find((piece) => piece?.id === id)
|
||||
if (found) {
|
||||
return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const cachePieceIfMissing = (piece) => {
|
||||
if (!piece?.id) {
|
||||
return
|
||||
}
|
||||
if (pieceById.value.has(piece.id)) {
|
||||
return
|
||||
}
|
||||
const current = Array.isArray(pieces.value) ? pieces.value : []
|
||||
pieces.value = [...current, piece]
|
||||
}
|
||||
|
||||
const fetchPieceOptions = async (requirement, entryIndex, term = '') => {
|
||||
const key = getPieceKey(requirement, entryIndex)
|
||||
if (pieceLoadingByKey.value[key]) {
|
||||
return
|
||||
}
|
||||
|
||||
const requirementTypeId = requirement?.typePieceId || requirement?.typePiece?.id || null
|
||||
const params = new URLSearchParams()
|
||||
params.set('itemsPerPage', '50')
|
||||
if (term && term.trim()) {
|
||||
params.set('name', term.trim())
|
||||
}
|
||||
if (requirementTypeId) {
|
||||
params.set('typePiece', `/api/model_types/${requirementTypeId}`)
|
||||
}
|
||||
|
||||
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: true }
|
||||
try {
|
||||
const result = await get(`/pieces?${params.toString()}`)
|
||||
if (result.success) {
|
||||
pieceOptionsByKey.value = {
|
||||
...pieceOptionsByKey.value,
|
||||
[key]: extractCollection(result.data)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
pieceLoadingByKey.value = { ...pieceLoadingByKey.value, [key]: false }
|
||||
}
|
||||
}
|
||||
|
||||
const isPlainObject = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
|
||||
const toTrimmedString = (value) => {
|
||||
@@ -1077,7 +1159,12 @@ const getComponentOptions = (requirement, currentEntry) => {
|
||||
})
|
||||
}
|
||||
|
||||
const getPieceOptions = (requirement, currentEntry) => {
|
||||
const getPieceOptions = (requirement, currentEntry, entryIndex) => {
|
||||
const key = getPieceKey(requirement, entryIndex)
|
||||
const cached = pieceOptionsByKey.value[key]
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const requirementTypeId = requirement?.typePieceId || requirement?.typePiece?.id || null
|
||||
const usedIds = new Set(
|
||||
selectedPieceIds.value.filter((id) => id && (!currentEntry || id !== currentEntry.pieceId)),
|
||||
@@ -1241,8 +1328,11 @@ const setPieceRequirementPiece = (requirement, index, pieceId) => {
|
||||
if (!entry) return
|
||||
entry.pieceId = pieceId || null
|
||||
if (pieceId) {
|
||||
const piece = findPieceById(pieceId)
|
||||
const piece = findPieceById(pieceId) || findPieceInCachedOptions(pieceId)
|
||||
entry.typePieceId = piece?.typePieceId || requirement?.typePieceId || null
|
||||
if (piece) {
|
||||
cachePieceIfMissing(piece)
|
||||
}
|
||||
} else {
|
||||
entry.typePieceId = requirement?.typePieceId || null
|
||||
}
|
||||
@@ -1259,7 +1349,7 @@ const findPieceById = (id) => {
|
||||
if (!id) {
|
||||
return null
|
||||
}
|
||||
return pieceById.value.get(id) || null
|
||||
return pieceById.value.get(id) || findPieceInCachedOptions(id) || null
|
||||
}
|
||||
|
||||
const findProductById = (id) => {
|
||||
@@ -1519,6 +1609,7 @@ const addPieceSelectionEntry = (requirement) => {
|
||||
...entries,
|
||||
createPieceSelectionEntry(requirement),
|
||||
]
|
||||
fetchPieceOptions(requirement, entries.length).catch(() => {})
|
||||
}
|
||||
|
||||
const removePieceSelectionEntry = (requirementId, index) => {
|
||||
@@ -2096,6 +2187,9 @@ const initializeRequirementSelections = (type) => {
|
||||
const initialCount = Math.max(min, requirement.required ? 1 : 0)
|
||||
if (initialCount > 0) {
|
||||
pieceRequirementSelections[requirement.id] = Array.from({ length: initialCount }, () => createPieceSelectionEntry(requirement))
|
||||
pieceRequirementSelections[requirement.id].forEach((_, index) => {
|
||||
fetchPieceOptions(requirement, index).catch(() => {})
|
||||
})
|
||||
} else {
|
||||
pieceRequirementSelections[requirement.id] = []
|
||||
}
|
||||
@@ -2158,22 +2252,22 @@ const finalizeMachineCreation = async () => {
|
||||
productLinks = validationResult.productLinks
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...baseMachineData,
|
||||
...(hasRequirements
|
||||
? {
|
||||
componentLinks,
|
||||
pieceLinks,
|
||||
productLinks
|
||||
}
|
||||
: {})
|
||||
}
|
||||
|
||||
const result = hasRequirements
|
||||
? await createMachine(payload)
|
||||
? await createMachine(baseMachineData)
|
||||
: await createMachineFromType(baseMachineData, type)
|
||||
|
||||
if (result.success) {
|
||||
if (hasRequirements && result.data?.id) {
|
||||
const skeletonResult = await reconfigureSkeleton(result.data.id, {
|
||||
componentLinks,
|
||||
pieceLinks,
|
||||
productLinks,
|
||||
})
|
||||
if (!skeletonResult.success) {
|
||||
toast.showError(skeletonResult.error || 'Impossible d\'enregistrer les pièces/composants')
|
||||
return
|
||||
}
|
||||
}
|
||||
newMachine.name = ''
|
||||
newMachine.siteId = ''
|
||||
newMachine.typeMachineId = ''
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
:initial-data="initialData"
|
||||
:lock-category="true"
|
||||
:saving="saving"
|
||||
:disable-submit="isSubmitBlocked"
|
||||
:disable-submit-message="submitBlockMessage"
|
||||
:restricted-mode="isRestrictedMode"
|
||||
:restricted-mode-message="restrictedModeMessage"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
@@ -38,6 +42,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useHead, useRoute, useRouter } from '#imports'
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -48,6 +53,23 @@ const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
||||
|
||||
const {
|
||||
isRestrictedMode,
|
||||
isSubmitBlocked,
|
||||
restrictedModeMessage,
|
||||
submitBlockMessage,
|
||||
loadLinkedCount,
|
||||
guardSubmitOrNotify,
|
||||
} = useCategoryEditGuard({
|
||||
endpoint: '/pieces',
|
||||
filterKey: 'typePiece',
|
||||
labels: {
|
||||
singular: 'pièce',
|
||||
plural: 'pièces',
|
||||
verifying: 'Vérification des pièces liées en cours…',
|
||||
},
|
||||
})
|
||||
|
||||
const title = computed(() =>
|
||||
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de pièce',
|
||||
)
|
||||
@@ -86,6 +108,8 @@ const loadCategory = async () => {
|
||||
notes: response.notes ?? response.description ?? '',
|
||||
structure: response.structure ?? undefined,
|
||||
}
|
||||
|
||||
await loadLinkedCount(id)
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
await navigateBackToList()
|
||||
@@ -99,6 +123,9 @@ const handleCancel = () => {
|
||||
}
|
||||
|
||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
if (guardSubmitOrNotify()) {
|
||||
return
|
||||
}
|
||||
const id = String(route.params.id)
|
||||
saving.value = true
|
||||
try {
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
type="text"
|
||||
class="input input-bordered input-sm w-full mt-1"
|
||||
placeholder="Nom ou référence…"
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -47,6 +48,7 @@
|
||||
id="piece-catalog-sort"
|
||||
v-model="sortField"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleSortChange"
|
||||
>
|
||||
<option value="name">Nom</option>
|
||||
<option value="createdAt">Date de création</option>
|
||||
@@ -63,14 +65,33 @@
|
||||
id="piece-catalog-dir"
|
||||
v-model="sortDirection"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handleSortChange"
|
||||
>
|
||||
<option value="asc">Ascendant</option>
|
||||
<option value="desc">Descendant</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label
|
||||
class="text-xs font-semibold uppercase tracking-wide text-base-content/70"
|
||||
for="piece-catalog-per-page"
|
||||
>
|
||||
Par page
|
||||
</label>
|
||||
<select
|
||||
id="piece-catalog-per-page"
|
||||
v-model.number="itemsPerPage"
|
||||
class="select select-bordered select-sm"
|
||||
@change="handlePerPageChange"
|
||||
>
|
||||
<option :value="20">20</option>
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/50 lg:text-right">
|
||||
{{ visiblePieces.length }} / {{ piecesTotal }} résultat{{ visiblePieces.length > 1 ? 's' : '' }}
|
||||
{{ piecesOnPage }} / {{ piecesTotal }} résultat{{ piecesTotal > 1 ? 's' : '' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -82,77 +103,85 @@
|
||||
Aucune pièce n'a encore été créée.
|
||||
</p>
|
||||
|
||||
<p v-else-if="!visiblePieces.length" class="text-sm text-base-content/70">
|
||||
<p v-else-if="!piecesList.length" class="text-sm text-base-content/70">
|
||||
Aucune pièce ne correspond à votre recherche.
|
||||
</p>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="table table-sm md:table-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-24">Aperçu</th>
|
||||
<th>Nom</th>
|
||||
<th>Référence</th>
|
||||
<th>Fournisseurs</th>
|
||||
<th>Type de pièce</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in pieceRows" :key="row.piece.id">
|
||||
<td class="align-middle">
|
||||
<DocumentThumbnail
|
||||
:document="resolvePrimaryDocument(row.piece)"
|
||||
:alt="resolvePreviewAlt(row.piece)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ row.piece.name || 'Pièce sans nom' }}</td>
|
||||
<td>{{ row.piece.reference || '—' }}</td>
|
||||
<td>
|
||||
<div
|
||||
v-if="row.suppliers.visible.length"
|
||||
class="flex max-w-[14rem] flex-wrap items-center gap-1"
|
||||
:title="row.suppliers.tooltip"
|
||||
>
|
||||
<span
|
||||
v-for="supplier in row.suppliers.visible"
|
||||
:key="supplier"
|
||||
class="badge badge-ghost badge-sm whitespace-nowrap"
|
||||
<template v-else>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm md:table-md">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-24">Aperçu</th>
|
||||
<th>Nom</th>
|
||||
<th>Référence</th>
|
||||
<th>Fournisseurs</th>
|
||||
<th>Type de pièce</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in pieceRows" :key="row.piece.id">
|
||||
<td class="align-middle">
|
||||
<DocumentThumbnail
|
||||
:document="resolvePrimaryDocument(row.piece)"
|
||||
:alt="resolvePreviewAlt(row.piece)"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ row.piece.name || 'Pièce sans nom' }}</td>
|
||||
<td>{{ row.piece.reference || '—' }}</td>
|
||||
<td>
|
||||
<div
|
||||
v-if="row.suppliers.visible.length"
|
||||
class="flex max-w-[14rem] flex-wrap items-center gap-1"
|
||||
:title="row.suppliers.tooltip"
|
||||
>
|
||||
{{ supplier }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.suppliers.overflow"
|
||||
class="badge badge-outline badge-sm"
|
||||
>
|
||||
+{{ row.suppliers.overflow }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td>{{ resolvePieceType(row.piece) }}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink
|
||||
:to="`/pieces/${row.piece.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="loadingPieces"
|
||||
@click="handleDeletePiece(row.piece)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<span
|
||||
v-for="supplier in row.suppliers.visible"
|
||||
:key="supplier"
|
||||
class="badge badge-ghost badge-sm whitespace-nowrap"
|
||||
>
|
||||
{{ supplier }}
|
||||
</span>
|
||||
<span
|
||||
v-if="row.suppliers.overflow"
|
||||
class="badge badge-outline badge-sm"
|
||||
>
|
||||
+{{ row.suppliers.overflow }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
<td>{{ resolvePieceType(row.piece) }}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<NuxtLink
|
||||
:to="`/pieces/${row.piece.id}/edit`"
|
||||
class="btn btn-ghost btn-xs"
|
||||
>
|
||||
Modifier
|
||||
</NuxtLink>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-xs"
|
||||
:disabled="loadingPieces"
|
||||
@click="handleDeletePiece(row.piece)"
|
||||
>
|
||||
Supprimer
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
:current-page="currentPage"
|
||||
:total-pages="totalPages"
|
||||
@update:current-page="handlePageChange"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -160,19 +189,47 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { usePieces } from '~/composables/usePieces'
|
||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { usePersistedSort } from '~/composables/usePersistedSort'
|
||||
import DocumentThumbnail from '~/components/DocumentThumbnail.vue'
|
||||
import Pagination from '~/components/common/Pagination.vue'
|
||||
import { isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
|
||||
const { showError } = useToast()
|
||||
const { pieces, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
|
||||
const { pieces, total, loadPieces, loading: loadingPiecesRef, deletePiece } = usePieces()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const loadingPieces = computed(() => loadingPiecesRef.value)
|
||||
|
||||
// Pagination state
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = ref(30)
|
||||
const piecesTotal = computed(() => total.value)
|
||||
const piecesOnPage = computed(() => pieces.value.length)
|
||||
const totalPages = computed(() => Math.ceil(piecesTotal.value / itemsPerPage.value) || 1)
|
||||
|
||||
// Search state with debounce
|
||||
const searchTerm = ref('')
|
||||
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const debouncedSearch = () => {
|
||||
if (searchTimeout) {
|
||||
clearTimeout(searchTimeout)
|
||||
}
|
||||
searchTimeout = setTimeout(() => {
|
||||
currentPage.value = 1
|
||||
fetchPieces()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// Sort state
|
||||
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
||||
'pieces-catalog',
|
||||
{ field: 'name', direction: 'asc' },
|
||||
)
|
||||
|
||||
// Enrichir les pièces avec les types de pièces complets
|
||||
const piecesList = computed(() => {
|
||||
return (pieces.value || []).map((piece) => {
|
||||
@@ -183,13 +240,31 @@ const piecesList = computed(() => {
|
||||
}
|
||||
})
|
||||
})
|
||||
const piecesTotal = computed(() => piecesList.value.length)
|
||||
|
||||
const searchTerm = ref('')
|
||||
const { sortField, sortDirection } = usePersistedSort<'name' | 'createdAt', 'asc' | 'desc'>(
|
||||
'pieces-catalog',
|
||||
{ field: 'name', direction: 'asc' },
|
||||
)
|
||||
const fetchPieces = async () => {
|
||||
await loadPieces({
|
||||
search: searchTerm.value,
|
||||
page: currentPage.value,
|
||||
itemsPerPage: itemsPerPage.value,
|
||||
orderBy: sortField.value,
|
||||
orderDir: sortDirection.value
|
||||
})
|
||||
}
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
currentPage.value = page
|
||||
fetchPieces()
|
||||
}
|
||||
|
||||
const handleSortChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchPieces()
|
||||
}
|
||||
|
||||
const handlePerPageChange = () => {
|
||||
currentPage.value = 1
|
||||
fetchPieces()
|
||||
}
|
||||
|
||||
const resolvePrimaryDocument = (piece: Record<string, any>) => {
|
||||
const documents = Array.isArray(piece?.documents) ? piece.documents : []
|
||||
@@ -337,60 +412,8 @@ const resolveDeleteGuard = (piece: Record<string, any>) => {
|
||||
}
|
||||
}
|
||||
|
||||
const resolveComparableName = (piece: Record<string, any>) => {
|
||||
const normalise = (value?: string | null) =>
|
||||
(value ?? '').toString().trim().toLowerCase()
|
||||
|
||||
return (
|
||||
normalise(piece?.name) ||
|
||||
normalise(piece?.reference) ||
|
||||
normalise(piece?.id)
|
||||
)
|
||||
}
|
||||
|
||||
const resolveComparableDate = (piece: Record<string, any>) => {
|
||||
const raw = piece?.createdAt ?? piece?.created_at ?? null
|
||||
if (!raw) {
|
||||
return 0
|
||||
}
|
||||
const timestamp = new Date(raw).getTime()
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp
|
||||
}
|
||||
|
||||
const visiblePieces = computed(() => {
|
||||
const term = searchTerm.value.trim().toLowerCase()
|
||||
const source = piecesList.value || []
|
||||
|
||||
const filtered = term
|
||||
? source.filter((piece) => {
|
||||
const name = (piece?.name || '').toLowerCase()
|
||||
const reference = (piece?.reference || '').toLowerCase()
|
||||
return (
|
||||
name.includes(term) ||
|
||||
reference.includes(term)
|
||||
)
|
||||
})
|
||||
: [...source]
|
||||
|
||||
const direction = sortDirection.value === 'asc' ? 1 : -1
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
if (sortField.value === 'name') {
|
||||
return (
|
||||
resolveComparableName(a).localeCompare(
|
||||
resolveComparableName(b),
|
||||
'fr',
|
||||
{ sensitivity: 'base' }
|
||||
) * direction
|
||||
)
|
||||
}
|
||||
|
||||
return (resolveComparableDate(a) - resolveComparableDate(b)) * direction
|
||||
})
|
||||
})
|
||||
|
||||
const pieceRows = computed(() =>
|
||||
visiblePieces.value.map((piece) => ({
|
||||
piecesList.value.map((piece) => ({
|
||||
piece,
|
||||
suppliers: buildPieceSuppliersDisplay(piece),
|
||||
})),
|
||||
@@ -425,11 +448,13 @@ const handleDeletePiece = async (piece: Record<string, any>) => {
|
||||
}
|
||||
|
||||
await deletePiece(piece.id)
|
||||
// Reload current page after deletion
|
||||
fetchPieces()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadPieces(),
|
||||
fetchPieces(),
|
||||
loadPieceTypes()
|
||||
])
|
||||
})
|
||||
|
||||
@@ -146,34 +146,48 @@
|
||||
<span>{{ description }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ProductSelect
|
||||
v-model="editionForm.productId"
|
||||
:disabled="saving"
|
||||
:type-product-id="primaryProductRequirement?.typeProductId || null"
|
||||
helper-text="Un produit valide est requis pour cette pièce."
|
||||
/>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
v-for="entry in productRequirementEntries"
|
||||
:key="entry.key"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">
|
||||
{{ entry.label }}
|
||||
</span>
|
||||
</label>
|
||||
<ProductSelect
|
||||
:model-value="productSelections[entry.index] || null"
|
||||
:disabled="saving"
|
||||
:type-product-id="entry.typeProductId"
|
||||
helper-text="Un produit valide est requis pour cette pièce."
|
||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<div v-if="selectedType || resolvedStructure" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="font-semibold text-base-content">Squelette sélectionné</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
{{ selectedType.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.' }}
|
||||
{{ selectedType?.description || 'Ce squelette définit la structure et les champs personnalisés de la pièce.' }}
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge badge-outline">{{ formatPieceStructurePreview(selectedType.structure) }}</span>
|
||||
<span class="badge badge-outline">{{ formatPieceStructurePreview(resolvedStructure) }}</span>
|
||||
</div>
|
||||
|
||||
<details v-if="selectedType.structure" class="collapse collapse-arrow bg-base-100">
|
||||
<details v-if="resolvedStructure" class="collapse collapse-arrow bg-base-100">
|
||||
<summary class="collapse-title text-sm font-medium">
|
||||
Consulter le détail du squelette
|
||||
</summary>
|
||||
<div class="collapse-content space-y-2 text-sm text-base-content/80">
|
||||
<div v-if="getStructureCustomFields(selectedType.structure).length" class="space-y-1">
|
||||
<div v-if="getStructureCustomFields(resolvedStructure).length" class="space-y-1">
|
||||
<h3 class="font-semibold text-sm text-base-content">Champs personnalisés</h3>
|
||||
<ul class="list-disc list-inside space-y-1">
|
||||
<li v-for="field in getStructureCustomFields(selectedType.structure)" :key="field.name">
|
||||
<li v-for="field in getStructureCustomFields(resolvedStructure)" :key="field.name">
|
||||
<span class="font-medium">{{ field.name }}</span>
|
||||
<span v-if="field.value !== undefined && field.value !== null"> : {{ field.value }}</span>
|
||||
</li>
|
||||
@@ -367,6 +381,74 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-semibold text-base-content">Historique</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Qui a changé quoi, et quand.
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="historyEntries.length" class="badge badge-outline">
|
||||
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||
Chargement de l’historique…
|
||||
</div>
|
||||
|
||||
<div v-else-if="historyError" class="alert alert-warning">
|
||||
<span>{{ historyError }}</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
|
||||
Aucun changement enregistré pour le moment.
|
||||
</p>
|
||||
|
||||
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
<li
|
||||
v-for="entry in historyEntries"
|
||||
:key="entry.id"
|
||||
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
|
||||
<span class="font-medium text-base-content">
|
||||
{{ historyActionLabel(entry.action) }}
|
||||
</span>
|
||||
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
Par {{ entry.actor?.label || 'Inconnu' }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="historyDiffEntries(entry).length"
|
||||
class="mt-2 space-y-1 text-xs"
|
||||
>
|
||||
<li
|
||||
v-for="diffEntry in historyDiffEntries(entry)"
|
||||
:key="`${entry.id}-${diffEntry.field}`"
|
||||
class="flex flex-col gap-0.5"
|
||||
>
|
||||
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
|
||||
<span class="text-base-content/60">
|
||||
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p
|
||||
v-else-if="entry.snapshot?.name"
|
||||
class="mt-2 text-xs text-base-content/70"
|
||||
>
|
||||
{{ entry.snapshot.name }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||
Annuler
|
||||
@@ -395,12 +477,15 @@ import { useApi } from '~/composables/useApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { usePieceHistory, type PieceHistoryEntry } from '~/composables/usePieceHistory'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
import { getFileIcon } from '~/utils/fileIcons'
|
||||
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||
import { formatPieceStructurePreview } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import type { PieceModelProduct, PieceModelStructure } from '~/shared/types/inventory'
|
||||
import type { ModelType } from '~/services/modelTypes'
|
||||
import { getModelType } from '~/services/modelTypes'
|
||||
|
||||
interface PieceCatalogType extends ModelType {
|
||||
structure: PieceModelStructure | null
|
||||
@@ -424,10 +509,16 @@ const router = useRouter()
|
||||
const { get } = useApi()
|
||||
const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
||||
const { updatePiece } = usePieces()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
||||
const toast = useToast()
|
||||
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const {
|
||||
history,
|
||||
loading: historyLoading,
|
||||
error: historyError,
|
||||
loadHistory,
|
||||
} = usePieceHistory()
|
||||
|
||||
const piece = ref<any | null>(null)
|
||||
const loading = ref(true)
|
||||
@@ -439,18 +530,113 @@ const pieceDocuments = ref<any[]>([])
|
||||
const previewDocument = ref<any | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const historyEntries = computed<PieceHistoryEntry[]>(() => history.value)
|
||||
|
||||
const historyFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
reference: 'Référence',
|
||||
prix: 'Prix',
|
||||
typePiece: 'Catégorie',
|
||||
product: 'Produit lié',
|
||||
productIds: 'Produits liés',
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
const historyActionLabel = (action: string) => {
|
||||
if (action === 'create') {
|
||||
return 'Création'
|
||||
}
|
||||
if (action === 'delete') {
|
||||
return 'Suppression'
|
||||
}
|
||||
return 'Modification'
|
||||
}
|
||||
|
||||
const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
const formatHistoryDate = (value: string) => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
return historyDateFormatter.format(date)
|
||||
}
|
||||
|
||||
const formatHistoryValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—'
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return '—'
|
||||
}
|
||||
return value.map((item) => formatHistoryValue(item)).join(', ')
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const maybeRecord = value as Record<string, unknown>
|
||||
const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
|
||||
const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
|
||||
if (name && id) {
|
||||
return `${name} (#${id})`
|
||||
}
|
||||
if (name) {
|
||||
return name
|
||||
}
|
||||
if (id) {
|
||||
return `#${id}`
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch (error) {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const historyDiffEntries = (entry: PieceHistoryEntry) => {
|
||||
const diff = entry.diff ?? {}
|
||||
return Object.entries(diff).map(([field, change]) => {
|
||||
const label = historyFieldLabels[field] ?? field
|
||||
const fromLabel = formatHistoryValue(change?.from)
|
||||
const toLabel = formatHistoryValue(change?.to)
|
||||
return {
|
||||
field,
|
||||
label,
|
||||
fromLabel,
|
||||
toLabel,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const selectedTypeId = ref<string>('')
|
||||
const pieceTypeDetails = ref<any | null>(null)
|
||||
const editionForm = reactive({
|
||||
name: '' as string,
|
||||
reference: '' as string,
|
||||
constructeurIds: [] as string[],
|
||||
prix: '' as string,
|
||||
productId: null as string | null,
|
||||
})
|
||||
const productSelections = ref<(string | null)[]>([])
|
||||
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
const documentIcon = (doc: any) =>
|
||||
getFileIcon({ name: doc?.filename || doc?.name, mime: doc?.mimeType })
|
||||
const resolvedStructure = computed<PieceModelStructure | null>(() =>
|
||||
pieceTypeDetails.value?.structure ?? selectedType.value?.structure ?? null,
|
||||
)
|
||||
|
||||
const refreshCustomFieldInputs = (
|
||||
structureOverride?: PieceModelStructure | null,
|
||||
valuesOverride?: any[] | null,
|
||||
) => {
|
||||
const structure = structureOverride ?? resolvedStructure.value ?? null
|
||||
const values = valuesOverride ?? piece.value?.customFieldValues ?? null
|
||||
customFieldInputs.value = buildCustomFieldInputs(structure, values)
|
||||
}
|
||||
const formatSize = (size: number | null | undefined) => {
|
||||
if (size === null || size === undefined) {
|
||||
return '—'
|
||||
@@ -577,14 +763,18 @@ const selectedType = computed(() => {
|
||||
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
||||
})
|
||||
|
||||
const getStructureProducts = (structure: PieceModelStructure | null) =>
|
||||
Array.isArray(structure?.products) ? structure.products : []
|
||||
|
||||
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
|
||||
Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||
|
||||
const structureProducts = computed(() =>
|
||||
getStructureProducts(selectedType.value?.structure ?? null),
|
||||
getStructureProducts(resolvedStructure.value),
|
||||
)
|
||||
|
||||
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
|
||||
|
||||
const primaryProductRequirement = computed(() => structureProducts.value[0] ?? null)
|
||||
|
||||
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
|
||||
if (!requirement) {
|
||||
return `Produit ${index + 1}`
|
||||
@@ -613,6 +803,50 @@ const productRequirementDescriptions = computed(() =>
|
||||
),
|
||||
)
|
||||
|
||||
const ensureProductSelections = (count: number) => {
|
||||
const next = Array.from({ length: count }, (_, index) => productSelections.value[index] ?? null)
|
||||
productSelections.value = next
|
||||
}
|
||||
|
||||
let pendingProductIds: string[] = []
|
||||
|
||||
const productRequirementEntries = computed(() =>
|
||||
structureProducts.value.map((requirement, index) => ({
|
||||
index,
|
||||
key: `piece-product-requirement-${index}-${requirement?.typeProductId || 'any'}`,
|
||||
label: describeProductRequirement(requirement, index),
|
||||
typeProductId: requirement?.typeProductId ? String(requirement.typeProductId) : null,
|
||||
})),
|
||||
)
|
||||
|
||||
const productSelectionsFilled = computed(() =>
|
||||
!requiresProductSelection.value ||
|
||||
productRequirementEntries.value.every((entry) => {
|
||||
const value = productSelections.value[entry.index]
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
}),
|
||||
)
|
||||
|
||||
const setProductSelection = (index: number, value: string | null) => {
|
||||
const normalized = typeof value === 'string' ? value : null
|
||||
const next = [...productSelections.value]
|
||||
next[index] = normalized
|
||||
productSelections.value = next
|
||||
}
|
||||
|
||||
watch(structureProducts, (products) => {
|
||||
ensureProductSelections(products.length)
|
||||
if (!pendingProductIds.length || products.length === 0) {
|
||||
return
|
||||
}
|
||||
const next = Array.from(
|
||||
{ length: products.length },
|
||||
(_, index) => pendingProductIds[index] ?? null,
|
||||
)
|
||||
productSelections.value = next
|
||||
pendingProductIds = []
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
customFieldInputs.value.every((field) => {
|
||||
if (!field.required) {
|
||||
@@ -630,7 +864,7 @@ const canSubmit = computed(() =>
|
||||
piece.value &&
|
||||
editionForm.name &&
|
||||
requiredCustomFieldsFilled.value &&
|
||||
(!requiresProductSelection.value || editionForm.productId) &&
|
||||
productSelectionsFilled.value &&
|
||||
!saving.value,
|
||||
),
|
||||
)
|
||||
@@ -659,12 +893,38 @@ const fetchPiece = async () => {
|
||||
if (result.success) {
|
||||
piece.value = result.data
|
||||
pieceDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||
const customValues = await getCustomFieldValuesByEntity('piece', result.data.id)
|
||||
if (customValues.success && Array.isArray(customValues.data)) {
|
||||
piece.value.customFieldValues = customValues.data
|
||||
refreshCustomFieldInputs(undefined, customValues.data)
|
||||
}
|
||||
await loadPieceTypeDetails(result.data)
|
||||
await loadHistory(result.data.id)
|
||||
} else {
|
||||
piece.value = null
|
||||
pieceDocuments.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const loadPieceTypeDetails = async (currentPiece: any) => {
|
||||
const typeId = currentPiece?.typePieceId
|
||||
|| extractRelationId(currentPiece?.typePiece)
|
||||
|| ''
|
||||
if (!typeId) {
|
||||
pieceTypeDetails.value = null
|
||||
return
|
||||
}
|
||||
try {
|
||||
const type = await getModelType(typeId)
|
||||
if (type && typeof type === 'object') {
|
||||
pieceTypeDetails.value = type
|
||||
refreshCustomFieldInputs(type.structure ?? null, currentPiece?.customFieldValues ?? null)
|
||||
}
|
||||
} catch (error) {
|
||||
pieceTypeDetails.value = null
|
||||
}
|
||||
}
|
||||
|
||||
let initialized = false
|
||||
|
||||
watch(
|
||||
@@ -674,7 +934,13 @@ watch(
|
||||
return
|
||||
}
|
||||
|
||||
selectedTypeId.value = currentPiece.typePieceId || ''
|
||||
const resolvedTypeId = currentPiece.typePieceId
|
||||
|| extractRelationId(currentPiece.typePiece)
|
||||
|| ''
|
||||
if (resolvedTypeId && !currentPiece.typePieceId) {
|
||||
currentPiece.typePieceId = resolvedTypeId
|
||||
}
|
||||
selectedTypeId.value = resolvedTypeId
|
||||
|
||||
editionForm.name = currentPiece.name || ''
|
||||
editionForm.reference = currentPiece.reference || ''
|
||||
@@ -684,15 +950,27 @@ watch(
|
||||
currentPiece.constructeur ? [currentPiece.constructeur] : [],
|
||||
)
|
||||
editionForm.prix = currentPiece.prix !== null && currentPiece.prix !== undefined ? String(currentPiece.prix) : ''
|
||||
editionForm.productId = currentPiece.product?.id || currentPiece.productId || null
|
||||
if (editionForm.constructeurIds.length) {
|
||||
void ensureConstructeurs(editionForm.constructeurIds)
|
||||
}
|
||||
|
||||
customFieldInputs.value = buildCustomFieldInputs(
|
||||
currentType?.structure ?? null,
|
||||
currentPiece.customFieldValues,
|
||||
)
|
||||
const existingProductIds = Array.isArray(currentPiece.productIds) && currentPiece.productIds.length
|
||||
? currentPiece.productIds.map((id: unknown) => String(id))
|
||||
: currentPiece.product?.id || currentPiece.productId
|
||||
? [String(currentPiece.product?.id || currentPiece.productId)]
|
||||
: []
|
||||
pendingProductIds = existingProductIds
|
||||
ensureProductSelections(structureProducts.value.length)
|
||||
if (existingProductIds.length && structureProducts.value.length) {
|
||||
const next = Array.from(
|
||||
{ length: structureProducts.value.length },
|
||||
(_, index) => existingProductIds[index] ?? null,
|
||||
)
|
||||
productSelections.value = next
|
||||
pendingProductIds = []
|
||||
}
|
||||
|
||||
refreshCustomFieldInputs(currentType?.structure ?? null, currentPiece.customFieldValues)
|
||||
|
||||
initialized = true
|
||||
},
|
||||
@@ -703,10 +981,17 @@ watch(selectedType, (currentType) => {
|
||||
if (!piece.value || !currentType) {
|
||||
return
|
||||
}
|
||||
customFieldInputs.value = buildCustomFieldInputs(
|
||||
currentType.structure,
|
||||
piece.value.customFieldValues,
|
||||
)
|
||||
if (!pieceTypeDetails.value) {
|
||||
refreshCustomFieldInputs(currentType.structure, piece.value.customFieldValues)
|
||||
}
|
||||
})
|
||||
|
||||
watch(resolvedStructure, (currentStructure) => {
|
||||
if (!piece.value) {
|
||||
return
|
||||
}
|
||||
ensureProductSelections(structureProducts.value.length)
|
||||
refreshCustomFieldInputs(currentStructure, piece.value.customFieldValues)
|
||||
})
|
||||
|
||||
const submitEdition = async () => {
|
||||
@@ -714,7 +999,7 @@ const submitEdition = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresProductSelection.value && !editionForm.productId) {
|
||||
if (!productSelectionsFilled.value) {
|
||||
toast.showError('Sélectionnez un produit conforme au squelette.')
|
||||
return
|
||||
}
|
||||
@@ -735,16 +1020,18 @@ const submitEdition = async () => {
|
||||
const reference = editionForm.reference.trim()
|
||||
payload.reference = reference ? reference : null
|
||||
|
||||
const selectedProductId =
|
||||
typeof editionForm.productId === 'string'
|
||||
? editionForm.productId.trim()
|
||||
: ''
|
||||
payload.productId = selectedProductId || null
|
||||
const normalizedProductIds = productRequirementEntries.value
|
||||
.map((entry) => productSelections.value[entry.index])
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.map((value) => value.trim())
|
||||
|
||||
payload.productIds = normalizedProductIds
|
||||
payload.productId = normalizedProductIds[0] || null
|
||||
|
||||
if (rawPrice) {
|
||||
const parsed = Number(rawPrice)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
payload.prix = parsed
|
||||
payload.prix = String(parsed)
|
||||
}
|
||||
} else {
|
||||
payload.prix = null
|
||||
@@ -932,12 +1219,6 @@ const formatDefaultValue = (type: string, defaultValue: any): string => {
|
||||
return String(defaultValue)
|
||||
}
|
||||
|
||||
const getStructureProducts = (structure: PieceModelStructure | null) =>
|
||||
Array.isArray(structure?.products) ? structure.products : []
|
||||
|
||||
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
|
||||
Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||
|
||||
const buildCustomFieldMetadata = (field: CustomFieldInput) => ({
|
||||
customFieldName: field.name,
|
||||
customFieldType: field.type,
|
||||
|
||||
@@ -118,12 +118,26 @@
|
||||
<span>{{ description }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ProductSelect
|
||||
v-model="creationForm.productId"
|
||||
:disabled="submitting || !selectedType"
|
||||
:type-product-id="primaryProductRequirement?.typeProductId || null"
|
||||
helper-text="Un produit est requis pour cette pièce."
|
||||
/>
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div
|
||||
v-for="entry in productRequirementEntries"
|
||||
:key="entry.key"
|
||||
class="form-control"
|
||||
>
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">
|
||||
{{ entry.label }}
|
||||
</span>
|
||||
</label>
|
||||
<ProductSelect
|
||||
:model-value="productSelections[entry.index] || null"
|
||||
:disabled="submitting || !selectedType"
|
||||
:type-product-id="entry.typeProductId"
|
||||
helper-text="Un produit est requis pour cette pièce."
|
||||
@update:model-value="(value) => setProductSelection(entry.index, value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedType" class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
@@ -317,8 +331,8 @@ const creationForm = reactive({
|
||||
reference: '' as string,
|
||||
constructeurIds: [] as string[],
|
||||
prix: '' as string,
|
||||
productId: null as string | null,
|
||||
})
|
||||
const productSelections = ref<(string | null)[]>([])
|
||||
|
||||
const lastSuggestedName = ref('')
|
||||
const customFieldInputs = ref<CustomFieldInput[]>([])
|
||||
@@ -364,14 +378,18 @@ const selectedType = computed(() => {
|
||||
return pieceTypeList.value.find((type) => type.id === selectedTypeId.value) ?? null
|
||||
})
|
||||
|
||||
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
|
||||
Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||
|
||||
const getStructureProducts = (structure: PieceModelStructure | null) =>
|
||||
Array.isArray(structure?.products) ? structure.products : []
|
||||
|
||||
const structureProducts = computed(() =>
|
||||
getStructureProducts(selectedType.value?.structure ?? null),
|
||||
)
|
||||
|
||||
const requiresProductSelection = computed(() => structureProducts.value.length > 0)
|
||||
|
||||
const primaryProductRequirement = computed(() => structureProducts.value[0] ?? null)
|
||||
|
||||
const describeProductRequirement = (requirement: PieceModelProduct, index: number) => {
|
||||
if (!requirement) {
|
||||
return `Produit ${index + 1}`
|
||||
@@ -400,6 +418,39 @@ const productRequirementDescriptions = computed(() =>
|
||||
),
|
||||
)
|
||||
|
||||
const ensureProductSelections = (count: number) => {
|
||||
const next = Array.from({ length: count }, (_, index) => productSelections.value[index] ?? null)
|
||||
productSelections.value = next
|
||||
}
|
||||
|
||||
const productRequirementEntries = computed(() =>
|
||||
structureProducts.value.map((requirement, index) => ({
|
||||
index,
|
||||
key: `piece-create-product-requirement-${index}-${requirement?.typeProductId || 'any'}`,
|
||||
label: describeProductRequirement(requirement, index),
|
||||
typeProductId: requirement?.typeProductId ? String(requirement.typeProductId) : null,
|
||||
})),
|
||||
)
|
||||
|
||||
const productSelectionsFilled = computed(() =>
|
||||
!requiresProductSelection.value ||
|
||||
productRequirementEntries.value.every((entry) => {
|
||||
const value = productSelections.value[entry.index]
|
||||
return typeof value === 'string' && value.trim().length > 0
|
||||
}),
|
||||
)
|
||||
|
||||
const setProductSelection = (index: number, value: string | null) => {
|
||||
const normalized = typeof value === 'string' ? value : null
|
||||
const next = [...productSelections.value]
|
||||
next[index] = normalized
|
||||
productSelections.value = next
|
||||
}
|
||||
|
||||
watch(structureProducts, (products) => {
|
||||
ensureProductSelections(products.length)
|
||||
})
|
||||
|
||||
watch(selectedType, (type) => {
|
||||
if (!type) {
|
||||
clearCreationForm()
|
||||
@@ -411,7 +462,7 @@ watch(selectedType, (type) => {
|
||||
}
|
||||
lastSuggestedName.value = creationForm.name
|
||||
customFieldInputs.value = normalizeCustomFieldInputs(type.structure)
|
||||
creationForm.productId = null
|
||||
productSelections.value = Array.from({ length: structureProducts.value.length }, () => null)
|
||||
})
|
||||
|
||||
const requiredCustomFieldsFilled = computed(() =>
|
||||
@@ -431,7 +482,7 @@ const canSubmit = computed(() =>
|
||||
selectedType.value &&
|
||||
creationForm.name &&
|
||||
requiredCustomFieldsFilled.value &&
|
||||
(!requiresProductSelection.value || creationForm.productId) &&
|
||||
productSelectionsFilled.value &&
|
||||
!submitting.value,
|
||||
),
|
||||
)
|
||||
@@ -449,18 +500,12 @@ const toFieldString = (value: unknown): string => {
|
||||
return ''
|
||||
}
|
||||
|
||||
const getStructureCustomFields = (structure: PieceModelStructure | null) =>
|
||||
Array.isArray(structure?.customFields) ? structure.customFields : []
|
||||
|
||||
const getStructureProducts = (structure: PieceModelStructure | null) =>
|
||||
Array.isArray(structure?.products) ? structure.products : []
|
||||
|
||||
const clearCreationForm = () => {
|
||||
creationForm.name = ''
|
||||
creationForm.reference = ''
|
||||
creationForm.constructeurIds = []
|
||||
creationForm.prix = ''
|
||||
creationForm.productId = null
|
||||
productSelections.value = []
|
||||
lastSuggestedName.value = ''
|
||||
}
|
||||
|
||||
@@ -470,7 +515,7 @@ const submitCreation = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
if (requiresProductSelection.value && !creationForm.productId) {
|
||||
if (!productSelectionsFilled.value) {
|
||||
toast.showError('Sélectionnez un produit conforme au squelette.')
|
||||
return
|
||||
}
|
||||
@@ -487,12 +532,13 @@ const submitCreation = async () => {
|
||||
|
||||
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
|
||||
|
||||
const selectedProductId =
|
||||
typeof creationForm.productId === 'string'
|
||||
? creationForm.productId.trim()
|
||||
: ''
|
||||
if (selectedProductId) {
|
||||
payload.productId = selectedProductId
|
||||
const normalizedProductIds = productRequirementEntries.value
|
||||
.map((entry) => productSelections.value[entry.index])
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.map((value) => value.trim())
|
||||
if (normalizedProductIds.length) {
|
||||
payload.productIds = normalizedProductIds
|
||||
payload.productId = normalizedProductIds[0]
|
||||
}
|
||||
|
||||
const rawPrice = typeof creationForm.prix === 'string'
|
||||
@@ -504,7 +550,7 @@ const submitCreation = async () => {
|
||||
if (rawPrice) {
|
||||
const parsed = Number(rawPrice)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
payload.prix = parsed
|
||||
payload.prix = String(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
:initial-data="initialData"
|
||||
:lock-category="true"
|
||||
:saving="saving"
|
||||
:disable-submit="isSubmitBlocked"
|
||||
:disable-submit-message="submitBlockMessage"
|
||||
:restricted-mode="isRestrictedMode"
|
||||
:restricted-mode-message="restrictedModeMessage"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
@@ -38,6 +42,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useHead, useRoute, useRouter } from '#imports'
|
||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -48,6 +53,23 @@ const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
||||
|
||||
const {
|
||||
isRestrictedMode,
|
||||
isSubmitBlocked,
|
||||
restrictedModeMessage,
|
||||
submitBlockMessage,
|
||||
loadLinkedCount,
|
||||
guardSubmitOrNotify,
|
||||
} = useCategoryEditGuard({
|
||||
endpoint: '/products',
|
||||
filterKey: 'typeProduct',
|
||||
labels: {
|
||||
singular: 'produit',
|
||||
plural: 'produits',
|
||||
verifying: 'Vérification des produits liés en cours…',
|
||||
},
|
||||
})
|
||||
|
||||
const title = computed(() =>
|
||||
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de produit',
|
||||
)
|
||||
@@ -86,6 +108,8 @@ const loadCategory = async () => {
|
||||
notes: response.notes ?? response.description ?? '',
|
||||
structure: response.structure ?? undefined,
|
||||
}
|
||||
|
||||
await loadLinkedCount(id)
|
||||
} catch (error) {
|
||||
showError(normalizeError(error))
|
||||
await navigateBackToList()
|
||||
@@ -99,6 +123,9 @@ const handleCancel = () => {
|
||||
}
|
||||
|
||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||
if (guardSubmitOrNotify()) {
|
||||
return
|
||||
}
|
||||
const id = String(route.params.id)
|
||||
saving.value = true
|
||||
try {
|
||||
|
||||
@@ -301,6 +301,74 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="space-y-3 rounded-lg border border-base-200 bg-base-200/40 p-4">
|
||||
<header class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="font-semibold text-base-content">Historique</h2>
|
||||
<p class="text-xs text-base-content/70">
|
||||
Qui a changé quoi, et quand.
|
||||
</p>
|
||||
</div>
|
||||
<span v-if="historyEntries.length" class="badge badge-outline">
|
||||
{{ historyEntries.length }} entrée{{ historyEntries.length > 1 ? 's' : '' }}
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div v-if="historyLoading" class="flex items-center gap-2 text-sm text-base-content/70">
|
||||
<span class="loading loading-spinner loading-sm" aria-hidden="true" />
|
||||
Chargement de l’historique…
|
||||
</div>
|
||||
|
||||
<div v-else-if="historyError" class="alert alert-warning">
|
||||
<span>{{ historyError }}</span>
|
||||
</div>
|
||||
|
||||
<p v-else-if="historyEntries.length === 0" class="text-xs text-base-content/70">
|
||||
Aucun changement enregistré pour le moment.
|
||||
</p>
|
||||
|
||||
<ul v-else class="max-h-80 space-y-2 overflow-y-auto pr-1">
|
||||
<li
|
||||
v-for="entry in historyEntries"
|
||||
:key="entry.id"
|
||||
class="rounded-md border border-base-200 bg-base-100 px-3 py-2"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-base-content/70">
|
||||
<span class="font-medium text-base-content">
|
||||
{{ historyActionLabel(entry.action) }}
|
||||
</span>
|
||||
<span>{{ formatHistoryDate(entry.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
Par {{ entry.actor?.label || 'Inconnu' }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="historyDiffEntries(entry).length"
|
||||
class="mt-2 space-y-1 text-xs"
|
||||
>
|
||||
<li
|
||||
v-for="diffEntry in historyDiffEntries(entry)"
|
||||
:key="`${entry.id}-${diffEntry.field}`"
|
||||
class="flex flex-col gap-0.5"
|
||||
>
|
||||
<span class="font-medium text-base-content/80">{{ diffEntry.label }}</span>
|
||||
<span class="text-base-content/60">
|
||||
{{ diffEntry.fromLabel }} → {{ diffEntry.toLabel }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p
|
||||
v-else-if="entry.snapshot?.name"
|
||||
class="mt-2 text-xs text-base-content/70"
|
||||
>
|
||||
{{ entry.snapshot.name }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div class="flex flex-col gap-3 md:flex-row md:justify-end">
|
||||
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||
Annuler
|
||||
@@ -329,6 +397,7 @@ import { useCustomFields } from '~/composables/useCustomFields'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useDocuments } from '~/composables/useDocuments'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
|
||||
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||
import { getModelType } from '~/services/modelTypes'
|
||||
@@ -352,13 +421,19 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { getProduct, updateProduct } = useProducts()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue } = useCustomFields()
|
||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
||||
const {
|
||||
loadDocumentsByProduct,
|
||||
uploadDocuments: uploadProductDocuments,
|
||||
deleteDocument: deleteProductDocument,
|
||||
} = useDocuments()
|
||||
const { ensureConstructeurs } = useConstructeurs()
|
||||
const {
|
||||
history,
|
||||
loading: historyLoading,
|
||||
error: historyError,
|
||||
loadHistory,
|
||||
} = useProductHistory()
|
||||
|
||||
const product = ref<any | null>(null)
|
||||
const productType = ref<any | null>(null)
|
||||
@@ -373,6 +448,95 @@ const productDocuments = ref<any[]>([])
|
||||
const previewDocument = ref<any | null>(null)
|
||||
const previewVisible = ref(false)
|
||||
|
||||
const historyEntries = computed<ProductHistoryEntry[]>(() => history.value)
|
||||
|
||||
const historyFieldLabels: Record<string, string> = {
|
||||
name: 'Nom',
|
||||
reference: 'Référence',
|
||||
supplierPrice: 'Prix fournisseur',
|
||||
typeProduct: 'Catégorie',
|
||||
constructeurIds: 'Fournisseurs',
|
||||
}
|
||||
|
||||
const historyActionLabel = (action: string) => {
|
||||
if (action === 'create') {
|
||||
return 'Création'
|
||||
}
|
||||
if (action === 'delete') {
|
||||
return 'Suppression'
|
||||
}
|
||||
return 'Modification'
|
||||
}
|
||||
|
||||
const historyDateFormatter = new Intl.DateTimeFormat('fr-FR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
const formatHistoryDate = (value: string) => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
return historyDateFormatter.format(date)
|
||||
}
|
||||
|
||||
const formatHistoryValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—'
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
return '—'
|
||||
}
|
||||
return value.map((item) => formatHistoryValue(item)).join(', ')
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const maybeRecord = value as Record<string, unknown>
|
||||
const name = typeof maybeRecord.name === 'string' ? maybeRecord.name : null
|
||||
const id = typeof maybeRecord.id === 'string' ? maybeRecord.id : null
|
||||
if (name && id) {
|
||||
return `${name} (#${id})`
|
||||
}
|
||||
if (name) {
|
||||
return name
|
||||
}
|
||||
if (id) {
|
||||
return `#${id}`
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value)
|
||||
} catch (error) {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const historyDiffEntries = (entry: ProductHistoryEntry) => {
|
||||
const diff = entry.diff ?? {}
|
||||
return Object.entries(diff).map(([field, change]) => {
|
||||
const label = historyFieldLabels[field] ?? field
|
||||
const fromLabel = formatHistoryValue(change?.from)
|
||||
const toLabel = formatHistoryValue(change?.to)
|
||||
return {
|
||||
field,
|
||||
label,
|
||||
fromLabel,
|
||||
toLabel,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const refreshCustomFieldInputs = (
|
||||
structureOverride?: ProductModelStructure | null,
|
||||
valuesOverride?: any[] | null,
|
||||
) => {
|
||||
const nextStructure = structureOverride ?? structure.value ?? null
|
||||
const nextValues = valuesOverride ?? product.value?.customFieldValues ?? null
|
||||
customFieldInputs.value = buildCustomFieldInputs(nextStructure, nextValues)
|
||||
}
|
||||
|
||||
const editionForm = reactive({
|
||||
name: '' as string,
|
||||
reference: '' as string,
|
||||
@@ -493,8 +657,14 @@ const loadProduct = async () => {
|
||||
product.value = result.data
|
||||
productDocuments.value = Array.isArray(result.data?.documents) ? result.data.documents : []
|
||||
await loadProductType()
|
||||
const customValues = await getCustomFieldValuesByEntity('product', result.data.id)
|
||||
if (customValues.success && Array.isArray(customValues.data)) {
|
||||
product.value.customFieldValues = customValues.data
|
||||
refreshCustomFieldInputs(undefined, customValues.data)
|
||||
}
|
||||
await hydrateForm()
|
||||
await refreshDocuments()
|
||||
await loadHistory(result.data.id)
|
||||
} else {
|
||||
product.value = null
|
||||
}
|
||||
@@ -582,7 +752,7 @@ const hydrateForm = async () => {
|
||||
editionForm.supplierPrice = product.value.supplierPrice !== null && product.value.supplierPrice !== undefined
|
||||
? String(product.value.supplierPrice)
|
||||
: ''
|
||||
customFieldInputs.value = buildCustomFieldInputs(structure.value, product.value.customFieldValues)
|
||||
refreshCustomFieldInputs(structure.value, product.value.customFieldValues)
|
||||
if (editionForm.constructeurIds.length) {
|
||||
await ensureConstructeurs(editionForm.constructeurIds)
|
||||
}
|
||||
@@ -691,11 +861,13 @@ const submitEdition = async () => {
|
||||
constructeurIds,
|
||||
}
|
||||
|
||||
const rawPrice = editionForm.supplierPrice.trim()
|
||||
payload.supplierPrice = rawPrice
|
||||
const rawPrice = typeof editionForm.supplierPrice === 'string'
|
||||
? editionForm.supplierPrice.trim()
|
||||
: editionForm.supplierPrice
|
||||
payload.supplierPrice = rawPrice !== '' && rawPrice !== null && rawPrice !== undefined
|
||||
? Number.isNaN(Number(rawPrice))
|
||||
? null
|
||||
: Number(rawPrice)
|
||||
: String(Number(rawPrice))
|
||||
: null
|
||||
|
||||
saving.value = true
|
||||
@@ -730,20 +902,29 @@ const saveCustomFieldValues = async (productId: string) => {
|
||||
continue
|
||||
}
|
||||
|
||||
if (!field.customFieldId) {
|
||||
continue
|
||||
}
|
||||
const metadata = field.customFieldId
|
||||
? undefined
|
||||
: { customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required }
|
||||
|
||||
const result = await upsertCustomFieldValue(
|
||||
field.customFieldId,
|
||||
'product',
|
||||
productId,
|
||||
String(value ?? ''),
|
||||
{ customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required },
|
||||
metadata,
|
||||
)
|
||||
|
||||
if (!result.success) {
|
||||
failed.push(field.name)
|
||||
} else {
|
||||
const createdValue = result.data
|
||||
if (createdValue?.id) {
|
||||
field.customFieldValueId = createdValue.id
|
||||
}
|
||||
const resolvedId = createdValue?.customField?.id || field.customFieldId
|
||||
if (resolvedId) {
|
||||
field.customFieldId = resolvedId
|
||||
}
|
||||
}
|
||||
}
|
||||
return failed
|
||||
|
||||
@@ -425,11 +425,13 @@ const buildPayload = () => {
|
||||
|
||||
payload.constructeurIds = uniqueConstructeurIds(creationForm.constructeurIds)
|
||||
|
||||
const rawPrice = creationForm.supplierPrice.trim()
|
||||
if (rawPrice) {
|
||||
const rawPrice = typeof creationForm.supplierPrice === 'string'
|
||||
? creationForm.supplierPrice.trim()
|
||||
: creationForm.supplierPrice
|
||||
if (rawPrice !== '' && rawPrice !== null && rawPrice !== undefined) {
|
||||
const parsed = Number(rawPrice)
|
||||
if (!Number.isNaN(parsed)) {
|
||||
payload.supplierPrice = parsed
|
||||
payload.supplierPrice = String(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,19 +488,31 @@ const submitCreation = async () => {
|
||||
const saveCustomFieldValues = async (productId: string) => {
|
||||
const failed: string[] = []
|
||||
for (const field of customFieldInputs.value) {
|
||||
if (!field.customFieldId || !field.name) {
|
||||
if (!field.name) {
|
||||
continue
|
||||
}
|
||||
const value = field.value ?? ''
|
||||
const metadata = field.customFieldId
|
||||
? undefined
|
||||
: { customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required }
|
||||
const result = await upsertCustomFieldValue(
|
||||
field.customFieldId,
|
||||
'product',
|
||||
productId,
|
||||
String(value ?? ''),
|
||||
{ customFieldName: field.name, customFieldType: field.type, customFieldRequired: field.required },
|
||||
metadata,
|
||||
)
|
||||
if (!result.success) {
|
||||
failed.push(field.name)
|
||||
} else {
|
||||
const createdValue = result.data
|
||||
if (createdValue?.id) {
|
||||
field.customFieldValueId = createdValue.id
|
||||
}
|
||||
const resolvedId = createdValue?.customField?.id || field.customFieldId
|
||||
if (resolvedId) {
|
||||
field.customFieldId = resolvedId
|
||||
}
|
||||
}
|
||||
}
|
||||
return failed
|
||||
|
||||
@@ -52,6 +52,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { extractRelationId } from '~/shared/apiRelations'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -90,6 +91,20 @@ const parseOptions = (field = {}) => {
|
||||
return []
|
||||
}
|
||||
|
||||
const toModelTypeIri = (value) => {
|
||||
if (!value) {
|
||||
return undefined
|
||||
}
|
||||
if (typeof value === 'string' && value.startsWith('/api/model_types/')) {
|
||||
return value
|
||||
}
|
||||
const relationId = extractRelationId(value)
|
||||
if (relationId) {
|
||||
return `/api/model_types/${relationId}`
|
||||
}
|
||||
return typeof value === 'string' ? `/api/model_types/${value}` : undefined
|
||||
}
|
||||
|
||||
const normalizeCustomFields = (fields = []) =>
|
||||
fields
|
||||
.filter(field => field?.name && field.name.trim() !== '')
|
||||
@@ -113,9 +128,9 @@ const toIntegerOrNull = (value, fallback = null) => {
|
||||
|
||||
const normalizeComponentRequirements = (requirements = []) =>
|
||||
requirements
|
||||
.filter(req => req?.typeComposantId)
|
||||
.filter(req => req?.typeComposantId || req?.typeComposant)
|
||||
.map((req, index) => ({
|
||||
typeComposantId: req.typeComposantId,
|
||||
typeComposant: toModelTypeIri(req.typeComposantId || req.typeComposant),
|
||||
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||
minCount: toIntegerOrNull(req.minCount, 1),
|
||||
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||
@@ -128,9 +143,9 @@ const normalizeComponentRequirements = (requirements = []) =>
|
||||
|
||||
const normalizePieceRequirements = (requirements = []) =>
|
||||
requirements
|
||||
.filter(req => req?.typePieceId)
|
||||
.filter(req => req?.typePieceId || req?.typePiece)
|
||||
.map((req, index) => ({
|
||||
typePieceId: req.typePieceId,
|
||||
typePiece: toModelTypeIri(req.typePieceId || req.typePiece),
|
||||
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||
minCount: toIntegerOrNull(req.minCount, 0),
|
||||
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||
@@ -143,9 +158,9 @@ const normalizePieceRequirements = (requirements = []) =>
|
||||
|
||||
const normalizeProductRequirements = (requirements = []) =>
|
||||
requirements
|
||||
.filter(req => req?.typeProductId)
|
||||
.filter(req => req?.typeProductId || req?.typeProduct)
|
||||
.map((req, index) => ({
|
||||
typeProductId: req.typeProductId,
|
||||
typeProduct: toModelTypeIri(req.typeProductId || req.typeProduct),
|
||||
label: req.label?.trim() ? req.label.trim() : undefined,
|
||||
minCount: toIntegerOrNull(req.minCount, 0),
|
||||
maxCount: toIntegerOrNull(req.maxCount, null),
|
||||
@@ -194,7 +209,7 @@ onMounted(async () => {
|
||||
console.log('=== EDIT TYPE PAGE LOADING ===')
|
||||
console.log('Loading type with ID:', typeId)
|
||||
|
||||
const result = await getMachineTypeById(typeId)
|
||||
const result = await getMachineTypeById(typeId, true)
|
||||
console.log('API Result:', result)
|
||||
|
||||
if (result.success) {
|
||||
|
||||
@@ -128,7 +128,7 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s
|
||||
const query: Record<string, string | number> = {};
|
||||
|
||||
if (params.q) {
|
||||
query.q = params.q;
|
||||
query.name = params.q;
|
||||
}
|
||||
if (params.category) {
|
||||
query.category = params.category;
|
||||
|
||||
@@ -117,21 +117,48 @@ export const formatConstructeurContact = (
|
||||
export const buildConstructeurRequestPayload = <T extends Record<string, any>>(
|
||||
payload: T,
|
||||
): T & { constructeurs?: string[] } => {
|
||||
const ids = uniqueConstructeurIds(
|
||||
const collected = new Set(uniqueConstructeurIds(
|
||||
payload?.constructeurIds,
|
||||
payload?.constructeurId,
|
||||
payload?.constructeur,
|
||||
payload?.constructeurs,
|
||||
);
|
||||
));
|
||||
|
||||
if (!collected.size) {
|
||||
const fallbackLists = [
|
||||
payload?.constructeurIds,
|
||||
payload?.constructeurs,
|
||||
];
|
||||
fallbackLists.forEach((list) => {
|
||||
if (!Array.isArray(list)) {
|
||||
return;
|
||||
}
|
||||
list.forEach((item) => {
|
||||
if (typeof item === 'string') {
|
||||
const id = toStringId(item);
|
||||
if (id) {
|
||||
collected.add(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isObject(item) && typeof item.id === 'string') {
|
||||
collected.add(item.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const ids = Array.from(collected);
|
||||
|
||||
const next = { ...payload } as Record<string, any>;
|
||||
if (ids.length) {
|
||||
next.constructeurs = ids.map((id) => `/api/constructeurs/${id}`);
|
||||
}
|
||||
delete next.constructeurId;
|
||||
delete next.constructeur;
|
||||
delete next.constructeurs;
|
||||
delete next.constructeurIds;
|
||||
|
||||
if (ids.length) {
|
||||
next.constructeurs = ids.map((id) => `/api/constructeurs/${id}`);
|
||||
}
|
||||
|
||||
return next as T & { constructeurs?: string[] };
|
||||
};
|
||||
|
||||
@@ -1,11 +1,28 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// Lire la version depuis le fichier VERSION à la racine du projet parent
|
||||
const getAppVersion = (): string => {
|
||||
try {
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const versionPath = resolve(__dirname, '..', 'VERSION')
|
||||
return readFileSync(versionPath, 'utf-8').trim()
|
||||
} catch {
|
||||
return '0.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
const appVersion = process.env.NUXT_PUBLIC_APP_VERSION || getAppVersion()
|
||||
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
ssr: false, // Désactive le SSR pour un mode SPA pur (Client-Side Rendering uniquement)
|
||||
devtools: { enabled: true },
|
||||
devServer: {
|
||||
host: '0.0.0.0',
|
||||
port: 3001
|
||||
port: 3000
|
||||
},
|
||||
modules: [
|
||||
[
|
||||
@@ -27,7 +44,7 @@ export default defineNuxtConfig({
|
||||
apiBaseUrl: process.env.NUXT_PUBLIC_API_BASE_URL || 'http://localhost:8081/api',
|
||||
appUrl: process.env.NUXT_PUBLIC_APP_URL || 'http://localhost:3001',
|
||||
appName: process.env.NUXT_PUBLIC_APP_NAME || 'Inventory Management System',
|
||||
appVersion: process.env.NUXT_PUBLIC_APP_VERSION || '0.1.0',
|
||||
appVersion: appVersion,
|
||||
apiTimeout: process.env.NUXT_PUBLIC_API_TIMEOUT || '30000',
|
||||
requestTimeout: process.env.NUXT_PUBLIC_REQUEST_TIMEOUT || '10000',
|
||||
enableDebug: process.env.NUXT_PUBLIC_ENABLE_DEBUG || 'true',
|
||||
|
||||
Reference in New Issue
Block a user