feat(reference-auto) : formula builder component + composant support + changelog v1.9.5
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -108,56 +108,12 @@
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section v-if="form.category === 'PIECE'" 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">
|
||||
Définissez une formule pour générer automatiquement une référence technique à partir des champs personnalisés.
|
||||
Utilisez <code class="bg-base-200 px-1 rounded">{'{'}nom_du_champ{'}'}</code> comme placeholder.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="rounded-lg border border-base-300 p-4 space-y-4">
|
||||
<div>
|
||||
<label class="label" for="reference-formula">
|
||||
<span class="label-text">Formule</span>
|
||||
</label>
|
||||
<input
|
||||
id="reference-formula"
|
||||
v-model.trim="form.referenceFormula"
|
||||
type="text"
|
||||
class="input input-bordered w-full font-mono"
|
||||
placeholder="Ex: {serie}{diametre}{type}"
|
||||
:disabled="isReadonly"
|
||||
/>
|
||||
<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>
|
||||
<label class="label" for="required-fields">
|
||||
<span class="label-text">Champs requis</span>
|
||||
</label>
|
||||
<input
|
||||
id="required-fields"
|
||||
v-model.trim="requiredFieldsInput"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Ex: serie, diametre, type"
|
||||
:disabled="isReadonly"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-base-content/60">
|
||||
Noms des champs séparés par des virgules. Si un champ requis est vide, la référence ne sera pas générée.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="form.referenceFormula" 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">{{ formulaPreview }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<button type="button" class="btn btn-ghost" :disabled="saving" @click="emit('cancel')">
|
||||
@@ -228,23 +184,33 @@ const componentSubcomponentMaxDepth = computed(() =>
|
||||
)
|
||||
const isReadonly = computed(() => props.readonly === true)
|
||||
|
||||
const form = reactive<ModelTypePayload & { referenceFormula?: string | null; requiredFieldsForReference?: string[] | null }>({
|
||||
const form = reactive<ModelTypePayload & { referenceFormula?: string | null }>({
|
||||
name: '',
|
||||
code: '',
|
||||
category: props.initialCategory,
|
||||
notes: '',
|
||||
structure: undefined,
|
||||
referenceFormula: null,
|
||||
requiredFieldsForReference: null,
|
||||
})
|
||||
|
||||
const requiredFieldsInput = ref('')
|
||||
|
||||
const formulaPreview = computed(() => {
|
||||
if (!form.referenceFormula) return ''
|
||||
return form.referenceFormula.replace(/\{(\w+)\}/g, '___')
|
||||
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 nameInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
@@ -310,8 +276,6 @@ const resetForm = () => {
|
||||
|
||||
const incomingAny = incoming as Record<string, unknown>
|
||||
form.referenceFormula = typeof incomingAny.referenceFormula === 'string' ? incomingAny.referenceFormula : null
|
||||
form.requiredFieldsForReference = Array.isArray(incomingAny.requiredFieldsForReference) ? incomingAny.requiredFieldsForReference : null
|
||||
requiredFieldsInput.value = form.requiredFieldsForReference?.join(', ') ?? ''
|
||||
|
||||
resetStructures(incoming.structure, form.category)
|
||||
}
|
||||
@@ -351,24 +315,27 @@ const handleSubmit = () => {
|
||||
}
|
||||
|
||||
if (form.category === 'COMPONENT') {
|
||||
const formula = form.referenceFormula || null
|
||||
const requiredFields = extractFormulaFields(formula)
|
||||
emit('submit', {
|
||||
...common,
|
||||
category: 'COMPONENT',
|
||||
structure: normalizeStructureForSave(cloneStructure(componentStructure.value)),
|
||||
})
|
||||
referenceFormula: formula,
|
||||
requiredFieldsForReference: requiredFields.length ? requiredFields : null,
|
||||
} as ModelTypePayload)
|
||||
return
|
||||
}
|
||||
|
||||
if (form.category === 'PIECE') {
|
||||
const parsedRequiredFields = requiredFieldsInput.value
|
||||
? requiredFieldsInput.value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: null
|
||||
const formula = form.referenceFormula || null
|
||||
const requiredFields = extractFormulaFields(formula)
|
||||
emit('submit', {
|
||||
...common,
|
||||
category: 'PIECE',
|
||||
structure: normalizePieceStructureForSave(clonePieceStructure(pieceStructure.value)),
|
||||
referenceFormula: form.referenceFormula || null,
|
||||
requiredFieldsForReference: parsedRequiredFields?.length ? parsedRequiredFields : null,
|
||||
referenceFormula: formula,
|
||||
requiredFieldsForReference: requiredFields.length ? requiredFields : null,
|
||||
} as ModelTypePayload)
|
||||
return
|
||||
}
|
||||
|
||||
115
app/components/model-types/ReferenceFormulaBuilder.vue
Normal file
115
app/components/model-types/ReferenceFormulaBuilder.vue
Normal file
@@ -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>
|
||||
@@ -72,6 +72,28 @@ const badgeClass = (type: ChangeType) => {
|
||||
};
|
||||
|
||||
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",
|
||||
date: "2026-03-25",
|
||||
|
||||
@@ -114,6 +114,8 @@ const loadCategory = async () => {
|
||||
category: response.category,
|
||||
notes: response.notes ?? response.description ?? '',
|
||||
structure: (response.structure as ComponentModelStructure | null) ?? undefined,
|
||||
referenceFormula: response.referenceFormula ?? null,
|
||||
requiredFieldsForReference: response.requiredFieldsForReference ?? null,
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface BaseModelTypePayload {
|
||||
export interface ComponentModelTypePayload extends BaseModelTypePayload {
|
||||
category: 'COMPONENT';
|
||||
structure?: ComponentModelStructure | null;
|
||||
referenceFormula?: string | null;
|
||||
requiredFieldsForReference?: string[] | null;
|
||||
}
|
||||
|
||||
export interface PieceModelTypePayload extends BaseModelTypePayload {
|
||||
|
||||
Reference in New Issue
Block a user