Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3705b8daed | |||
|
|
202b964b24 | ||
|
|
a1d15c23a4 | ||
|
|
a7101c7e77 | ||
|
|
adccfa9b46 | ||
|
|
5f54acdfac | ||
|
|
86d15faa01 | ||
|
|
603c03ca00 | ||
|
|
155cd9b358 |
16
app/app.vue
16
app/app.vue
@@ -297,15 +297,17 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<div class="avatar placeholder">
|
<div class="avatar">
|
||||||
<div
|
<div class="w-14">
|
||||||
class="bg-primary text-primary-content rounded-lg w-10 grid place-items-center"
|
<img
|
||||||
>
|
:src="logoSrc"
|
||||||
<IconLucideBoxes class="w-6 h-6" aria-hidden="true" />
|
alt="Logo Malio"
|
||||||
|
class="h-full w-full object-contain"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink to="/" class="btn btn-ghost text-xl">
|
<NuxtLink to="/" class="btn btn-ghost text-xl">
|
||||||
Inventaire Pro
|
Inventory
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -705,13 +707,13 @@ import { useRoute, navigateTo, useRuntimeConfig } from "#imports";
|
|||||||
import { useProfileSession } from "~/composables/useProfileSession";
|
import { useProfileSession } from "~/composables/useProfileSession";
|
||||||
import IconLucideMenu from "~icons/lucide/menu";
|
import IconLucideMenu from "~icons/lucide/menu";
|
||||||
import IconLucideSettings from "~icons/lucide/settings";
|
import IconLucideSettings from "~icons/lucide/settings";
|
||||||
import IconLucideBoxes from "~icons/lucide/boxes";
|
|
||||||
import IconLucidePlus from "~icons/lucide/plus";
|
import IconLucidePlus from "~icons/lucide/plus";
|
||||||
import IconLucideCpu from "~icons/lucide/cpu";
|
import IconLucideCpu from "~icons/lucide/cpu";
|
||||||
import IconLucideFilePlus from "~icons/lucide/file-plus";
|
import IconLucideFilePlus from "~icons/lucide/file-plus";
|
||||||
import IconLucideMapPin from "~icons/lucide/map-pin";
|
import IconLucideMapPin from "~icons/lucide/map-pin";
|
||||||
import IconLucideChevronRight from "~icons/lucide/chevron-right";
|
import IconLucideChevronRight from "~icons/lucide/chevron-right";
|
||||||
import IconLucideLogOut from "~icons/lucide/log-out";
|
import IconLucideLogOut from "~icons/lucide/log-out";
|
||||||
|
import logoSrc from "~/assets/LOGO_CARRE_BLANC.png";
|
||||||
|
|
||||||
// État du modal des paramètres d'affichage
|
// État du modal des paramètres d'affichage
|
||||||
const displaySettingsOpen = ref(false);
|
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"
|
:locked-type-label="displayedRootTypeLabel"
|
||||||
:allow-subcomponents="allowSubcomponents"
|
:allow-subcomponents="allowSubcomponents"
|
||||||
:max-subcomponent-depth="maxSubcomponentDepth"
|
:max-subcomponent-depth="maxSubcomponentDepth"
|
||||||
|
:restricted-mode="restrictedMode"
|
||||||
is-root
|
is-root
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,6 +56,10 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: Infinity,
|
default: Infinity,
|
||||||
},
|
},
|
||||||
|
restrictedMode: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue'])
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|||||||
@@ -7,10 +7,10 @@
|
|||||||
Produits inclus par défaut
|
Produits inclus par défaut
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-xs text-base-content/70">
|
<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>
|
</p>
|
||||||
</div>
|
</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" />
|
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||||
Ajouter
|
Ajouter
|
||||||
</button>
|
</button>
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
<select
|
<select
|
||||||
v-model="product.typeProductId"
|
v-model="product.typeProductId"
|
||||||
class="select select-bordered select-xs"
|
class="select select-bordered select-xs"
|
||||||
|
:disabled="isProductLocked(product)"
|
||||||
@change="handleProductTypeSelect(product)"
|
@change="handleProductTypeSelect(product)"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
@@ -51,12 +52,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
v-if="!isProductLocked(product)"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs btn-square"
|
class="btn btn-error btn-xs btn-square"
|
||||||
@click="removeProduct(index)"
|
@click="removeProduct(index)"
|
||||||
>
|
>
|
||||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</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>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -107,8 +118,9 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-xs"
|
class="input input-bordered input-xs"
|
||||||
placeholder="Nom du champ"
|
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">
|
<option value="text">
|
||||||
Texte
|
Texte
|
||||||
</option>
|
</option>
|
||||||
@@ -128,7 +140,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2 text-xs">
|
<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
|
Obligatoire
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -137,16 +149,27 @@
|
|||||||
v-model="field.optionsText"
|
v-model="field.optionsText"
|
||||||
class="textarea textarea-bordered textarea-xs h-20"
|
class="textarea textarea-bordered textarea-xs h-20"
|
||||||
placeholder="Option 1 Option 2"
|
placeholder="Option 1 Option 2"
|
||||||
|
:disabled="isFieldLocked(field)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
v-if="!isFieldLocked(field)"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs btn-square"
|
class="btn btn-error btn-xs btn-square"
|
||||||
@click="removeField(index)"
|
@click="removeField(index)"
|
||||||
>
|
>
|
||||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</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>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -181,6 +204,7 @@ type EditorProduct = {
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue?: PieceModelStructure | null
|
modelValue?: PieceModelStructure | null
|
||||||
|
restrictedMode?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -330,6 +354,19 @@ const fields = ref<EditorField[]>(hydrateFields(props.modelValue))
|
|||||||
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
|
const products = ref<EditorProduct[]>(hydrateProducts(props.modelValue))
|
||||||
const restState = ref<Record<string, unknown>>(extractRest(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[] =>
|
const applyOrderIndex = (list: EditorField[]): EditorField[] =>
|
||||||
list.map((field, index) => ({
|
list.map((field, index) => ({
|
||||||
...field,
|
...field,
|
||||||
@@ -438,6 +475,8 @@ watch(
|
|||||||
products.value = hydrateProducts(value)
|
products.value = hydrateProducts(value)
|
||||||
products.value.forEach((product) => updateProductTypeMetadata(product))
|
products.value.forEach((product) => updateProductTypeMetadata(product))
|
||||||
lastEmitted = incomingSerialized
|
lastEmitted = incomingSerialized
|
||||||
|
initialFieldUids.value = new Set(fields.value.map(f => f.uid))
|
||||||
|
initialProductUids.value = new Set(products.value.map(p => p.uid))
|
||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<select
|
<select
|
||||||
v-model="node.typeComposantId"
|
v-model="node.typeComposantId"
|
||||||
class="select select-bordered select-sm w-full"
|
class="select select-bordered select-sm w-full"
|
||||||
|
:disabled="isLocked"
|
||||||
@change="handleComponentTypeSelect(node)"
|
@change="handleComponentTypeSelect(node)"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-xs"
|
class="input input-bordered input-xs"
|
||||||
placeholder="Alias du sous-composant"
|
placeholder="Alias du sous-composant"
|
||||||
|
:disabled="isLocked"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -52,13 +54,18 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="!isRoot"
|
v-if="!isRoot && !isLocked"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs btn-square"
|
class="btn btn-error btn-xs btn-square"
|
||||||
@click="emit('remove')"
|
@click="emit('remove')"
|
||||||
>
|
>
|
||||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</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>
|
||||||
|
|
||||||
<div class="px-4 py-4 space-y-5">
|
<div class="px-4 py-4 space-y-5">
|
||||||
@@ -107,8 +114,9 @@
|
|||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-xs"
|
class="input input-bordered input-xs"
|
||||||
placeholder="Nom du champ"
|
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="text">Texte</option>
|
||||||
<option value="number">Nombre</option>
|
<option value="number">Nombre</option>
|
||||||
<option value="select">Liste</option>
|
<option value="select">Liste</option>
|
||||||
@@ -117,7 +125,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 text-xs">
|
<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
|
Obligatoire
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -125,15 +133,26 @@
|
|||||||
v-model="field.optionsText"
|
v-model="field.optionsText"
|
||||||
class="textarea textarea-bordered textarea-xs h-20"
|
class="textarea textarea-bordered textarea-xs h-20"
|
||||||
placeholder="Option 1 Option 2"
|
placeholder="Option 1 Option 2"
|
||||||
|
:disabled="isCustomFieldLocked(index)"
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
v-if="!isCustomFieldLocked(index)"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-xs btn-square"
|
class="btn btn-error btn-xs btn-square"
|
||||||
@click="removeCustomField(index)"
|
@click="removeCustomField(index)"
|
||||||
>
|
>
|
||||||
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,7 +163,7 @@
|
|||||||
<h4 :class="headingClass">
|
<h4 :class="headingClass">
|
||||||
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
|
{{ isRoot ? 'Produits inclus par défaut' : 'Produits' }}
|
||||||
</h4>
|
</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" />
|
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||||
Ajouter
|
Ajouter
|
||||||
</button>
|
</button>
|
||||||
@@ -179,6 +198,7 @@
|
|||||||
<select
|
<select
|
||||||
v-model="product.typeProductId"
|
v-model="product.typeProductId"
|
||||||
class="select select-bordered select-xs"
|
class="select select-bordered select-xs"
|
||||||
|
:disabled="isProductLocked(index)"
|
||||||
@change="handleProductTypeSelect(product)"
|
@change="handleProductTypeSelect(product)"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
@@ -194,9 +214,18 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,7 +236,7 @@
|
|||||||
<h4 :class="headingClass">
|
<h4 :class="headingClass">
|
||||||
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
|
{{ isRoot ? 'Pièces incluses par défaut' : 'Pièces' }}
|
||||||
</h4>
|
</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" />
|
<IconLucidePlus class="w-3 h-3 mr-2" aria-hidden="true" />
|
||||||
Ajouter
|
Ajouter
|
||||||
</button>
|
</button>
|
||||||
@@ -243,6 +272,7 @@
|
|||||||
<select
|
<select
|
||||||
v-model="piece.typePieceId"
|
v-model="piece.typePieceId"
|
||||||
class="select select-bordered select-xs"
|
class="select select-bordered select-xs"
|
||||||
|
:disabled="isPieceLocked(index)"
|
||||||
@change="handlePieceTypeSelect(piece)"
|
@change="handlePieceTypeSelect(piece)"
|
||||||
>
|
>
|
||||||
<option value="">
|
<option value="">
|
||||||
@@ -262,9 +292,14 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<IconLucideTrash class="w-4 h-4" aria-hidden="true" />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -274,7 +309,7 @@
|
|||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<h4 :class="headingClass">Sous-composants</h4>
|
<h4 :class="headingClass">Sous-composants</h4>
|
||||||
<button
|
<button
|
||||||
v-if="canManageSubcomponents"
|
v-if="canManageSubcomponents && !restrictedMode"
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline btn-xs"
|
class="btn btn-outline btn-xs"
|
||||||
@click="addSubComponent"
|
@click="addSubComponent"
|
||||||
@@ -317,6 +352,8 @@
|
|||||||
:product-types="productTypes"
|
:product-types="productTypes"
|
||||||
:allow-subcomponents="childAllowSubcomponents"
|
:allow-subcomponents="childAllowSubcomponents"
|
||||||
:max-subcomponent-depth="maxSubcomponentDepth"
|
:max-subcomponent-depth="maxSubcomponentDepth"
|
||||||
|
:restricted-mode="restrictedMode"
|
||||||
|
:is-locked="isSubcomponentLocked(index)"
|
||||||
@remove="removeSubComponent(index)"
|
@remove="removeSubComponent(index)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -359,6 +396,8 @@ const props = withDefaults(defineProps<{
|
|||||||
lockedTypeLabel?: string
|
lockedTypeLabel?: string
|
||||||
allowSubcomponents?: boolean
|
allowSubcomponents?: boolean
|
||||||
maxSubcomponentDepth?: number
|
maxSubcomponentDepth?: number
|
||||||
|
restrictedMode?: boolean
|
||||||
|
isLocked?: boolean
|
||||||
}>(), {
|
}>(), {
|
||||||
depth: 0,
|
depth: 0,
|
||||||
componentTypes: () => [],
|
componentTypes: () => [],
|
||||||
@@ -369,10 +408,52 @@ const props = withDefaults(defineProps<{
|
|||||||
lockedTypeLabel: '',
|
lockedTypeLabel: '',
|
||||||
allowSubcomponents: true,
|
allowSubcomponents: true,
|
||||||
maxSubcomponentDepth: Infinity,
|
maxSubcomponentDepth: Infinity,
|
||||||
|
restrictedMode: false,
|
||||||
|
isLocked: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['remove'])
|
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 componentTypes = computed(() => props.componentTypes ?? [])
|
||||||
const pieceTypes = computed(() => props.pieceTypes ?? [])
|
const pieceTypes = computed(() => props.pieceTypes ?? [])
|
||||||
const productTypes = computed(() => props.productTypes ?? [])
|
const productTypes = computed(() => props.productTypes ?? [])
|
||||||
|
|||||||
@@ -29,10 +29,65 @@
|
|||||||
:total="total"
|
:total="total"
|
||||||
:limit="limit"
|
:limit="limit"
|
||||||
:offset="offset"
|
:offset="offset"
|
||||||
|
@related="openRelatedModal"
|
||||||
@edit="openEditPage"
|
@edit="openEditPage"
|
||||||
@delete="confirmDelete"
|
@delete="confirmDelete"
|
||||||
@update:offset="onOffsetChange"
|
@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>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -41,6 +96,7 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
|||||||
import { useHead, useRouter } from "#imports";
|
import { useHead, useRouter } from "#imports";
|
||||||
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
import ModelTypesToolbar from "~/components/model-types/Toolbar.vue";
|
||||||
import ModelTypesTable from "~/components/model-types/Table.vue";
|
import ModelTypesTable from "~/components/model-types/Table.vue";
|
||||||
|
import { useApi } from "~/composables/useApi";
|
||||||
import {
|
import {
|
||||||
deleteModelType,
|
deleteModelType,
|
||||||
listModelTypes,
|
listModelTypes,
|
||||||
@@ -82,6 +138,7 @@ let activeController: AbortController | null = null;
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { showError, showSuccess } = useToast();
|
const { showError, showSuccess } = useToast();
|
||||||
|
const { get } = useApi();
|
||||||
|
|
||||||
const headingText = computed(() => props.heading);
|
const headingText = computed(() => props.heading);
|
||||||
const descriptionText = computed(
|
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(
|
watch(
|
||||||
() => searchInput.value,
|
() => searchInput.value,
|
||||||
(value) => {
|
(value) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
minlength="2"
|
minlength="2"
|
||||||
maxlength="120"
|
maxlength="120"
|
||||||
required
|
required
|
||||||
|
:disabled="restrictedMode"
|
||||||
/>
|
/>
|
||||||
<p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p>
|
<p v-if="errors.name" class="mt-1 text-sm text-error">{{ errors.name }}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
rows="4"
|
rows="4"
|
||||||
name="notes"
|
name="notes"
|
||||||
maxlength="2000"
|
maxlength="2000"
|
||||||
|
:disabled="restrictedMode"
|
||||||
></textarea>
|
></textarea>
|
||||||
<p class="mt-1 text-xs text-base-content/70">Saisissez des informations complémentaires (facultatif).</p>
|
<p class="mt-1 text-xs text-base-content/70">Saisissez des informations complémentaires (facultatif).</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,6 +83,7 @@
|
|||||||
v-model="componentStructure"
|
v-model="componentStructure"
|
||||||
:allow-subcomponents="allowComponentSubcomponents"
|
:allow-subcomponents="allowComponentSubcomponents"
|
||||||
:max-subcomponent-depth="componentSubcomponentMaxDepth"
|
:max-subcomponent-depth="componentSubcomponentMaxDepth"
|
||||||
|
:restricted-mode="restrictedMode"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -92,7 +95,7 @@
|
|||||||
Aperçu :
|
Aperçu :
|
||||||
<span class="font-medium text-base-content">{{ pieceStructurePreview }}</span>
|
<span class="font-medium text-base-content">{{ pieceStructurePreview }}</span>
|
||||||
</p>
|
</p>
|
||||||
<PieceModelStructureEditor v-model="pieceStructure" />
|
<PieceModelStructureEditor v-model="pieceStructure" :restricted-mode="restrictedMode" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -103,11 +106,30 @@
|
|||||||
Aperçu :
|
Aperçu :
|
||||||
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
|
<span class="font-medium text-base-content">{{ productStructurePreview }}</span>
|
||||||
</p>
|
</p>
|
||||||
<PieceModelStructureEditor v-model="productStructure" />
|
<PieceModelStructureEditor v-model="productStructure" :restricted-mode="restrictedMode" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</section>
|
</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">
|
<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')">
|
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
||||||
Annuler
|
Annuler
|
||||||
@@ -150,6 +172,10 @@ const props = withDefaults(defineProps<{
|
|||||||
structureLoading?: boolean
|
structureLoading?: boolean
|
||||||
allowComponentSubcomponents?: boolean
|
allowComponentSubcomponents?: boolean
|
||||||
componentSubcomponentMaxDepth?: number
|
componentSubcomponentMaxDepth?: number
|
||||||
|
disableSubmit?: boolean
|
||||||
|
disableSubmitMessage?: string
|
||||||
|
restrictedMode?: boolean
|
||||||
|
restrictedModeMessage?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
initialData: null,
|
initialData: null,
|
||||||
saving: false,
|
saving: false,
|
||||||
@@ -157,6 +183,10 @@ const props = withDefaults(defineProps<{
|
|||||||
structureLoading: false,
|
structureLoading: false,
|
||||||
allowComponentSubcomponents: true,
|
allowComponentSubcomponents: true,
|
||||||
componentSubcomponentMaxDepth: 1,
|
componentSubcomponentMaxDepth: 1,
|
||||||
|
disableSubmit: false,
|
||||||
|
disableSubmitMessage: '',
|
||||||
|
restrictedMode: false,
|
||||||
|
restrictedModeMessage: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -173,6 +203,18 @@ const componentSubcomponentMaxDepth = computed(() =>
|
|||||||
? props.componentSubcomponentMaxDepth
|
? props.componentSubcomponentMaxDepth
|
||||||
: 1,
|
: 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>({
|
const form = reactive<ModelTypePayload>({
|
||||||
name: '',
|
name: '',
|
||||||
@@ -248,7 +290,7 @@ const resetForm = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const submitLabel = computed(() => (props.mode === 'edit' ? 'Enregistrer' : 'Créer'))
|
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 = () => {
|
const validate = () => {
|
||||||
errors.name = undefined
|
errors.name = undefined
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
<tr class="text-base-content/70">
|
<tr class="text-base-content/70">
|
||||||
<th scope="col">Nom</th>
|
<th scope="col">Nom</th>
|
||||||
<th scope="col">Notes</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -45,6 +45,9 @@
|
|||||||
<span v-else class="text-base-content/50">—</span>
|
<span v-else class="text-base-content/50">—</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right space-x-2">
|
<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)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||||
Éditer
|
Éditer
|
||||||
</button>
|
</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/80" v-if="item.notes">{{ item.notes }}</p>
|
||||||
<p class="mt-3 text-sm text-base-content/50" v-else>Pas de 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">
|
<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)">
|
<button type="button" class="btn btn-ghost btn-sm" @click="emit('edit', item)">
|
||||||
Éditer
|
Éditer
|
||||||
</button>
|
</button>
|
||||||
@@ -123,6 +129,7 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
(e: 'related', item: ModelType): void;
|
||||||
(e: 'edit', item: ModelType): void;
|
(e: 'edit', item: ModelType): void;
|
||||||
(e: 'delete', item: ModelType): void;
|
(e: 'delete', item: ModelType): void;
|
||||||
(e: 'update:offset', offset: number): 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -26,6 +26,10 @@
|
|||||||
:initial-data="initialData"
|
:initial-data="initialData"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:disable-submit="isSubmitBlocked"
|
||||||
|
:disable-submit-message="submitBlockMessage"
|
||||||
|
:restricted-mode="isRestrictedMode"
|
||||||
|
:restricted-mode-message="restrictedModeMessage"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
@@ -38,6 +42,7 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { useHead, useRoute, useRouter } from '#imports'
|
import { useHead, useRoute, useRouter } from '#imports'
|
||||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||||
|
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -48,6 +53,23 @@ const loading = ref(true)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
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(() =>
|
const title = computed(() =>
|
||||||
initialData.value?.name
|
initialData.value?.name
|
||||||
? `Modifier « ${initialData.value.name} »`
|
? `Modifier « ${initialData.value.name} »`
|
||||||
@@ -88,6 +110,8 @@ const loadCategory = async () => {
|
|||||||
notes: response.notes ?? response.description ?? '',
|
notes: response.notes ?? response.description ?? '',
|
||||||
structure: response.structure ?? undefined,
|
structure: response.structure ?? undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadLinkedCount(id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(normalizeError(error))
|
showError(normalizeError(error))
|
||||||
await navigateBackToList()
|
await navigateBackToList()
|
||||||
@@ -101,6 +125,9 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
if (guardSubmitOrNotify()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const id = String(route.params.id)
|
const id = String(route.params.id)
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -434,6 +434,74 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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 }">
|
<NuxtLink to="/component-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||||
Annuler
|
Annuler
|
||||||
@@ -466,6 +534,7 @@ import { useToast } from '~/composables/useToast'
|
|||||||
import { extractRelationId } from '~/shared/apiRelations'
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
|
import { useComponentHistory, type ComponentHistoryEntry } from '~/composables/useComponentHistory'
|
||||||
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
import { formatStructurePreview, normalizeStructureForEditor } from '~/shared/modelUtils'
|
||||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
import type { ComponentModelStructure } from '~/shared/types/inventory'
|
||||||
@@ -503,6 +572,12 @@ const { ensureConstructeurs } = useConstructeurs()
|
|||||||
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEntity } = useCustomFields()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
const { loadDocumentsByComponent, uploadDocuments, deleteDocument } = useDocuments()
|
||||||
|
const {
|
||||||
|
history,
|
||||||
|
loading: historyLoading,
|
||||||
|
error: historyError,
|
||||||
|
loadHistory,
|
||||||
|
} = useComponentHistory()
|
||||||
|
|
||||||
const component = ref<any | null>(null)
|
const component = ref<any | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -513,6 +588,88 @@ const loadingDocuments = ref(false)
|
|||||||
const componentDocuments = ref<any[]>([])
|
const componentDocuments = ref<any[]>([])
|
||||||
const previewDocument = ref<any | null>(null)
|
const previewDocument = ref<any | null>(null)
|
||||||
const previewVisible = ref(false)
|
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 selectedTypeId = ref<string>('')
|
||||||
const editionForm = reactive({
|
const editionForm = reactive({
|
||||||
name: '' as string,
|
name: '' as string,
|
||||||
@@ -756,6 +913,7 @@ const fetchComponent = async () => {
|
|||||||
component.value.customFieldValues = customValues.data
|
component.value.customFieldValues = customValues.data
|
||||||
refreshCustomFieldInputs(undefined, customValues.data)
|
refreshCustomFieldInputs(undefined, customValues.data)
|
||||||
}
|
}
|
||||||
|
await loadHistory(result.data.id)
|
||||||
} else {
|
} else {
|
||||||
component.value = null
|
component.value = null
|
||||||
componentDocuments.value = []
|
componentDocuments.value = []
|
||||||
|
|||||||
@@ -26,6 +26,10 @@
|
|||||||
:initial-data="initialData"
|
:initial-data="initialData"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:disable-submit="isSubmitBlocked"
|
||||||
|
:disable-submit-message="submitBlockMessage"
|
||||||
|
:restricted-mode="isRestrictedMode"
|
||||||
|
:restricted-mode-message="restrictedModeMessage"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
@@ -38,6 +42,7 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { useHead, useRoute, useRouter } from '#imports'
|
import { useHead, useRoute, useRouter } from '#imports'
|
||||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||||
|
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -48,6 +53,23 @@ const loading = ref(true)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
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(() =>
|
const title = computed(() =>
|
||||||
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de pièce',
|
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 ?? '',
|
notes: response.notes ?? response.description ?? '',
|
||||||
structure: response.structure ?? undefined,
|
structure: response.structure ?? undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadLinkedCount(id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(normalizeError(error))
|
showError(normalizeError(error))
|
||||||
await navigateBackToList()
|
await navigateBackToList()
|
||||||
@@ -99,6 +123,9 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
if (guardSubmitOrNotify()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const id = String(route.params.id)
|
const id = String(route.params.id)
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -381,6 +381,74 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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 }">
|
<NuxtLink to="/pieces-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||||
Annuler
|
Annuler
|
||||||
@@ -409,6 +477,7 @@ import { useApi } from '~/composables/useApi'
|
|||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
|
import { usePieceHistory, type PieceHistoryEntry } from '~/composables/usePieceHistory'
|
||||||
import { extractRelationId } from '~/shared/apiRelations'
|
import { extractRelationId } from '~/shared/apiRelations'
|
||||||
import { getFileIcon } from '~/utils/fileIcons'
|
import { getFileIcon } from '~/utils/fileIcons'
|
||||||
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument, isImageDocument, isPdfDocument } from '~/utils/documentPreview'
|
||||||
@@ -444,6 +513,12 @@ const { upsertCustomFieldValue, updateCustomFieldValue, getCustomFieldValuesByEn
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
const { loadDocumentsByPiece, uploadDocuments, deleteDocument } = useDocuments()
|
||||||
const { ensureConstructeurs } = useConstructeurs()
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
|
const {
|
||||||
|
history,
|
||||||
|
loading: historyLoading,
|
||||||
|
error: historyError,
|
||||||
|
loadHistory,
|
||||||
|
} = usePieceHistory()
|
||||||
|
|
||||||
const piece = ref<any | null>(null)
|
const piece = ref<any | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -455,6 +530,88 @@ const pieceDocuments = ref<any[]>([])
|
|||||||
const previewDocument = ref<any | null>(null)
|
const previewDocument = ref<any | null>(null)
|
||||||
const previewVisible = ref(false)
|
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 selectedTypeId = ref<string>('')
|
||||||
const pieceTypeDetails = ref<any | null>(null)
|
const pieceTypeDetails = ref<any | null>(null)
|
||||||
const editionForm = reactive({
|
const editionForm = reactive({
|
||||||
@@ -742,6 +899,7 @@ const fetchPiece = async () => {
|
|||||||
refreshCustomFieldInputs(undefined, customValues.data)
|
refreshCustomFieldInputs(undefined, customValues.data)
|
||||||
}
|
}
|
||||||
await loadPieceTypeDetails(result.data)
|
await loadPieceTypeDetails(result.data)
|
||||||
|
await loadHistory(result.data.id)
|
||||||
} else {
|
} else {
|
||||||
piece.value = null
|
piece.value = null
|
||||||
pieceDocuments.value = []
|
pieceDocuments.value = []
|
||||||
|
|||||||
@@ -26,6 +26,10 @@
|
|||||||
:initial-data="initialData"
|
:initial-data="initialData"
|
||||||
:lock-category="true"
|
:lock-category="true"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
|
:disable-submit="isSubmitBlocked"
|
||||||
|
:disable-submit-message="submitBlockMessage"
|
||||||
|
:restricted-mode="isRestrictedMode"
|
||||||
|
:restricted-mode-message="restrictedModeMessage"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
@@ -38,6 +42,7 @@ import { computed, onMounted, ref } from 'vue'
|
|||||||
import { useHead, useRoute, useRouter } from '#imports'
|
import { useHead, useRoute, useRouter } from '#imports'
|
||||||
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
import ModelTypeForm from '~/components/model-types/ModelTypeForm.vue'
|
||||||
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
import { getModelType, updateModelType, type ModelTypePayload } from '~/services/modelTypes'
|
||||||
|
import { useCategoryEditGuard } from '~/composables/useCategoryEditGuard'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -48,6 +53,23 @@ const loading = ref(true)
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const initialData = ref<Partial<ModelTypePayload> | null>(null)
|
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(() =>
|
const title = computed(() =>
|
||||||
initialData.value?.name ? `Modifier « ${initialData.value.name} »` : 'Modifier une catégorie de produit',
|
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 ?? '',
|
notes: response.notes ?? response.description ?? '',
|
||||||
structure: response.structure ?? undefined,
|
structure: response.structure ?? undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await loadLinkedCount(id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showError(normalizeError(error))
|
showError(normalizeError(error))
|
||||||
await navigateBackToList()
|
await navigateBackToList()
|
||||||
@@ -99,6 +123,9 @@ const handleCancel = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
const handleSubmit = async (payload: Parameters<typeof updateModelType>[1]) => {
|
||||||
|
if (guardSubmitOrNotify()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const id = String(route.params.id)
|
const id = String(route.params.id)
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -301,6 +301,74 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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 }">
|
<NuxtLink to="/product-catalog" class="btn btn-ghost" :class="{ 'btn-disabled': saving }">
|
||||||
Annuler
|
Annuler
|
||||||
@@ -329,6 +397,7 @@ import { useCustomFields } from '~/composables/useCustomFields'
|
|||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||||
|
import { useProductHistory, type ProductHistoryEntry } from '~/composables/useProductHistory'
|
||||||
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
import { formatProductStructurePreview, normalizeProductStructureForSave } from '~/shared/modelUtils'
|
||||||
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
import { uniqueConstructeurIds } from '~/shared/constructeurUtils'
|
||||||
import { getModelType } from '~/services/modelTypes'
|
import { getModelType } from '~/services/modelTypes'
|
||||||
@@ -359,6 +428,12 @@ const {
|
|||||||
deleteDocument: deleteProductDocument,
|
deleteDocument: deleteProductDocument,
|
||||||
} = useDocuments()
|
} = useDocuments()
|
||||||
const { ensureConstructeurs } = useConstructeurs()
|
const { ensureConstructeurs } = useConstructeurs()
|
||||||
|
const {
|
||||||
|
history,
|
||||||
|
loading: historyLoading,
|
||||||
|
error: historyError,
|
||||||
|
loadHistory,
|
||||||
|
} = useProductHistory()
|
||||||
|
|
||||||
const product = ref<any | null>(null)
|
const product = ref<any | null>(null)
|
||||||
const productType = ref<any | null>(null)
|
const productType = ref<any | null>(null)
|
||||||
@@ -373,6 +448,86 @@ const productDocuments = ref<any[]>([])
|
|||||||
const previewDocument = ref<any | null>(null)
|
const previewDocument = ref<any | null>(null)
|
||||||
const previewVisible = ref(false)
|
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 = (
|
const refreshCustomFieldInputs = (
|
||||||
structureOverride?: ProductModelStructure | null,
|
structureOverride?: ProductModelStructure | null,
|
||||||
valuesOverride?: any[] | null,
|
valuesOverride?: any[] | null,
|
||||||
@@ -509,6 +664,7 @@ const loadProduct = async () => {
|
|||||||
}
|
}
|
||||||
await hydrateForm()
|
await hydrateForm()
|
||||||
await refreshDocuments()
|
await refreshDocuments()
|
||||||
|
await loadHistory(result.data.id)
|
||||||
} else {
|
} else {
|
||||||
product.value = null
|
product.value = null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export async function listModelTypes(params: ModelTypeListParams = {}, opts: { s
|
|||||||
const query: Record<string, string | number> = {};
|
const query: Record<string, string | number> = {};
|
||||||
|
|
||||||
if (params.q) {
|
if (params.q) {
|
||||||
query.q = params.q;
|
query.name = params.q;
|
||||||
}
|
}
|
||||||
if (params.category) {
|
if (params.category) {
|
||||||
query.category = params.category;
|
query.category = params.category;
|
||||||
|
|||||||
Reference in New Issue
Block a user