Files
Lesstime/frontend/modules/directory/components/DirectoryAddressBlock.vue
T
tristan bbd8a38c95
Auto Tag Develop / tag (push) Successful in 9s
feat(directory) : refonte UI du Répertoire (LST-72) (#27)
Améliorations frontend de la partie **Répertoire** (Client / Prospect / Prestataire). Onglet **Rapport** retravaillé en fin de parcours ; le reste de la logique métier inchangé.

## Navigation & liste
- Onglet actif conservé au retour liste ↔ fiche (flèche app **et** navigateur) via `history.state` (hors URL) — util `historyTab.ts`
- Colonne « Action » (entête alignée) + feedback hover sur les boutons d'action
- Conversion prospect → client : modal de confirmation
- Boutons « Ajouter » : label court + taille Malio standard ; barres d'outils à hauteur homogène (plus de saut entre onglets)

## Fiches (Info / Contact / Adresse)
- Style **plat** sans box-shadow (comme Starseed)
- Champs email/téléphone : `MalioInputEmail` / `MalioInputPhone`
- Grilles en **4 colonnes** (Info + blocs)
- Boutons « Nouveau contact/adresse » en secondary ; « Enregistrer » en taille Malio ; marge form↔bouton homogène
- Bouton retour **ghost** (`mdi:arrow-left-bold`)
- **Adresse** : flux CP → ville → rue (rue conditionnée au CP+ville, cascade de reset) ; titre du bloc = libellé saisi
- Suppression d'un bloc Contact/Adresse : **modal** de confirmation (centralisée dans `useDirectoryDetail`)
- Modals (suppression, conversion) basées sur `MalioModal` (design Starseed) avec nom en gras

## Onglet Rapport
- Bouton d'ajout en taille Malio (« Ajouter »)
- Suppression compte-rendu : `ConfirmModal` partagée (remplace l'ancienne modal maison)
- Suppression d'un document joint : ajout d'une modal de confirmation
- Upload via `MalioInputUpload` ; bouton supprimer document aligné (`mdi:delete-outline` ghost)

## Divers
- `fix(auth)` : cookie JWT renommé `BEARER_LESSTIME` (collision localhost avec d'autres apps Symfony)
- `fix(infra)` : target makefile `fix-uploads-perm` (volume `uploads_data` root → upload local OK)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #27
2026-06-27 13:29:56 +00:00

257 lines
9.7 KiB
Vue

<template>
<!-- Bloc à plat (sans box-shadow) : un filet noir 1px le sépare du suivant
(pas de bordure sous le dernier bloc), comme sur Starseed. -->
<div class="pb-5" :class="{ 'border-b border-black': !last }">
<div class="flex items-center justify-between">
<!-- Titre = libellé saisi ; repli sur « Adresse N » tant qu'il est vide. -->
<h3 class="text-[20px] font-semibold text-black">{{ blockTitle }}</h3>
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
:aria-label="$t('common.delete')"
@click="$emit('remove')"
/>
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
class="col-span-2"
:label="$t('directory.addresses.fields.label')"
:model-value="modelValue.label ?? ''"
:readonly="readonly"
@update:model-value="update('label', $event)"
/>
<!-- On commence par le code postal : il alimente la liste des villes (BAN)
et réinitialise ville/rue devenues incohérentes en cas de changement. -->
<MalioInputText
:label="$t('directory.addresses.fields.postalCode')"
:model-value="modelValue.postalCode ?? ''"
:readonly="readonly"
@update:model-value="onPostalCodeInput"
/>
<!-- Ville : select alimenté par le code postal (BAN). En mode dégradé
(BAN indispo) ou lecture seule, on bascule en saisie libre. -->
<MalioSelect
v-if="!readonly && !degraded"
:model-value="modelValue.city ?? ''"
:options="cityOptions"
:label="$t('directory.addresses.fields.city')"
empty-option-label=""
group-class="w-full"
@update:model-value="onCityChange"
/>
<MalioInputText
v-else
:label="$t('directory.addresses.fields.city')"
:model-value="modelValue.city ?? ''"
:readonly="readonly"
@update:model-value="update('city', $event)"
/>
<!-- Rue : conditionnée au code postal + ville (comme Starseed). Saisie
assistée (BAN) filtrée par le code postal ; désactivée tant que CP et
ville ne sont pas renseignés. Champ texte simple en lecture seule. -->
<div class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly"
:model-value="modelValue.street ?? ''"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:allow-create="true"
:disabled="!canEditStreet"
:hint="canEditStreet ? '' : $t('directory.addresses.streetHint')"
:label="$t('directory.addresses.fields.street')"
:no-results-text="$t('directory.addresses.streetNotFound')"
@update:model-value="(v) => update('street', v === null ? '' : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:label="$t('directory.addresses.fields.street')"
:model-value="modelValue.street ?? ''"
:readonly="readonly"
@update:model-value="update('street', $event)"
/>
</div>
<MalioInputText
class="col-span-2"
:label="$t('directory.addresses.fields.streetComplement')"
:model-value="modelValue.streetComplement ?? ''"
:readonly="readonly"
@update:model-value="update('streetComplement', $event)"
/>
</div>
</div>
</template>
<script setup lang="ts">
import type { Address } from '~/modules/directory/services/dto/address'
import {
useAddressAutocomplete,
type AddressSuggestion,
} from '~/modules/directory/composables/useAddressAutocomplete'
const props = defineProps<{
modelValue: Address
title: string
removable?: boolean
readonly?: boolean
/** Dernier bloc de la liste : supprime le filet de séparation bas. */
last?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: Address]
'remove': []
}>()
const { t } = useI18n()
const toast = useToast()
const autocomplete = useAddressAutocomplete()
type Option = { label: string, value: string | number }
const addressOptions = ref<Option[]>([])
// Villes renvoyées par la BAN pour le code postal courant.
const fetchedCityOptions = ref<Option[]>([])
const addressLoading = ref(false)
// Titre du bloc : le libellé saisi prime ; repli sur « Adresse N » (prop `title`).
const blockTitle = computed(() => (props.modelValue.label ?? '').trim() || props.title)
// La rue n'est éditable qu'une fois le code postal (5 chiffres) ET la ville
// renseignés — conditionnement métier repris de Starseed.
const canEditStreet = computed(() => {
const digits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
return digits.length === 5 && !!(props.modelValue.city ?? '').trim()
})
// Le select Ville n'affiche que les valeurs présentes dans ses options : on
// garantit donc que la ville déjà enregistrée (chargement d'une fiche) ou
// pré-remplie par l'autocomplétion d'adresse figure toujours dans la liste,
// même avant toute recherche par code postal — sinon elle s'afficherait vide.
const cityOptions = computed<Option[]>(() => {
const current = (props.modelValue.city ?? '').trim()
const options = [...fetchedCityOptions.value]
if (current && !options.some(o => o.value === current)) {
options.unshift({ value: current, label: current })
}
return options
})
// Mode dégradé : BAN indisponible → la ville passe en saisie libre.
const degraded = ref(false)
let lastAddressSuggestions: AddressSuggestion[] = []
let notified = false
function update(field: keyof Address, value: string): void {
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
}
// Avertit une seule fois que l'autocomplétion est indisponible (saisie libre).
function notifyUnavailable(): void {
if (notified) return
notified = true
toast.info({ title: '', message: t('directory.addresses.autocompleteUnavailable') })
}
/**
* Sélection d'une ville → vide rue + complément (devenus incohérents avec la
* nouvelle ville). Ne réagit qu'à un vrai changement de valeur.
*/
function onCityChange(value: string | number | null): void {
const next = value === null ? '' : String(value)
if (next === (props.modelValue.city ?? '')) return
addressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
city: next === '' ? null : next,
street: null,
streetComplement: null,
})
}
/** Recherche d'adresse assistée (event de MalioInputAutocomplete). */
async function onAddressSearch(query: string): Promise<void> {
if (query.trim().length < 3) {
addressOptions.value = []
return
}
addressLoading.value = true
try {
const postalCode = (props.modelValue.postalCode ?? '').replace(/\D/g, '') || undefined
const suggestions = await autocomplete.searchAddress(query, postalCode)
lastAddressSuggestions = suggestions
addressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
}
catch {
addressOptions.value = []
notifyUnavailable()
}
finally {
addressLoading.value = false
}
}
/** Sélection d'une suggestion → remplit rue + ville + code postal. */
function onAddressSelect(option: Option | null): void {
if (option === null) return
// Matching par `label` (adresse complète, unique côté BAN) plutôt que par
// rue : deux communes peuvent partager le même libellé de voie.
const suggestion = lastAddressSuggestions.find(s => s.label === option.label)
if (!suggestion) {
update('street', String(option.value))
return
}
emit('update:modelValue', {
...props.modelValue,
street: suggestion.street,
city: suggestion.city || props.modelValue.city,
postalCode: suggestion.postalCode || props.modelValue.postalCode,
})
}
/**
* Saisie du code postal → réinitialise ville/rue/complément quand le CP est
* complet (5 chiffres) ET réellement modifié, puis interroge la BAN pour les
* villes. Sinon simple mise à jour du champ (correction partielle).
*/
async function onPostalCodeInput(value: string): Promise<void> {
const digits = (value ?? '').replace(/\D/g, '')
const previousDigits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
if (digits.length === 5 && digits !== previousDigits) {
addressOptions.value = []
lastAddressSuggestions = []
emit('update:modelValue', {
...props.modelValue,
postalCode: value,
city: null,
street: null,
streetComplement: null,
})
}
else {
update('postalCode', value)
}
if (digits.length < 5) return
try {
const suggestions = await autocomplete.searchCity(digits)
fetchedCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
degraded.value = false
}
catch {
degraded.value = true
notifyUnavailable()
}
}
</script>