Refactor duplicated site forms and requirements
This commit is contained in:
@@ -1,137 +1,50 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card bg-base-100 shadow-lg">
|
<RequirementListEditor
|
||||||
<div class="card-body space-y-4">
|
v-model="requirements"
|
||||||
<div class="flex items-center justify-between">
|
:type-options="componentTypes"
|
||||||
<h3 class="card-title text-lg">Familles de composants</h3>
|
type-field="typeComposantId"
|
||||||
<button type="button" class="btn btn-primary btn-sm" @click="addRequirement">
|
:labels="labels"
|
||||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
:default-requirement="createDefaultRequirement"
|
||||||
Ajouter une famille
|
:required-fallback="true"
|
||||||
</button>
|
:min-fallback="1"
|
||||||
</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 })"
|
|
||||||
/>
|
|
||||||
</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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
||||||
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
|
||||||
import { useComponentTypes } from '~/composables/useComponentTypes'
|
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({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: Array,
|
type: Array as () => Requirement[],
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -142,50 +55,40 @@ const { componentTypes, loadComponentTypes } = useComponentTypes()
|
|||||||
|
|
||||||
const requirements = computed({
|
const requirements = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (value) => emit('update:modelValue', value),
|
set: (value: Requirement[]) => emit('update:modelValue', value),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const createDefaultRequirement = (): Requirement => ({
|
||||||
|
id: undefined,
|
||||||
|
typeComposantId: null,
|
||||||
|
label: '',
|
||||||
|
minCount: 1,
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!componentTypes.value.length) {
|
if (!componentTypes.value.length) {
|
||||||
await loadComponentTypes()
|
await loadComponentTypes()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const addRequirement = () => {
|
|
||||||
requirements.value = [
|
|
||||||
...requirements.value,
|
|
||||||
{
|
|
||||||
id: undefined,
|
|
||||||
typeComposantId: null,
|
|
||||||
label: '',
|
|
||||||
minCount: 1,
|
|
||||||
maxCount: null,
|
|
||||||
required: true,
|
|
||||||
allowNewModels: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,137 +1,50 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card bg-base-100 shadow-lg">
|
<RequirementListEditor
|
||||||
<div class="card-body space-y-4">
|
v-model="requirements"
|
||||||
<div class="flex items-center justify-between">
|
:type-options="pieceTypes"
|
||||||
<h3 class="card-title text-lg">Pièces principales</h3>
|
type-field="typePieceId"
|
||||||
<button type="button" class="btn btn-primary btn-sm" @click="addRequirement">
|
:labels="labels"
|
||||||
<IconLucidePlus class="w-4 h-4 mr-2" aria-hidden="true" />
|
:default-requirement="createDefaultRequirement"
|
||||||
Ajouter un groupe
|
:required-fallback="false"
|
||||||
</button>
|
:min-fallback="0"
|
||||||
</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 })"
|
|
||||||
/>
|
|
||||||
</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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import RequirementListEditor from '~/components/common/RequirementListEditor.vue'
|
||||||
import IconLucideTrash2 from '~icons/lucide/trash-2'
|
|
||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
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({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
type: Array,
|
type: Array as () => Requirement[],
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -142,50 +55,40 @@ const { pieceTypes, loadPieceTypes } = usePieceTypes()
|
|||||||
|
|
||||||
const requirements = computed({
|
const requirements = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (value) => emit('update:modelValue', value),
|
set: (value: Requirement[]) => emit('update:modelValue', value),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const createDefaultRequirement = (): Requirement => ({
|
||||||
|
id: undefined,
|
||||||
|
typePieceId: null,
|
||||||
|
label: '',
|
||||||
|
minCount: 0,
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!pieceTypes.value.length) {
|
if (!pieceTypes.value.length) {
|
||||||
await loadPieceTypes()
|
await loadPieceTypes()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const addRequirement = () => {
|
|
||||||
requirements.value = [
|
|
||||||
...requirements.value,
|
|
||||||
{
|
|
||||||
id: undefined,
|
|
||||||
typePieceId: null,
|
|
||||||
label: '',
|
|
||||||
minCount: 0,
|
|
||||||
maxCount: null,
|
|
||||||
required: false,
|
|
||||||
allowNewModels: true,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
246
app/components/common/RequirementListEditor.vue
Normal file
246
app/components/common/RequirementListEditor.vue
Normal 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.
|
||||||
|
-->
|
||||||
98
app/components/sites/SiteContactFormFields.vue
Normal file
98
app/components/sites/SiteContactFormFields.vue
Normal 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" />
|
||||||
|
-->
|
||||||
@@ -16,74 +16,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4">
|
<SiteContactFormFields :form="props.site" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button type="button" class="btn" @click="emit('close')">
|
<button type="button" class="btn" @click="emit('close')">
|
||||||
@@ -100,6 +33,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { toRefs } from 'vue'
|
import { toRefs } from 'vue'
|
||||||
|
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: {
|
visible: {
|
||||||
|
|||||||
@@ -19,74 +19,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 gap-4">
|
<SiteContactFormFields :form="props.form" />
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="border-t border-base-200 pt-4 space-y-4">
|
<div class="border-t border-base-200 pt-4 space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -163,6 +96,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, toRefs } from 'vue'
|
import { computed, toRefs } from 'vue'
|
||||||
import DocumentUpload from '~/components/DocumentUpload.vue'
|
import DocumentUpload from '~/components/DocumentUpload.vue'
|
||||||
|
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: {
|
visible: {
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
<span v-else class="text-gray-400">Non défini</span>
|
<span v-else class="text-gray-400">Non défini</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ formatDate(document.createdAt) }}</td>
|
<td>{{ formatFrenchDate(document.createdAt) }}</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -120,6 +120,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { useDocuments } from '~/composables/useDocuments'
|
import { useDocuments } from '~/composables/useDocuments'
|
||||||
import { getFileIcon } from '~/utils/fileIcons'
|
import { getFileIcon } from '~/utils/fileIcons'
|
||||||
import { canPreviewDocument } from '~/utils/documentPreview'
|
import { canPreviewDocument } from '~/utils/documentPreview'
|
||||||
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
import DocumentPreviewModal from '~/components/DocumentPreviewModal.vue'
|
||||||
import IconLucideFileSearch from '~icons/lucide/file-search'
|
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 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) => {
|
const downloadDocument = (doc) => {
|
||||||
if (!doc?.path) return
|
if (!doc?.path) return
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
<td class="hidden md:table-cell">{{ model.description || '—' }}</td>
|
<td class="hidden md:table-cell">{{ model.description || '—' }}</td>
|
||||||
<td>{{ model.typeComposant?.name || 'Non défini' }}</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 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">
|
<td class="text-right space-x-2">
|
||||||
<button type="button" class="btn btn-sm btn-outline" @click="startEdit(model)">
|
<button type="button" class="btn btn-sm btn-outline" @click="startEdit(model)">
|
||||||
Éditer
|
Éditer
|
||||||
@@ -178,6 +178,7 @@ import { useComponentTypes } from '~/composables/useComponentTypes'
|
|||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import ComponentModelStructureEditor from '~/components/ComponentModelStructureEditor.vue'
|
import ComponentModelStructureEditor from '~/components/ComponentModelStructureEditor.vue'
|
||||||
import { formatStructurePreview } from '~/shared/modelUtils'
|
import { formatStructurePreview } from '~/shared/modelUtils'
|
||||||
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
import IconLucideLayers from '~icons/lucide/layers'
|
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 () => {
|
const refreshModels = async () => {
|
||||||
if (selectedType.value === 'all') {
|
if (selectedType.value === 'all') {
|
||||||
await loadComponentModels()
|
await loadComponentModels()
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="hidden md:table-cell">{{ model.description || '—' }}</td>
|
<td class="hidden md:table-cell">{{ model.description || '—' }}</td>
|
||||||
<td>{{ model.typePiece?.name || 'Non défini' }}</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">
|
<td class="text-right space-x-2">
|
||||||
<button type="button" class="btn btn-sm btn-outline" @click="startEdit(model)">
|
<button type="button" class="btn btn-sm btn-outline" @click="startEdit(model)">
|
||||||
Éditer
|
Éditer
|
||||||
@@ -169,6 +169,7 @@ import { usePieceModels } from '~/composables/usePieceModels'
|
|||||||
import { usePieceTypes } from '~/composables/usePieceTypes'
|
import { usePieceTypes } from '~/composables/usePieceTypes'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import { formatStructurePreview } from '~/shared/modelUtils'
|
import { formatStructurePreview } from '~/shared/modelUtils'
|
||||||
|
import { formatFrenchDate } from '~/utils/date'
|
||||||
import IconLucidePlus from '~icons/lucide/plus'
|
import IconLucidePlus from '~icons/lucide/plus'
|
||||||
import IconLucidePackage from '~icons/lucide/package'
|
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 () => {
|
const refreshModels = async () => {
|
||||||
if (selectedType.value === 'all') {
|
if (selectedType.value === 'all') {
|
||||||
await loadPieceModels()
|
await loadPieceModels()
|
||||||
|
|||||||
20
app/utils/date.ts
Normal file
20
app/utils/date.ts
Normal 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
35
dup-report.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Rapport de déduplication
|
||||||
|
|
||||||
|
## DUP-001 · Score 92 · Formulaire de contact site
|
||||||
|
- **Motif** : duplication à l’identique 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 l’objet du formulaire).
|
||||||
|
- **Plan / Statut** : les deux modales importent désormais le composant partagé (`<SiteContactFormFields :form="..." />`), supprimant l’ancienne duplication. Aucun changement d’API 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é d’association,
|
||||||
|
- `labels` (structure textuelle),
|
||||||
|
- `defaultRequirement`, `requiredFallback`, `minFallback`.
|
||||||
|
- **Plan / Statut** : les deux sections n’hé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 l’utilisent 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.
|
||||||
Reference in New Issue
Block a user