2 Commits

Author SHA1 Message Date
Matthieu c82c21c0cd feat(reference-auto) : formula builder component + composant support + changelog v1.9.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 09:51:22 +02:00
Matthieu a339e722a6 feat(reference-auto) : display referenceAuto in piece views + formula config in ModelTypeForm
- Piece interface: add referenceAuto field
- piece/[id].vue: read-only display with auto badge
- pieces/[id]/edit.vue: disabled input when referenceAuto is set
- pieces-catalog.vue: new column "Réf. auto"
- PieceItem.vue: badge + detail line for referenceAuto
- ModelTypeForm.vue: formula + required fields config for PIECE category
- modelTypes.ts: add referenceFormula/requiredFieldsForReference to types

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 20:33:33 +01:00
10 changed files with 221 additions and 3 deletions
+6
View File
@@ -42,6 +42,7 @@
Rattachée à {{ piece.parentComponentName }} Rattachée à {{ piece.parentComponentName }}
</span> </span>
<span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span> <span v-if="pieceData.reference" class="badge badge-outline badge-sm">{{ pieceData.reference }}</span>
<span v-if="pieceData.referenceAuto" class="badge badge-secondary badge-sm" title="Référence auto">{{ pieceData.referenceAuto }}</span>
<template v-if="pieceConstructeursDisplay.length"> <template v-if="pieceConstructeursDisplay.length">
<span <span
v-for="constructeur in pieceConstructeursDisplay" v-for="constructeur in pieceConstructeursDisplay"
@@ -106,6 +107,10 @@
pieceData.reference || "Non définie" pieceData.reference || "Non définie"
}}</span> }}</span>
</div> </div>
<div v-if="pieceData.referenceAuto">
<span class="font-medium">Référence auto:</span>
<span class="ml-2">{{ pieceData.referenceAuto }}</span>
</div>
<div> <div>
<span class="font-medium">Fournisseur:</span> <span class="font-medium">Fournisseur:</span>
<div v-if="!isEditMode" class="ml-2"> <div v-if="!isEditMode" class="ml-2">
@@ -301,6 +306,7 @@ const emit = defineEmits(['update', 'edit', 'custom-field-update', 'delete'])
const pieceData = reactive({ const pieceData = reactive({
name: props.piece.name || '', name: props.piece.name || '',
reference: props.piece.reference || '', reference: props.piece.reference || '',
referenceAuto: props.piece.referenceAuto || null,
prix: props.piece.prix || '', prix: props.piece.prix || '',
productId: props.piece.product?.id || props.piece.productId || null, productId: props.piece.product?.id || props.piece.productId || null,
quantity: props.piece.quantity ?? 1, quantity: props.piece.quantity ?? 1,
+40 -3
View File
@@ -108,6 +108,13 @@
</template> </template>
</section> </section>
<ReferenceFormulaBuilder
v-if="form.category === 'PIECE' || form.category === 'COMPONENT'"
v-model="form.referenceFormula"
:custom-fields="formulaBuilderCustomFields"
:disabled="isReadonly"
/>
<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
@@ -177,14 +184,33 @@ const componentSubcomponentMaxDepth = computed(() =>
) )
const isReadonly = computed(() => props.readonly === true) const isReadonly = computed(() => props.readonly === true)
const form = reactive<ModelTypePayload>({ const form = reactive<ModelTypePayload & { referenceFormula?: string | null }>({
name: '', name: '',
code: '', code: '',
category: props.initialCategory, category: props.initialCategory,
notes: '', notes: '',
structure: undefined, structure: undefined,
referenceFormula: null,
}) })
const formulaBuilderCustomFields = computed(() => {
if (form.category === 'PIECE') {
const fields = pieceStructure.value?.customFields
return Array.isArray(fields) ? fields : []
}
if (form.category === 'COMPONENT') {
const fields = componentStructure.value?.customFields
return Array.isArray(fields) ? fields : []
}
return []
})
const extractFormulaFields = (formula: string | null | undefined): string[] => {
if (!formula) return []
const matches = [...formula.matchAll(/\{(\w+)\}/g)]
return [...new Set(matches.map(m => m[1]).filter((n): n is string => n !== undefined))]
}
const errors = reactive<{ name?: string }>({}) const errors = reactive<{ name?: string }>({})
const nameInput = ref<HTMLInputElement | null>(null) const nameInput = ref<HTMLInputElement | null>(null)
@@ -248,6 +274,9 @@ const resetForm = () => {
errors.name = undefined errors.name = undefined
const incomingAny = incoming as Record<string, unknown>
form.referenceFormula = typeof incomingAny.referenceFormula === 'string' ? incomingAny.referenceFormula : null
resetStructures(incoming.structure, form.category) resetStructures(incoming.structure, form.category)
} }
@@ -286,20 +315,28 @@ const handleSubmit = () => {
} }
if (form.category === 'COMPONENT') { if (form.category === 'COMPONENT') {
const formula = form.referenceFormula || null
const requiredFields = extractFormulaFields(formula)
emit('submit', { emit('submit', {
...common, ...common,
category: 'COMPONENT', category: 'COMPONENT',
structure: normalizeStructureForSave(cloneStructure(componentStructure.value)), structure: normalizeStructureForSave(cloneStructure(componentStructure.value)),
}) referenceFormula: formula,
requiredFieldsForReference: requiredFields.length ? requiredFields : null,
} as ModelTypePayload)
return return
} }
if (form.category === 'PIECE') { if (form.category === 'PIECE') {
const formula = form.referenceFormula || null
const requiredFields = extractFormulaFields(formula)
emit('submit', { emit('submit', {
...common, ...common,
category: 'PIECE', category: 'PIECE',
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)), structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
}) referenceFormula: formula,
requiredFieldsForReference: requiredFields.length ? requiredFields : null,
} as ModelTypePayload)
return return
} }
@@ -0,0 +1,115 @@
<template>
<section class="space-y-4">
<header>
<h3 class="text-lg font-semibold text-base-content">Génération de référence automatique</h3>
<p class="mt-1 text-sm text-base-content/70">
Cliquez sur un champ pour l'insérer dans la formule. Vous pouvez aussi taper du texte libre (séparateurs, préfixes…).
</p>
</header>
<div class="rounded-lg border border-base-300 p-4 space-y-4">
<div v-if="fieldNames.length" class="flex flex-wrap gap-2">
<button
v-for="name in fieldNames"
:key="name"
type="button"
class="btn btn-xs btn-outline btn-primary font-mono"
:disabled="disabled"
@click="insertField(name)"
>
{{ name }}
</button>
</div>
<p v-else class="text-sm text-base-content/50 italic">
Aucun champ personnalisé défini dans la structure.
</p>
<div>
<label class="label" for="reference-formula">
<span class="label-text">Formule</span>
</label>
<input
id="reference-formula"
ref="inputRef"
:value="modelValue"
type="text"
class="input input-bordered w-full font-mono"
placeholder="Ex: SNU {serie}-{diametre}/{type}"
:disabled="disabled"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value || null)"
/>
<p class="mt-1 text-xs text-base-content/60">
Laissez vide si ce type n'utilise pas de référence automatique.
</p>
</div>
<div v-if="modelValue" class="rounded bg-base-200 px-3 py-2 text-sm">
<span class="text-base-content/70">Aperçu :</span>
<span class="ml-1 font-mono font-semibold">{{ preview }}</span>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
interface CustomField {
name: string
type: string
}
const props = defineProps<{
modelValue: string | null | undefined
customFields: CustomField[]
disabled?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string | null): void
}>()
const inputRef = ref<HTMLInputElement | null>(null)
const fieldNames = computed(() =>
props.customFields.map(f => f.name).filter((n): n is string => Boolean(n)),
)
const previewExamples: Record<string, string> = {
text: 'VALEUR',
number: '123',
select: 'OPTION',
boolean: 'OUI',
date: '2026-01-01',
}
const preview = computed(() => {
if (!props.modelValue) return ''
const fieldMap = new Map<string, string>()
for (const f of props.customFields) {
if (f.name) {
fieldMap.set(f.name, previewExamples[f.type] ?? 'VALEUR')
}
}
return props.modelValue.replace(/\{(\w+)\}/g, (_, name) => fieldMap.get(name) ?? '???')
})
const insertField = (fieldName: string) => {
const placeholder = `{${fieldName}}`
const input = inputRef.value
const current = props.modelValue ?? ''
if (!input) {
emit('update:modelValue', current + placeholder)
return
}
const start = input.selectionStart ?? current.length
const end = input.selectionEnd ?? start
const updated = current.slice(0, start) + placeholder + current.slice(end)
emit('update:modelValue', updated)
nextTick(() => {
const newPos = start + placeholder.length
input.focus()
input.setSelectionRange(newPos, newPos)
})
}
</script>
+1
View File
@@ -10,6 +10,7 @@ export interface Piece {
id: string id: string
name: string name: string
reference?: string | null reference?: string | null
referenceAuto?: string | null
description?: string | null description?: string | null
typePieceId?: string | null typePieceId?: string | null
typePiece?: { id: string; name?: string } | null typePiece?: { id: string; name?: string } | null
+22
View File
@@ -72,6 +72,28 @@ const badgeClass = (type: ChangeType) => {
}; };
const releases: Release[] = [ const releases: Release[] = [
{
version: "v1.9.5",
date: "2026-03-31",
changes: [
{
type: "feat",
text: "Référence automatique des pièces et composants : génération d'une référence technique à partir d'une formule configurable sur la catégorie, recalculée automatiquement à chaque modification des champs personnalisés",
},
{
type: "feat",
text: "Formula builder interactif : sélection des champs disponibles par clic (chips) avec insertion à la position du curseur, aperçu live avec valeurs d'exemple, et calcul automatique des champs requis",
},
{
type: "feat",
text: "Versioning des entités : numéro de version incrémenté automatiquement à chaque modification, avec historique des versions et possibilité de restaurer une version antérieure",
},
{
type: "feat",
text: "Bouton de sauvegarde unique sur la fiche machine : remplacement des sauvegardes automatiques par un bouton explicite, avec affichage des versions sur les liens composants/pièces/produits",
},
],
},
{ {
version: "v1.9.4", version: "v1.9.4",
date: "2026-03-25", date: "2026-03-25",
@@ -114,6 +114,8 @@ const loadCategory = async () => {
category: response.category, category: response.category,
notes: response.notes ?? response.description ?? '', notes: response.notes ?? response.description ?? '',
structure: (response.structure as ComponentModelStructure | null) ?? undefined, structure: (response.structure as ComponentModelStructure | null) ?? undefined,
referenceFormula: response.referenceFormula ?? null,
requiredFieldsForReference: response.requiredFieldsForReference ?? null,
} }
} catch (error) { } catch (error) {
+11
View File
@@ -113,6 +113,17 @@
</div> </div>
</div> </div>
<!-- Référence auto (read-only, shown only if computed) -->
<div v-if="piece.referenceAuto" class="form-control">
<label class="label">
<span class="label-text">Référence auto</span>
</label>
<div class="input input-bordered input-sm md:input-md bg-base-200 flex items-center gap-2">
<span class="font-mono font-semibold">{{ piece.referenceAuto }}</span>
<span class="badge badge-sm badge-ghost">auto</span>
</div>
</div>
<!-- Référence + Fournisseurs (if value or edit mode) --> <!-- Référence + Fournisseurs (if value or edit mode) -->
<div <div
v-if="isEditMode || piece.reference || editionForm.constructeurIds.length" v-if="isEditMode || piece.reference || editionForm.constructeurIds.length"
+5
View File
@@ -69,6 +69,10 @@
{{ row.piece.reference || '—' }} {{ row.piece.reference || '—' }}
</template> </template>
<template #cell-referenceAuto="{ row }">
{{ row.piece.referenceAuto || '—' }}
</template>
<template #cell-description="{ row }"> <template #cell-description="{ row }">
<div v-if="row.piece.description" class="group relative"> <div v-if="row.piece.description" class="group relative">
<span class="block cursor-help truncate">{{ row.piece.description }}</span> <span class="block cursor-help truncate">{{ row.piece.description }}</span>
@@ -174,6 +178,7 @@ const columns = [
{ key: 'preview', label: 'Aperçu', width: 'w-24' }, { key: 'preview', label: 'Aperçu', width: 'w-24' },
{ key: 'name', label: 'Nom', sortable: true }, { key: 'name', label: 'Nom', sortable: true },
{ key: 'reference', label: 'Référence' }, { key: 'reference', label: 'Référence' },
{ key: 'referenceAuto', label: 'Réf. auto' },
{ key: 'description', label: 'Description' }, { key: 'description', label: 'Description' },
{ key: 'suppliers', label: 'Fournisseurs' }, { key: 'suppliers', label: 'Fournisseurs' },
{ key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' }, { key: 'typePiece', label: 'Type de pièce', filterable: true, filterPlaceholder: 'Filtrer…' },
+13
View File
@@ -114,6 +114,19 @@
> >
</div> </div>
<div v-if="piece?.referenceAuto" class="form-control">
<label class="label">
<span class="label-text">Référence auto</span>
</label>
<input
:value="piece.referenceAuto"
type="text"
class="input input-bordered input-sm md:input-md bg-base-200"
disabled
title="Générée automatiquement à partir du type et des champs personnalisés"
>
</div>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text">Fournisseur</span> <span class="label-text">Fournisseur</span>
+6
View File
@@ -23,11 +23,15 @@ export interface BaseModelTypePayload {
export interface ComponentModelTypePayload extends BaseModelTypePayload { export interface ComponentModelTypePayload extends BaseModelTypePayload {
category: 'COMPONENT'; category: 'COMPONENT';
structure?: ComponentModelStructure | null; structure?: ComponentModelStructure | null;
referenceFormula?: string | null;
requiredFieldsForReference?: string[] | null;
} }
export interface PieceModelTypePayload extends BaseModelTypePayload { export interface PieceModelTypePayload extends BaseModelTypePayload {
category: 'PIECE'; category: 'PIECE';
structure?: PieceModelStructure | null; structure?: PieceModelStructure | null;
referenceFormula?: string | null;
requiredFieldsForReference?: string[] | null;
} }
export interface ProductModelTypePayload extends BaseModelTypePayload { export interface ProductModelTypePayload extends BaseModelTypePayload {
@@ -46,6 +50,8 @@ export interface ModelType extends BaseModelTypePayload {
updatedAt: string; updatedAt: string;
category: ModelCategory; category: ModelCategory;
structure: ModelTypeStructure; structure: ModelTypeStructure;
referenceFormula?: string | null;
requiredFieldsForReference?: string[] | null;
} }
export interface ModelTypeListParams { export interface ModelTypeListParams {