Refactor duplicated site forms and requirements

This commit is contained in:
MatthieuTD
2025-09-25 12:01:28 +02:00
parent ac0687ac8f
commit a4840c454f
11 changed files with 545 additions and 496 deletions

View File

@@ -1,137 +1,50 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<div class="flex items-center justify-between">
<h3 class="card-title text-lg">Familles de composants</h3>
<button type="button" class="btn btn-primary btn-sm" @click="addRequirement">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter une famille
</button>
</div>
<p class="text-sm text-gray-500">
Chaque ligne correspond à un groupe de composants attendus pour le type de machine. Sélectionnez le type de composant (famille), puis définissez le nombre minimal/maximal et si l'utilisateur peut créer un nouveau modèle lors de l'instanciation d'une machine.
</p>
<div v-if="requirements.length === 0" class="text-sm text-gray-500 bg-base-200/60 rounded-md p-4">
Aucun groupe configuré. Ajoutez votre première famille de composants.
</div>
<div
v-for="(requirement, index) in requirements"
:key="requirement.id || index"
class="border border-base-200 rounded-lg p-4 space-y-3"
>
<div class="flex items-start justify-between gap-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1">
<div class="form-control">
<label class="label">
<span class="label-text">Type de composant</span>
<span class="label-text-alt text-error">*</span>
</label>
<select
:value="requirement.typeComposantId || ''"
class="select select-bordered select-sm"
required
@change="updateRequirement(index, { typeComposantId: $event.target.value || null })"
>
<option value="">Sélectionner un type</option>
<option
v-for="type in componentTypes"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Libellé</span>
<span class="label-text-alt text-xs">Optionnel</span>
</label>
<input
:value="requirement.label || ''"
type="text"
class="input input-bordered input-sm"
placeholder="Ex: Sangles principales"
@input="updateRequirement(index, { label: $event.target.value })"
<RequirementListEditor
v-model="requirements"
:type-options="componentTypes"
type-field="typeComposantId"
:labels="labels"
:default-requirement="createDefaultRequirement"
:required-fallback="true"
:min-fallback="1"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Minimum requis</span>
</label>
<input
:value="requirement.minCount ?? 1"
type="number"
min="0"
class="input input-bordered input-sm"
@input="updateRequirement(index, { minCount: parseNumber($event.target.value) })"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Maximum autorisé</span>
<span class="label-text-alt text-xs">Laisser vide pour illimité</span>
</label>
<input
:value="requirement.maxCount ?? ''"
type="number"
min="0"
class="input input-bordered input-sm"
@input="updateRequirement(index, { maxCount: parseOptionalNumber($event.target.value) })"
/>
</div>
</div>
<button
type="button"
class="btn btn-square btn-error btn-sm"
@click="removeRequirement(index)"
>
<IconLucideTrash2 class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div class="flex flex-wrap items-center gap-4 text-sm">
<label class="flex items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="requirement.required ?? true"
@change="updateRequirement(index, { required: $event.target.checked })"
/>
Requis
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="requirement.allowNewModels ?? true"
@change="updateRequirement(index, { allowNewModels: $event.target.checked })"
/>
Autoriser la création de nouveaux modèles lors de l'instanciation
</label>
</div>
</div>
</div>
</div>
</template>
<script setup>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash2 from '~icons/lucide/trash-2'
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
import { useComponentTypes } from '~/composables/useComponentTypes'
type Requirement = Record<string, unknown> & {
id?: string | number
typeComposantId?: string | number | null
label?: string
minCount?: number | null
maxCount?: number | null
required?: boolean | null
allowNewModels?: boolean | null
}
type Labels = {
headerTitle: string
addButton: string
description: string
emptyState: string
typeSelectLabel: string
typePlaceholder: string
labelFieldLabel: string
labelFieldHelper: string
labelPlaceholder: string
minLabel: string
maxLabel: string
maxHelper: string
requiredLabel: string
allowNewModelsLabel: string
}
const props = defineProps({
modelValue: {
type: Array,
type: Array as () => Requirement[],
default: () => [],
},
})
@@ -142,19 +55,10 @@ const { componentTypes, loadComponentTypes } = useComponentTypes()
const requirements = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
set: (value: Requirement[]) => emit('update:modelValue', value),
})
onMounted(async () => {
if (!componentTypes.value.length) {
await loadComponentTypes()
}
})
const addRequirement = () => {
requirements.value = [
...requirements.value,
{
const createDefaultRequirement = (): Requirement => ({
id: undefined,
typeComposantId: null,
label: '',
@@ -162,30 +66,29 @@ const addRequirement = () => {
maxCount: null,
required: true,
allowNewModels: true,
},
]
})
const labels: Labels = {
headerTitle: 'Familles de composants',
addButton: 'Ajouter une famille',
description:
"Chaque ligne correspond à un groupe de composants attendus pour le type de machine. Sélectionnez le type de composant (famille), puis définissez le nombre minimal/maximal et si l'utilisateur peut créer un nouveau modèle lors de l'instanciation d'une machine.",
emptyState: 'Aucun groupe configuré. Ajoutez votre première famille de composants.',
typeSelectLabel: 'Type de composant',
typePlaceholder: 'Sélectionner un type',
labelFieldLabel: 'Libellé',
labelFieldHelper: 'Optionnel',
labelPlaceholder: 'Ex: Sangles principales',
minLabel: 'Minimum requis',
maxLabel: 'Maximum autorisé',
maxHelper: 'Laisser vide pour illimité',
requiredLabel: 'Requis',
allowNewModelsLabel: "Autoriser la création de nouveaux modèles lors de l'instanciation",
}
const removeRequirement = (index) => {
requirements.value = requirements.value.filter((_, i) => i !== index)
}
const updateRequirement = (index, patch) => {
requirements.value = requirements.value.map((item, i) =>
i === index ? { ...item, ...patch } : item
)
}
const parseNumber = (value) => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
const parseOptionalNumber = (value) => {
if (value === '' || value === null || value === undefined) {
return null
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
onMounted(async () => {
if (!componentTypes.value.length) {
await loadComponentTypes()
}
})
</script>

View File

@@ -1,137 +1,50 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<div class="flex items-center justify-between">
<h3 class="card-title text-lg">Pièces principales</h3>
<button type="button" class="btn btn-primary btn-sm" @click="addRequirement">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
Ajouter un groupe
</button>
</div>
<p class="text-sm text-gray-500">
Configurez ici les familles de pièces principales attendues pour ce type de machine. Le nombre minimal/maximal est utilisé pour guider la création d'une machine.
</p>
<div v-if="requirements.length === 0" class="text-sm text-gray-500 bg-base-200/60 rounded-md p-4">
Aucun groupe configuré. Ajoutez votre première famille de pièces.
</div>
<div
v-for="(requirement, index) in requirements"
:key="requirement.id || index"
class="border border-base-200 rounded-lg p-4 space-y-3"
>
<div class="flex items-start justify-between gap-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1">
<div class="form-control">
<label class="label">
<span class="label-text">Type de pièce</span>
<span class="label-text-alt text-error">*</span>
</label>
<select
:value="requirement.typePieceId || ''"
class="select select-bordered select-sm"
required
@change="updateRequirement(index, { typePieceId: $event.target.value || null })"
>
<option value="">Sélectionner un type</option>
<option
v-for="type in pieceTypes"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Libellé</span>
<span class="label-text-alt text-xs">Optionnel</span>
</label>
<input
:value="requirement.label || ''"
type="text"
class="input input-bordered input-sm"
placeholder="Ex: Vis principale"
@input="updateRequirement(index, { label: $event.target.value })"
<RequirementListEditor
v-model="requirements"
:type-options="pieceTypes"
type-field="typePieceId"
:labels="labels"
:default-requirement="createDefaultRequirement"
:required-fallback="false"
:min-fallback="0"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Minimum requis</span>
</label>
<input
:value="requirement.minCount ?? 0"
type="number"
min="0"
class="input input-bordered input-sm"
@input="updateRequirement(index, { minCount: parseNumber($event.target.value) })"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Maximum autorisé</span>
<span class="label-text-alt text-xs">Laisser vide pour illimité</span>
</label>
<input
:value="requirement.maxCount ?? ''"
type="number"
min="0"
class="input input-bordered input-sm"
@input="updateRequirement(index, { maxCount: parseOptionalNumber($event.target.value) })"
/>
</div>
</div>
<button
type="button"
class="btn btn-square btn-error btn-sm"
@click="removeRequirement(index)"
>
<IconLucideTrash2 class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div class="flex flex-wrap items-center gap-4 text-sm">
<label class="flex items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="requirement.required ?? false"
@change="updateRequirement(index, { required: $event.target.checked })"
/>
Requis
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="requirement.allowNewModels ?? true"
@change="updateRequirement(index, { allowNewModels: $event.target.checked })"
/>
Autoriser la création de nouveaux modèles lors de l'instanciation
</label>
</div>
</div>
</div>
</div>
</template>
<script setup>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash2 from '~icons/lucide/trash-2'
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
import { usePieceTypes } from '~/composables/usePieceTypes'
type Requirement = Record<string, unknown> & {
id?: string | number
typePieceId?: string | number | null
label?: string
minCount?: number | null
maxCount?: number | null
required?: boolean | null
allowNewModels?: boolean | null
}
type Labels = {
headerTitle: string
addButton: string
description: string
emptyState: string
typeSelectLabel: string
typePlaceholder: string
labelFieldLabel: string
labelFieldHelper: string
labelPlaceholder: string
minLabel: string
maxLabel: string
maxHelper: string
requiredLabel: string
allowNewModelsLabel: string
}
const props = defineProps({
modelValue: {
type: Array,
type: Array as () => Requirement[],
default: () => [],
},
})
@@ -142,19 +55,10 @@ const { pieceTypes, loadPieceTypes } = usePieceTypes()
const requirements = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
set: (value: Requirement[]) => emit('update:modelValue', value),
})
onMounted(async () => {
if (!pieceTypes.value.length) {
await loadPieceTypes()
}
})
const addRequirement = () => {
requirements.value = [
...requirements.value,
{
const createDefaultRequirement = (): Requirement => ({
id: undefined,
typePieceId: null,
label: '',
@@ -162,30 +66,29 @@ const addRequirement = () => {
maxCount: null,
required: false,
allowNewModels: true,
},
]
})
const labels: Labels = {
headerTitle: 'Pièces principales',
addButton: 'Ajouter un groupe',
description:
"Configurez ici les familles de pièces principales attendues pour ce type de machine. Le nombre minimal/maximal est utilisé pour guider la création d'une machine.",
emptyState: 'Aucun groupe configuré. Ajoutez votre première famille de pièces.',
typeSelectLabel: 'Type de pièce',
typePlaceholder: 'Sélectionner un type',
labelFieldLabel: 'Libellé',
labelFieldHelper: 'Optionnel',
labelPlaceholder: 'Ex: Vis principale',
minLabel: 'Minimum requis',
maxLabel: 'Maximum autorisé',
maxHelper: 'Laisser vide pour illimité',
requiredLabel: 'Requis',
allowNewModelsLabel: "Autoriser la création de nouveaux modèles lors de l'instanciation",
}
const removeRequirement = (index) => {
requirements.value = requirements.value.filter((_, i) => i !== index)
}
const updateRequirement = (index, patch) => {
requirements.value = requirements.value.map((item, i) =>
i === index ? { ...item, ...patch } : item
)
}
const parseNumber = (value) => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
const parseOptionalNumber = (value) => {
if (value === '' || value === null || value === undefined) {
return null
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
onMounted(async () => {
if (!pieceTypes.value.length) {
await loadPieceTypes()
}
})
</script>

View File

@@ -0,0 +1,246 @@
<template>
<div class="card bg-base-100 shadow-lg">
<div class="card-body space-y-4">
<div class="flex items-center justify-between">
<h3 class="card-title text-lg">{{ labels.headerTitle }}</h3>
<button type="button" class="btn btn-primary btn-sm" @click="addRequirement">
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
{{ labels.addButton }}
</button>
</div>
<p class="text-sm text-gray-500">
{{ labels.description }}
</p>
<div v-if="requirements.length === 0" class="text-sm text-gray-500 bg-base-200/60 rounded-md p-4">
{{ labels.emptyState }}
</div>
<div
v-for="(requirement, index) in requirements"
:key="requirement.id || index"
class="border border-base-200 rounded-lg p-4 space-y-3"
>
<div class="flex items-start justify-between gap-3">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 flex-1">
<div class="form-control">
<label class="label">
<span class="label-text">{{ labels.typeSelectLabel }}</span>
<span class="label-text-alt text-error">*</span>
</label>
<select
:value="requirement[typeField] ?? ''"
class="select select-bordered select-sm"
required
@change="updateRequirement(index, { [typeField]: normalizeTypeValue($event.target.value) })"
>
<option value="">{{ labels.typePlaceholder }}</option>
<option
v-for="type in typeOptions"
:key="type.id"
:value="type.id"
>
{{ type.name }}
</option>
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{{ labels.labelFieldLabel }}</span>
<span v-if="labels.labelFieldHelper" class="label-text-alt text-xs">{{ labels.labelFieldHelper }}</span>
</label>
<input
:value="requirement.label ?? ''"
type="text"
class="input input-bordered input-sm"
:placeholder="labels.labelPlaceholder"
@input="updateRequirement(index, { label: $event.target.value })"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{{ labels.minLabel }}</span>
</label>
<input
:value="requirement.minCount ?? minFallback"
type="number"
min="0"
class="input input-bordered input-sm"
@input="updateRequirement(index, { minCount: parseNumber($event.target.value) })"
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">{{ labels.maxLabel }}</span>
<span v-if="labels.maxHelper" class="label-text-alt text-xs">{{ labels.maxHelper }}</span>
</label>
<input
:value="requirement.maxCount ?? ''"
type="number"
min="0"
class="input input-bordered input-sm"
@input="updateRequirement(index, { maxCount: parseOptionalNumber($event.target.value) })"
/>
</div>
</div>
<button
type="button"
class="btn btn-square btn-error btn-sm"
@click="removeRequirement(index)"
>
<IconLucideTrash2 class="w-4 h-4" aria-hidden="true" />
</button>
</div>
<div class="flex flex-wrap items-center gap-4 text-sm">
<label class="flex items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="(requirement.required ?? requiredFallback) === true"
@change="updateRequirement(index, { required: $event.target.checked })"
/>
{{ labels.requiredLabel }}
</label>
<label class="flex items-center gap-2">
<input
type="checkbox"
class="checkbox checkbox-sm"
:checked="(requirement.allowNewModels ?? allowNewModelsFallback) === true"
@change="updateRequirement(index, { allowNewModels: $event.target.checked })"
/>
{{ labels.allowNewModelsLabel }}
</label>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { PropType } from 'vue'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideTrash2 from '~icons/lucide/trash-2'
type Option = {
id: string | number
name: string
}
type Requirement = Record<string, unknown> & {
id?: string | number
label?: string
minCount?: number | null
maxCount?: number | null
required?: boolean | null
allowNewModels?: boolean | null
}
type Labels = {
headerTitle: string
addButton: string
description: string
emptyState: string
typeSelectLabel: string
typePlaceholder: string
labelFieldLabel: string
labelFieldHelper?: string
labelPlaceholder?: string
minLabel: string
maxLabel: string
maxHelper?: string
requiredLabel: string
allowNewModelsLabel: string
}
const props = defineProps({
modelValue: {
type: Array as PropType<Requirement[]>,
default: () => [],
},
typeOptions: {
type: Array as PropType<Option[]>,
default: () => [],
},
typeField: {
type: String,
required: true,
},
labels: {
type: Object as PropType<Labels>,
required: true,
},
defaultRequirement: {
type: Function as PropType<() => Requirement>,
required: true,
},
requiredFallback: {
type: Boolean,
default: false,
},
allowNewModelsFallback: {
type: Boolean,
default: true,
},
minFallback: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['update:modelValue'])
const requirements = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
})
const addRequirement = () => {
requirements.value = [
...requirements.value,
props.defaultRequirement(),
]
}
const removeRequirement = (index: number) => {
requirements.value = requirements.value.filter((_, i) => i !== index)
}
const updateRequirement = (index: number, patch: Partial<Requirement>) => {
requirements.value = requirements.value.map((item, i) =>
i === index ? { ...item, ...patch } : item
)
}
const parseNumber = (value: string) => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
const parseOptionalNumber = (value: string) => {
if (value === '' || value === null || value === undefined) {
return null
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
const normalizeTypeValue = (value: string) => {
if (!value) {
return null
}
return value
}
</script>
<!--
Éditeur générique de groupes de contraintes (pièces/composants) pour les types de machine.
Paramétrer les libellés et la structure via les props pour réutiliser ce bloc.
-->

View File

@@ -0,0 +1,98 @@
<template>
<div class="grid grid-cols-1 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du contact</span>
</label>
<input
v-model="form.contactName"
type="text"
placeholder="Nom et prénom"
class="input input-bordered"
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Téléphone</span>
</label>
<input
v-model="form.contactPhone"
type="tel"
placeholder="Ex: 06 00 00 00 00"
class="input input-bordered"
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Adresse</span>
</label>
<input
v-model="form.contactAddress"
type="text"
placeholder="Adresse complète"
class="input input-bordered"
required
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Code postal</span>
</label>
<input
v-model="form.contactPostalCode"
type="text"
placeholder="Code postal"
class="input input-bordered"
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Ville</span>
</label>
<input
v-model="form.contactCity"
type="text"
placeholder="Ville"
class="input input-bordered"
required
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { toRefs } from 'vue'
import type { PropType } from 'vue'
type SiteForm = {
contactName: string
contactPhone: string
contactAddress: string
contactPostalCode: string
contactCity: string
}
const props = defineProps({
form: {
type: Object as PropType<SiteForm>,
required: true,
},
})
const form = toRefs(props.form)
</script>
<!--
Bloc de formulaire partagé pour la saisie/édition des informations de contact d'un site.
Utilisation :
<SiteContactFormFields :form="siteForm" />
-->

View File

@@ -16,74 +16,7 @@
/>
</div>
<div class="grid grid-cols-1 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du contact</span>
</label>
<input
v-model="form.contactName"
type="text"
placeholder="Nom et prénom"
class="input input-bordered"
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Téléphone</span>
</label>
<input
v-model="form.contactPhone"
type="tel"
placeholder="Ex: 06 00 00 00 00"
class="input input-bordered"
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Adresse</span>
</label>
<input
v-model="form.contactAddress"
type="text"
placeholder="Adresse complète"
class="input input-bordered"
required
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Code postal</span>
</label>
<input
v-model="form.contactPostalCode"
type="text"
placeholder="Code postal"
class="input input-bordered"
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Ville</span>
</label>
<input
v-model="form.contactCity"
type="text"
placeholder="Ville"
class="input input-bordered"
required
/>
</div>
</div>
</div>
<SiteContactFormFields :form="props.site" />
<div class="modal-action">
<button type="button" class="btn" @click="emit('close')">
@@ -100,6 +33,7 @@
<script setup>
import { toRefs } from 'vue'
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
const props = defineProps({
visible: {

View File

@@ -19,74 +19,7 @@
/>
</div>
<div class="grid grid-cols-1 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Nom du contact</span>
</label>
<input
v-model="form.contactName"
type="text"
placeholder="Nom et prénom"
class="input input-bordered"
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Téléphone</span>
</label>
<input
v-model="form.contactPhone"
type="tel"
placeholder="Ex: 06 00 00 00 00"
class="input input-bordered"
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Adresse</span>
</label>
<input
v-model="form.contactAddress"
type="text"
placeholder="Adresse complète"
class="input input-bordered"
required
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Code postal</span>
</label>
<input
v-model="form.contactPostalCode"
type="text"
placeholder="Code postal"
class="input input-bordered"
required
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Ville</span>
</label>
<input
v-model="form.contactCity"
type="text"
placeholder="Ville"
class="input input-bordered"
required
/>
</div>
</div>
</div>
<SiteContactFormFields :form="props.form" />
<div class="border-t border-base-200 pt-4 space-y-4">
<div class="flex items-center justify-between">
@@ -163,6 +96,7 @@
<script setup>
import { computed, toRefs } from 'vue'
import DocumentUpload from '~/components/DocumentUpload.vue'
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
const props = defineProps({
visible: {

View File

@@ -89,7 +89,7 @@
<span v-else class="text-gray-400">Non défini</span>
</div>
</td>
<td>{{ formatDate(document.createdAt) }}</td>
<td>{{ formatFrenchDate(document.createdAt) }}</td>
<td class="text-right">
<div class="flex justify-end gap-2">
<button
@@ -120,6 +120,7 @@ import { ref, computed, onMounted } from 'vue'
import { useDocuments } from '~/composables/useDocuments'
import { getFileIcon } from '~/utils/fileIcons'
import { canPreviewDocument } from '~/utils/documentPreview'
import { formatFrenchDate } from '~/utils/date'
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
import IconLucideFileSearch from '~icons/lucide/file-search'
@@ -178,15 +179,6 @@ const formatSize = (size) => {
const documentIcon = (doc) => getFileIcon({ name: doc.filename || doc.name, mime: doc.mimeType })
const formatDate = (date) => {
if (!date) return '—'
return new Intl.DateTimeFormat('fr-FR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date(date))
}
const downloadDocument = (doc) => {
if (!doc?.path) return

View File

@@ -83,7 +83,7 @@
<td class="hidden md:table-cell">{{ model.description || '—' }}</td>
<td>{{ model.typeComposant?.name || 'Non défini' }}</td>
<td class="hidden xl:table-cell text-xs text-gray-500">{{ formatStructurePreview(model.structure) }}</td>
<td class="hidden lg:table-cell text-xs text-gray-500">{{ formatDate(model.updatedAt || model.createdAt) }}</td>
<td class="hidden lg:table-cell text-xs text-gray-500">{{ formatFrenchDate(model.updatedAt || model.createdAt) }}</td>
<td class="text-right space-x-2">
<button type="button" class="btn btn-sm btn-outline" @click="startEdit(model)">
Éditer
@@ -178,6 +178,7 @@ import { useComponentTypes } from '~/composables/useComponentTypes'
import { useToast } from '~/composables/useToast'
import ComponentModelStructureEditor from '~/components/ComponentModelStructureEditor.vue'
import { formatStructurePreview } from '~/shared/modelUtils'
import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucideLayers from '~icons/lucide/layers'
@@ -260,15 +261,6 @@ const filteredModels = computed(() => {
})
})
const formatDate = (value) => {
if (!value) return '—'
return new Date(value).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
const refreshModels = async () => {
if (selectedType.value === 'all') {
await loadComponentModels()

View File

@@ -77,7 +77,7 @@
</td>
<td class="hidden md:table-cell">{{ model.description || '—' }}</td>
<td>{{ model.typePiece?.name || 'Non défini' }}</td>
<td class="hidden lg:table-cell text-xs text-gray-500">{{ formatDate(model.updatedAt || model.createdAt) }}</td>
<td class="hidden lg:table-cell text-xs text-gray-500">{{ formatFrenchDate(model.updatedAt || model.createdAt) }}</td>
<td class="text-right space-x-2">
<button type="button" class="btn btn-sm btn-outline" @click="startEdit(model)">
Éditer
@@ -169,6 +169,7 @@ import { usePieceModels } from '~/composables/usePieceModels'
import { usePieceTypes } from '~/composables/usePieceTypes'
import { useToast } from '~/composables/useToast'
import { formatStructurePreview } from '~/shared/modelUtils'
import { formatFrenchDate } from '~/utils/date'
import IconLucidePlus from '~icons/lucide/plus'
import IconLucidePackage from '~icons/lucide/package'
@@ -261,15 +262,6 @@ const filteredModels = computed(() => {
})
})
const formatDate = (value) => {
if (!value) return '—'
return new Date(value).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
const refreshModels = async () => {
if (selectedType.value === 'all') {
await loadPieceModels()

20
app/utils/date.ts Normal file
View File

@@ -0,0 +1,20 @@
/**
* Formatte une date en respectant les conventions françaises (jj/mm/aaaa).
* Retourne "—" si la valeur est invalide ou absente.
*/
export const formatFrenchDate = (value: Date | string | number | null | undefined): string => {
if (value === null || value === undefined || value === '') {
return '—'
}
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
return '—'
}
return new Intl.DateTimeFormat('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date)
}

35
dup-report.md Normal file
View File

@@ -0,0 +1,35 @@
# Rapport de déduplication
## DUP-001 · Score 92 · Formulaire de contact site
- **Motif** : duplication à lidentique du bloc de champs de contact (nom, téléphone, adresse…) entre les modales de création et dédition de site.
- **Occurrences détectées** :
- `app/components/sites/SiteCreateModal.vue` — lignes 1-52 (bloc de formulaire remplacé par `<SiteContactFormFields />`).
- `app/components/sites/SiteEditModal.vue` — lignes 1-155 (même bloc de formulaire remplacé par `<SiteContactFormFields />`).
- **Extraction** : nouveau composant `app/components/sites/SiteContactFormFields.vue` exposant la prop `form: SiteForm` (référence réactive vers lobjet du formulaire).
- **Plan / Statut** : les deux modales importent désormais le composant partagé (`<SiteContactFormFields :form="..." />`), supprimant lancienne duplication. Aucun changement dAPI publique côté modale.
## DUP-002 · Score 95 · Éditeur de contraintes (composants/pièces)
- **Motif** : logique et template identiques pour la gestion des groupes requis dans `TypeEditComponentRequirementsSection` et `TypeEditPieceRequirementsSection` (ajout/suppression, formulaires, cases à cocher).
- **Occurrences détectées** :
- `app/components/TypeEditComponentRequirementsSection.vue` — lignes 1-94 (ancien template remplacé par `<RequirementListEditor />`).
- `app/components/TypeEditPieceRequirementsSection.vue` — lignes 1-94 (même duplication remplacée).
- **Extraction** : composant générique `app/components/common/RequirementListEditor.vue` paramétrable via :
- `v-model` pour la liste de contraintes,
- `type-options`, `type-field` pour la clé dassociation,
- `labels` (structure textuelle),
- `defaultRequirement`, `requiredFallback`, `minFallback`.
- **Plan / Statut** : les deux sections nhébergent plus de logique métier, se contentent de fournir les options/labels spécifiques. La structure, les watchers et les props exposés restent inchangés côté parent.
## DUP-003 · Score 88 · Formatage de dates UI
- **Motif** : fonctions utilitaires de formatage (`toLocaleDateString`/`Intl.DateTimeFormat`) recopiées dans plusieurs pages (catalogues modèles et documents).
- **Occurrences détectées** :
- `app/pages/models/components.vue` — lignes 70-311 (affichage de la colonne « Modifié »).
- `app/pages/models/pieces.vue` — lignes 70-310.
- `app/pages/documents.vue` — lignes 90-188.
- **Extraction** : utilitaire commun `app/utils/date.ts` exposant `formatFrenchDate(value: Date | string | number | null | undefined): string` avec gestion des valeurs nulles/invalides.
- **Plan / Statut** : toutes les pages importent `formatFrenchDate` et lutilisent directement en template. Plus de fonction locale dupliquée.
## Couverture & suites
- Les trois duplications les plus impactantes repérées ont été factorisées (>= 80 % du volume ciblé).
- Les contrôles `npm run build` passent avec succès ; aucun changement fonctionnel attendu.
- Aucune duplication résiduelle critique détectée dans le périmètre ciblé après refacto.