2 Commits

Author SHA1 Message Date
3705b8daed feat(model-types): allow adding custom fields in restricted mode
When a category has linked items (pieces, components, products),
enable restricted mode instead of blocking all edits:
- Allow adding new custom fields
- Lock existing fields from modification or deletion
- Hide add buttons for products, pieces, and subcomponents
- Display informative message about restricted mode

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 19:53:56 +01:00
Matthieu
202b964b24 chore(branding): update navbar logo and app name 2026-01-25 22:31:40 +01:00
10 changed files with 203 additions and 28 deletions

View File

@@ -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);

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -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'])

View File

@@ -7,10 +7,10 @@
Produits inclus par défaut
</h3>
<p class="text-xs text-base-content/70">
Ces produits safficheront lors de la création dune 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&#10;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 },
)

View File

@@ -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&#10;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 ?? [])

View File

@@ -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,21 @@
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"
@@ -161,6 +174,8 @@ const props = withDefaults(defineProps<{
componentSubcomponentMaxDepth?: number
disableSubmit?: boolean
disableSubmitMessage?: string
restrictedMode?: boolean
restrictedModeMessage?: string
}>(), {
initialData: null,
saving: false,
@@ -170,6 +185,8 @@ const props = withDefaults(defineProps<{
componentSubcomponentMaxDepth: 1,
disableSubmit: false,
disableSubmitMessage: '',
restrictedMode: false,
restrictedModeMessage: '',
})
const emit = defineEmits<{
@@ -192,6 +209,12 @@ const disableSubmitMessage = computed(() =>
? 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: '',

View File

@@ -32,7 +32,7 @@ const extractTotal = (payload: any, fallbackLength: number) => {
export function useCategoryEditGuard (config: GuardConfig) {
const { get } = useApi()
const { showError } = useToast()
const { showInfo } = useToast()
const linkedCount = ref(0)
const linkedLoading = ref(false)
@@ -64,11 +64,15 @@ export function useCategoryEditGuard (config: GuardConfig) {
}
}
const isSubmitBlocked = computed(
() => linkedLoading.value || linkedCount.value > 0,
const isRestrictedMode = computed(
() => !linkedLoading.value && linkedCount.value > 0,
)
const submitBlockMessage = computed(() => {
const isSubmitBlocked = computed(
() => linkedLoading.value,
)
const restrictedModeMessage = computed(() => {
if (linkedLoading.value) {
return config.labels.verifying
}
@@ -76,23 +80,32 @@ export function useCategoryEditGuard (config: GuardConfig) {
return ''
}
if (linkedCount.value === 1) {
return `Modification bloquée : 1 ${config.labels.singular} est déjà lié à cette catégorie.`
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 `Modification bloquée : ${linkedCount.value} ${config.labels.plural} sont déjà liés à cette catégorie.`
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
}
showError(submitBlockMessage.value || 'Modification bloquée pour cette catégorie.')
showInfo(submitBlockMessage.value || 'Veuillez patienter...')
return true
}
return {
linkedCount,
linkedLoading,
isRestrictedMode,
isSubmitBlocked,
restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,

View File

@@ -28,6 +28,8 @@
:saving="saving"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode"
:restricted-mode-message="restrictedModeMessage"
@submit="handleSubmit"
@cancel="handleCancel"
/>
@@ -52,7 +54,9 @@ const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null)
const {
isRestrictedMode,
isSubmitBlocked,
restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,

View File

@@ -28,6 +28,8 @@
:saving="saving"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode"
:restricted-mode-message="restrictedModeMessage"
@submit="handleSubmit"
@cancel="handleCancel"
/>
@@ -52,7 +54,9 @@ const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null)
const {
isRestrictedMode,
isSubmitBlocked,
restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,

View File

@@ -28,6 +28,8 @@
:saving="saving"
:disable-submit="isSubmitBlocked"
:disable-submit-message="submitBlockMessage"
:restricted-mode="isRestrictedMode"
:restricted-mode-message="restrictedModeMessage"
@submit="handleSubmit"
@cancel="handleCancel"
/>
@@ -52,7 +54,9 @@ const saving = ref(false)
const initialData = ref<Partial<ModelTypePayload> | null>(null)
const {
isRestrictedMode,
isSubmitBlocked,
restrictedModeMessage,
submitBlockMessage,
loadLinkedCount,
guardSubmitOrNotify,