Add shared form fields for contact details
This commit is contained in:
@@ -63,14 +63,19 @@
|
||||
<label class="label"><span class="label-text">Nom</span></label>
|
||||
<input v-model="createForm.name" type="text" class="input input-bordered" required />
|
||||
</div>
|
||||
<div class="form-control mb-3">
|
||||
<label class="label"><span class="label-text">Email</span></label>
|
||||
<input v-model="createForm.email" type="email" class="input input-bordered" placeholder="ex: contact@constructeur.com" />
|
||||
</div>
|
||||
<div class="form-control mb-3">
|
||||
<label class="label"><span class="label-text">Téléphone</span></label>
|
||||
<input v-model="createForm.phone" type="text" class="input input-bordered" placeholder="ex: 01 23 45 67 89" />
|
||||
</div>
|
||||
<FieldEmail
|
||||
v-model="createForm.email"
|
||||
class="mb-3"
|
||||
label="Email"
|
||||
placeholder="ex: contact@constructeur.com"
|
||||
autocomplete="email"
|
||||
/>
|
||||
<FieldPhone
|
||||
v-model="createForm.phone"
|
||||
class="mb-3"
|
||||
label="Téléphone"
|
||||
placeholder="ex: 01 23 45 67 89"
|
||||
/>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" @click="closeCreateModal">Annuler</button>
|
||||
@@ -87,6 +92,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import FieldEmail from '~/components/form/FieldEmail.vue'
|
||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||
|
||||
|
||||
112
app/components/form/FieldEmail.vue
Normal file
112
app/components/form/FieldEmail.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="form-control">
|
||||
<label v-if="label" class="label" :for="inputId">
|
||||
<span class="label-text">{{ label }}</span>
|
||||
<span v-if="required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
:value="modelValue"
|
||||
type="email"
|
||||
class="input input-bordered"
|
||||
:class="{ 'input-error': Boolean(errorMessage) }"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:name="name"
|
||||
:autocomplete="autocomplete"
|
||||
:pattern="EMAIL_INPUT_PATTERN"
|
||||
:aria-invalid="Boolean(errorMessage)"
|
||||
:aria-describedby="describedBy"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
@focus="(event) => emit('focus', event)"
|
||||
/>
|
||||
<p v-if="help" :id="helpId" class="mt-2 text-xs text-gray-500">
|
||||
{{ help }}
|
||||
</p>
|
||||
<p v-if="errorMessage" :id="errorId" class="mt-2 text-xs text-error">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import { normalizeEmail } from '~/utils/formatters/email'
|
||||
import { EMAIL_INPUT_PATTERN, EMAIL_VALIDATION_ERROR, emailSchema } from '~/shared/validation/email'
|
||||
|
||||
type Emits = {
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'blur', value: FocusEvent): void
|
||||
(event: 'focus', value: FocusEvent): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
label?: string
|
||||
required?: boolean
|
||||
error?: string | null
|
||||
help?: string | null
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
name?: string
|
||||
id?: string
|
||||
autocomplete?: string
|
||||
normalizeOnBlur?: boolean
|
||||
validateOnBlur?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
label: 'Email',
|
||||
required: false,
|
||||
error: null,
|
||||
help: null,
|
||||
placeholder: 'ex: contact@example.com',
|
||||
disabled: false,
|
||||
name: undefined,
|
||||
id: undefined,
|
||||
autocomplete: 'email',
|
||||
normalizeOnBlur: false,
|
||||
validateOnBlur: false,
|
||||
}
|
||||
)
|
||||
|
||||
const fallbackId = useId()
|
||||
const inputId = computed(() => props.id || fallbackId)
|
||||
const helpId = computed(() => (props.help ? `${inputId.value}-help` : undefined))
|
||||
const errorMessage = computed(() => props.error || null)
|
||||
const errorId = computed(() => (errorMessage.value ? `${inputId.value}-error` : undefined))
|
||||
const describedBy = computed(() => [helpId.value, errorId.value].filter(Boolean).join(' ') || undefined)
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
const onBlur = (event: FocusEvent) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
|
||||
if (props.normalizeOnBlur) {
|
||||
const normalized = normalizeEmail(target.value)
|
||||
if (normalized !== target.value) {
|
||||
target.value = normalized
|
||||
emit('update:modelValue', normalized)
|
||||
}
|
||||
}
|
||||
|
||||
if (props.validateOnBlur && !errorMessage.value) {
|
||||
const validation = emailSchema.validate(target.value)
|
||||
if (!validation.valid) {
|
||||
target.setCustomValidity(EMAIL_VALIDATION_ERROR)
|
||||
} else {
|
||||
target.setCustomValidity('')
|
||||
}
|
||||
}
|
||||
|
||||
emit('blur', event)
|
||||
}
|
||||
</script>
|
||||
113
app/components/form/FieldPhone.vue
Normal file
113
app/components/form/FieldPhone.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="form-control">
|
||||
<label v-if="label" class="label" :for="inputId">
|
||||
<span class="label-text">{{ label }}</span>
|
||||
<span v-if="required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
:value="modelValue"
|
||||
type="tel"
|
||||
class="input input-bordered"
|
||||
:class="{ 'input-error': Boolean(errorMessage) }"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:name="name"
|
||||
:autocomplete="autocomplete"
|
||||
:pattern="PHONE_INPUT_PATTERN"
|
||||
inputmode="tel"
|
||||
:aria-invalid="Boolean(errorMessage)"
|
||||
:aria-describedby="describedBy"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
@focus="(event) => emit('focus', event)"
|
||||
/>
|
||||
<p v-if="help" :id="helpId" class="mt-2 text-xs text-gray-500">
|
||||
{{ help }}
|
||||
</p>
|
||||
<p v-if="errorMessage" :id="errorId" class="mt-2 text-xs text-error">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import { formatPhone } from '~/utils/formatters/phone'
|
||||
import { PHONE_INPUT_PATTERN, PHONE_VALIDATION_ERROR, phoneSchema } from '~/shared/validation/phone'
|
||||
|
||||
type Emits = {
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'blur', value: FocusEvent): void
|
||||
(event: 'focus', value: FocusEvent): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
label?: string
|
||||
required?: boolean
|
||||
error?: string | null
|
||||
help?: string | null
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
name?: string
|
||||
id?: string
|
||||
autocomplete?: string
|
||||
normalizeOnBlur?: boolean
|
||||
validateOnBlur?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
label: 'Téléphone',
|
||||
required: false,
|
||||
error: null,
|
||||
help: null,
|
||||
placeholder: 'Ex: 06 00 00 00 00',
|
||||
disabled: false,
|
||||
name: undefined,
|
||||
id: undefined,
|
||||
autocomplete: 'tel',
|
||||
normalizeOnBlur: false,
|
||||
validateOnBlur: false,
|
||||
}
|
||||
)
|
||||
|
||||
const fallbackId = useId()
|
||||
const inputId = computed(() => props.id || fallbackId)
|
||||
const helpId = computed(() => (props.help ? `${inputId.value}-help` : undefined))
|
||||
const errorMessage = computed(() => props.error || null)
|
||||
const errorId = computed(() => (errorMessage.value ? `${inputId.value}-error` : undefined))
|
||||
const describedBy = computed(() => [helpId.value, errorId.value].filter(Boolean).join(' ') || undefined)
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
const onBlur = (event: FocusEvent) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
|
||||
if (props.normalizeOnBlur) {
|
||||
const formatted = formatPhone(target.value)
|
||||
if (formatted !== target.value) {
|
||||
target.value = formatted
|
||||
emit('update:modelValue', formatted)
|
||||
}
|
||||
}
|
||||
|
||||
if (props.validateOnBlur && !errorMessage.value) {
|
||||
const validation = phoneSchema.validate(target.value)
|
||||
if (!validation.valid) {
|
||||
target.setCustomValidity(PHONE_VALIDATION_ERROR)
|
||||
} else {
|
||||
target.setCustomValidity('')
|
||||
}
|
||||
}
|
||||
|
||||
emit('blur', event)
|
||||
}
|
||||
</script>
|
||||
@@ -13,18 +13,7 @@
|
||||
/>
|
||||
</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>
|
||||
<FieldPhone v-model="form.contactPhone" required />
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
@@ -73,6 +62,8 @@
|
||||
import { toRefs } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||
|
||||
type SiteForm = {
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
|
||||
@@ -80,14 +80,8 @@
|
||||
<input v-model="form.name" type="text" 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">Email</span></label>
|
||||
<input v-model="form.email" type="email" class="input input-bordered" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Téléphone</span></label>
|
||||
<input v-model="form.phone" type="text" class="input input-bordered" />
|
||||
</div>
|
||||
<FieldEmail v-model="form.email" label="Email" />
|
||||
<FieldPhone v-model="form.phone" label="Téléphone" />
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" @click="closeModal">Annuler</button>
|
||||
@@ -104,6 +98,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import FieldEmail from '~/components/form/FieldEmail.vue'
|
||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
|
||||
@@ -212,74 +212,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom du contact</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="newSite.contactName"
|
||||
type="text"
|
||||
placeholder="Nom et prénom"
|
||||
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">Téléphone</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="newSite.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="newSite.contactAddress"
|
||||
type="text"
|
||||
placeholder="Adresse complète"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</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="newSite.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="newSite.contactCity"
|
||||
type="text"
|
||||
placeholder="Ville"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SiteContactFormFields :form="newSite" />
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" @click="showAddSiteModal = false" class="btn btn-outline">
|
||||
@@ -386,6 +319,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||
import { useMachines } from '~/composables/useMachines'
|
||||
|
||||
34
app/shared/validation/email.ts
Normal file
34
app/shared/validation/email.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { normalizeEmail } from '~/utils/formatters/email'
|
||||
|
||||
export const EMAIL_INPUT_PATTERN = '[^\s@]+'
|
||||
|
||||
const EMAIL_VALIDATION_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export type EmailValidationResult = {
|
||||
valid: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const EMAIL_VALIDATION_ERROR = 'Adresse email invalide'
|
||||
|
||||
/**
|
||||
* Minimal email schema to align validation logic across the UI.
|
||||
*/
|
||||
export const emailSchema = {
|
||||
pattern: EMAIL_VALIDATION_PATTERN,
|
||||
message: EMAIL_VALIDATION_ERROR,
|
||||
validate(value: string): EmailValidationResult {
|
||||
const normalized = normalizeEmail(value)
|
||||
if (!normalized) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
if (EMAIL_VALIDATION_PATTERN.test(normalized)) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return { valid: false, error: EMAIL_VALIDATION_ERROR }
|
||||
},
|
||||
}
|
||||
|
||||
export const isEmailValid = (value: string): boolean => emailSchema.validate(value).valid
|
||||
36
app/shared/validation/phone.ts
Normal file
36
app/shared/validation/phone.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { normalizePhone } from '~/utils/formatters/phone'
|
||||
|
||||
/** Pattern used for the HTML input `pattern` attribute on phone fields. */
|
||||
export const PHONE_INPUT_PATTERN = '[0-9+ ]*'
|
||||
|
||||
const PHONE_VALIDATION_PATTERN = /^\+?\d{7,15}$/
|
||||
|
||||
export type PhoneValidationResult = {
|
||||
valid: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const PHONE_VALIDATION_ERROR = 'Numéro de téléphone invalide'
|
||||
|
||||
/**
|
||||
* Lightweight phone schema mirroring the API used across the app.
|
||||
*/
|
||||
export const phoneSchema = {
|
||||
pattern: PHONE_VALIDATION_PATTERN,
|
||||
message: PHONE_VALIDATION_ERROR,
|
||||
validate(value: string): PhoneValidationResult {
|
||||
const normalized = normalizePhone(value)
|
||||
if (!normalized) {
|
||||
// Empty values are considered valid; required-ness is handled by the caller.
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
if (PHONE_VALIDATION_PATTERN.test(normalized)) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return { valid: false, error: PHONE_VALIDATION_ERROR }
|
||||
},
|
||||
}
|
||||
|
||||
export const isPhoneValid = (value: string): boolean => phoneSchema.validate(value).valid
|
||||
37
app/utils/formatters/email.ts
Normal file
37
app/utils/formatters/email.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Basic helpers around email formatting shared across components and composables.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalises an email by trimming whitespace and converting it to lowercase.
|
||||
*/
|
||||
export const normalizeEmail = (rawValue: string): string => {
|
||||
const value = (rawValue || '').trim()
|
||||
return value.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Masks an email address by hiding the characters between the first and last letters
|
||||
* of the local part. Useful for UI fragments where we want partial obfuscation.
|
||||
*/
|
||||
export const maskEmail = (rawValue: string): string => {
|
||||
const value = normalizeEmail(rawValue)
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const [localPart, domain] = value.split('@')
|
||||
if (!domain) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (localPart.length <= 2) {
|
||||
return `${localPart[0] ?? ''}·@${domain}`
|
||||
}
|
||||
|
||||
const start = localPart[0]
|
||||
const end = localPart.slice(-1)
|
||||
const masked = '·'.repeat(Math.max(0, localPart.length - 2))
|
||||
|
||||
return `${start}${masked}${end}@${domain}`
|
||||
}
|
||||
67
app/utils/formatters/phone.ts
Normal file
67
app/utils/formatters/phone.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Utilities to normalize and format phone numbers without relying on external libraries.
|
||||
* The helpers keep the behaviour permissive to avoid breaking existing flows while
|
||||
* still providing a single place where formatting rules live.
|
||||
*/
|
||||
|
||||
/** Matches characters that should be kept when normalising a phone number. */
|
||||
const PHONE_CHAR_PATTERN = /[^+\d]/g
|
||||
|
||||
/**
|
||||
* Normalises a phone number by trimming whitespace, removing spacing/separators and
|
||||
* converting international prefixes written with `00` to their `+` variant.
|
||||
*/
|
||||
export const normalizePhone = (rawValue: string): string => {
|
||||
const trimmed = (rawValue || '').trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const cleaned = trimmed.replace(PHONE_CHAR_PATTERN, '')
|
||||
if (cleaned.startsWith('00')) {
|
||||
return `+${cleaned.slice(2)}`
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a phone number by grouping digits by two while keeping any international
|
||||
* prefix. The function remains tolerant to partially entered numbers.
|
||||
*/
|
||||
export const formatPhone = (rawValue: string): string => {
|
||||
const normalized = normalizePhone(rawValue)
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const hasInternationalPrefix = normalized.startsWith('+')
|
||||
const prefix = hasInternationalPrefix ? normalized.slice(0, 1) : ''
|
||||
const digits = hasInternationalPrefix ? normalized.slice(1) : normalized
|
||||
|
||||
const groups = digits.match(/.{1,2}/g) ?? []
|
||||
const grouped = groups.join(' ')
|
||||
|
||||
return prefix ? `${prefix}${grouped ? ' ' : ''}${grouped}` : grouped
|
||||
}
|
||||
|
||||
/**
|
||||
* Masks a phone number for display purposes by replacing the middle digits with ·.
|
||||
* Useful for UI fragments where the full number should not be exposed.
|
||||
*/
|
||||
export const maskPhone = (rawValue: string): string => {
|
||||
const normalized = normalizePhone(rawValue)
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (normalized.length <= 4) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const start = normalized.slice(0, 2)
|
||||
const end = normalized.slice(-2)
|
||||
const maskedMiddle = '·'.repeat(Math.max(0, normalized.length - 4))
|
||||
|
||||
return `${start}${maskedMiddle}${end}`
|
||||
}
|
||||
100
micro-dup-report.md
Normal file
100
micro-dup-report.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# Micro duplication report
|
||||
|
||||
## MDUP-001 · Score 92 · Type form-field
|
||||
- **Pattern**: Champ téléphone complet (label, input `tel`, placeholder "Ex: 06 00 00 00 00", règles de validation implicites).
|
||||
- **Occurrences**:
|
||||
- `app/components/ConstructeurSelect.vue` L70-L86 — modal de création de constructeur. 【F:app/components/ConstructeurSelect.vue†L66-L88】
|
||||
- `app/pages/constructeurs.vue` L82-L92 — formulaire de création/édition. 【F:app/pages/constructeurs.vue†L80-L92】
|
||||
- `app/components/sites/SiteContactFormFields.vue` L1-L57 — bloc de formulaire de contact. 【F:app/components/sites/SiteContactFormFields.vue†L1-L58】
|
||||
- `app/pages/index.vue` L200-L224 — création rapide d’un site. 【F:app/pages/index.vue†L200-L224】
|
||||
- **Extraction**: ✅ `app/components/form/FieldPhone.vue` (props : `modelValue`, `label`, `required`, `error`, `help`, `placeholder`, `disabled`, `normalizeOnBlur`, `validateOnBlur`). 【F:app/components/form/FieldPhone.vue†L1-L113】
|
||||
- **Plan de remplacement**: Remplacer chaque bloc par `<FieldPhone v-model="..." />`. Call-sites déjà migrés ci-dessus.
|
||||
|
||||
## MDUP-002 · Score 90 · Type form-field
|
||||
- **Pattern**: Champ email (label « Email », input `type="email"`, placeholder d’exemple, aucune validation mutualisée).
|
||||
- **Occurrences**:
|
||||
- `app/components/ConstructeurSelect.vue` L66-L84. 【F:app/components/ConstructeurSelect.vue†L66-L86】
|
||||
- `app/pages/constructeurs.vue` L82-L90. 【F:app/pages/constructeurs.vue†L80-L92】
|
||||
- **Extraction**: ✅ `app/components/form/FieldEmail.vue` avec normalisation et validation partagée. 【F:app/components/form/FieldEmail.vue†L1-L112】
|
||||
- **Plan de remplacement**: Blocs remplacés par `<FieldEmail />` sur les deux formulaires.
|
||||
|
||||
## MDUP-003 · Score 88 · Type form-field
|
||||
- **Pattern**: Groupe « informations de contact site » (Nom du contact, Téléphone, Adresse, Code postal, Ville) répliqué.
|
||||
- **Occurrences**:
|
||||
- `app/components/sites/SiteContactFormFields.vue` (composant existant). 【F:app/components/sites/SiteContactFormFields.vue†L1-L58】
|
||||
- `app/pages/index.vue` L200-L223 — doublait le bloc dans le modal rapide. 【F:app/pages/index.vue†L200-L223】
|
||||
- **Extraction**: ✅ Réutilisation directe du composant `SiteContactFormFields` sur la page index. Aucun changement d’API.
|
||||
- **Plan de remplacement**: Remplacer le bloc du modal par `<SiteContactFormFields :form="newSite" />` (effectué).
|
||||
|
||||
## MDUP-004 · Score 86 · Type tiny-logic
|
||||
- **Pattern**: Liaisons `computed({ get, set })` pour faire transiter `v-model` entre props et emits.
|
||||
- **Occurrences**:
|
||||
- `app/components/TypeEditComponentRequirementsSection.vue` L45-L59. 【F:app/components/TypeEditComponentRequirementsSection.vue†L45-L59】
|
||||
- `app/components/TypeEditPieceRequirementsSection.vue` L45-L59. 【F:app/components/TypeEditPieceRequirementsSection.vue†L45-L59】
|
||||
- `app/components/common/RequirementListEditor.vue` L198-L203. 【F:app/components/common/RequirementListEditor.vue†L198-L204】
|
||||
- `app/components/TypeEditCustomFieldsSection.vue` L163-L168. 【F:app/components/TypeEditCustomFieldsSection.vue†L163-L168】
|
||||
- `app/components/TypeEditBaseInfoSection.vue` L82-L102. 【F:app/components/TypeEditBaseInfoSection.vue†L82-L102】
|
||||
- `app/components/sites/SiteEditModal.vue` L140-L154. 【F:app/components/sites/SiteEditModal.vue†L140-L154】
|
||||
- **Extraction proposée**: `app/composables/useControlledModel.ts` retournant `{ model }` via `useVModel` maison (prop name configurable, options pour defaultValue et transform).
|
||||
- **Plan**: 1) Introduire le composable, 2) remapper les computed existantes, 3) supprimer le code duplicatif.
|
||||
|
||||
## MDUP-005 · Score 82 · Type tiny-logic
|
||||
- **Pattern**: Fonctions `createDefaultRequirement` quasi identiques (seuls champs `minCount`, `required` et `type*Id` changent).
|
||||
- **Occurrences**:
|
||||
- `app/components/TypeEditComponentRequirementsSection.vue` L61-L69. 【F:app/components/TypeEditComponentRequirementsSection.vue†L61-L69】
|
||||
- `app/components/TypeEditPieceRequirementsSection.vue` L61-L69. 【F:app/components/TypeEditPieceRequirementsSection.vue†L61-L69】
|
||||
- **Extraction proposée**: `app/shared/requirements/defaults.ts` exportant `createRequirementDefaults({ min, required, typeKey })`.
|
||||
- **Plan**: Mutualiser la fonction, la paramétrer par options, adapter les deux sections.
|
||||
|
||||
## MDUP-006 · Score 80 · Type tiny-logic
|
||||
- **Pattern**: Effet `onMounted` identique qui teste la liste et déclenche `loadX` si vide.
|
||||
- **Occurrences**:
|
||||
- `app/components/TypeEditComponentRequirementsSection.vue` L89-L93. 【F:app/components/TypeEditComponentRequirementsSection.vue†L89-L93】
|
||||
- `app/components/TypeEditPieceRequirementsSection.vue` L89-L93. 【F:app/components/TypeEditPieceRequirementsSection.vue†L89-L93】
|
||||
- **Extraction proposée**: `useEnsureOptionsLoaded(optionsRef, loader)` dans `app/composables/` pour encapsuler le check + chargement (support async/await, options pour refetch forcé).
|
||||
- **Plan**: Appeler le composable dans les deux sections et supprimer le code inline.
|
||||
|
||||
## MDUP-007 · Score 78 · Type ui-fragment
|
||||
- **Pattern**: Pieds de modale avec boutons « Annuler » + primaire + spinner optionnel.
|
||||
- **Occurrences**:
|
||||
- `app/components/ConstructeurSelect.vue` L80-L86. 【F:app/components/ConstructeurSelect.vue†L80-L86】
|
||||
- `app/pages/constructeurs.vue` L86-L91. 【F:app/pages/constructeurs.vue†L86-L91】
|
||||
- `app/components/sites/SiteCreateModal.vue` L21-L27. 【F:app/components/sites/SiteCreateModal.vue†L21-L27】
|
||||
- `app/components/sites/SiteEditModal.vue` L82-L89. 【F:app/components/sites/SiteEditModal.vue†L82-L89】
|
||||
- `app/pages/index.vue` L217-L223 & L306-L312. 【F:app/pages/index.vue†L215-L313】
|
||||
- **Extraction proposée**: `app/components/common/ModalActions.vue` avec props `primaryLabel`, `primaryLoading`, `onCancel`, slots secondaires.
|
||||
- **Plan**: Introduire le composant, refactorer chaque modal pour l’utiliser, garantir les mêmes classes Tailwind.
|
||||
|
||||
## MDUP-008 · Score 76 · Type ui-fragment
|
||||
- **Pattern**: Gabarit de modale (div `.modal` + `.modal-box`, titre `<h3>`, formulaire, actions).
|
||||
- **Occurrences**:
|
||||
- `app/components/ConstructeurSelect.vue` L58-L89. 【F:app/components/ConstructeurSelect.vue†L58-L89】
|
||||
- `app/components/sites/SiteCreateModal.vue` L1-L31. 【F:app/components/sites/SiteCreateModal.vue†L1-L31】
|
||||
- `app/components/sites/SiteEditModal.vue` L1-L94. 【F:app/components/sites/SiteEditModal.vue†L1-L94】
|
||||
- `app/pages/index.vue` L192-L315 (modales site/machine). 【F:app/pages/index.vue†L192-L315】
|
||||
- **Extraction proposée**: `app/components/common/ModalShell.vue` gérant l’ouverture, le titre, le footer via slots (`header`, `default`, `footer`).
|
||||
- **Plan**: Remplacer chaque squelette par le nouveau composant tout en conservant la structure DOM requise par DaisyUI.
|
||||
|
||||
## MDUP-009 · Score 74 · Type form-field
|
||||
- **Pattern**: Champ texte simple (label, input type="text", `required`) pour les « Nom » & co.
|
||||
- **Occurrences**:
|
||||
- `app/components/ConstructeurSelect.vue` L62-L65. 【F:app/components/ConstructeurSelect.vue†L62-L66】
|
||||
- `app/pages/constructeurs.vue` L78-L81. 【F:app/pages/constructeurs.vue†L78-L81】
|
||||
- `app/components/sites/SiteCreateModal.vue` L6-L17. 【F:app/components/sites/SiteCreateModal.vue†L5-L17】
|
||||
- `app/components/sites/SiteEditModal.vue` L8-L20. 【F:app/components/sites/SiteEditModal.vue†L8-L20】
|
||||
- `app/components/TypeEditBaseInfoSection.vue` L8-L48. 【F:app/components/TypeEditBaseInfoSection.vue†L8-L48】
|
||||
- **Extraction proposée**: `app/components/form/FieldText.vue` avec props `type`, `label`, `required`, `maxlength`, `placeholder`, support `modelModifiers`.
|
||||
- **Plan**: Introduire le composant, migrer progressivement les champs texte, ajouter un paramètre pour afficher l’étoile obligatoire.
|
||||
|
||||
## MDUP-010 · Score 72 · Type ui-fragment
|
||||
- **Pattern**: Bouton primaire avec indicateur de chargement inline (`<span class="loading loading-spinner loading-xs mr-2">`).
|
||||
- **Occurrences**:
|
||||
- `app/components/ConstructeurSelect.vue` L82-L84. 【F:app/components/ConstructeurSelect.vue†L82-L84】
|
||||
- `app/pages/constructeurs.vue` L88-L89. 【F:app/pages/constructeurs.vue†L88-L90】
|
||||
- `app/components/sites/SiteEditModal.vue` L86-L88. 【F:app/components/sites/SiteEditModal.vue†L86-L88】
|
||||
- **Extraction proposée**: `app/components/common/LoadingButton.vue` gérant les variantes (`primary`, `outline`), le spinner et le label via slots.
|
||||
- **Plan**: Remplacer les boutons concernés par le composant, propager `loading` & `disabled` automatiquement.
|
||||
|
||||
## Annexes
|
||||
- **Validations centralisées**: `app/shared/validation/phone.ts` & `app/shared/validation/email.ts` fournissent désormais des schémas communs. 【F:app/shared/validation/phone.ts†L1-L36】【F:app/shared/validation/email.ts†L1-L34】
|
||||
- **Formatters communs**: `app/utils/formatters/phone.ts` et `app/utils/formatters/email.ts` proposent les helpers associés. 【F:app/utils/formatters/phone.ts†L1-L67】【F:app/utils/formatters/email.ts†L1-L37】
|
||||
Reference in New Issue
Block a user