Files
Lesstime/frontend/modules/directory/composables/useDirectoryDetail.ts
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

163 lines
6.5 KiB
TypeScript

import type { Contact } from '~/modules/directory/services/dto/contact'
import type { Address } from '~/modules/directory/services/dto/address'
import { useContactService } from '~/modules/directory/services/contacts'
import { useAddressService } from '~/modules/directory/services/addresses'
type Owner = { client?: string, prospect?: string, prestataire?: string }
/**
* Logique partagée des fiches détail Client/Prospect : blocs répétables Contact
* et Adresse (chargement, ajout, suppression). L'édition est tenue en mémoire
* localement ; la persistance se fait au clic sur « Enregistrer » (saveContacts/
* saveAddresses), comme les formulaires de tâche — pas d'enregistrement au blur.
* Paramétré par l'IRI du propriétaire (`{ client }` ou `{ prospect }`), réutilisé
* tel quel par les deux pages.
*/
export function useDirectoryDetail(owner: Owner) {
const { t } = useI18n()
const contactService = useContactService()
const addressService = useAddressService()
const contacts = ref<Contact[]>([])
const addresses = ref<Address[]>([])
const savingContacts = ref(false)
const savingAddresses = ref(false)
function emptyContact(): Contact {
return { id: 0, firstName: null, lastName: null, jobTitle: null, email: null, phonePrimary: null, phoneSecondary: null, ...owner }
}
function emptyAddress(): Address {
return { id: 0, label: null, street: null, streetComplement: null, postalCode: null, city: null, country: 'FR', ...owner }
}
// Édition locale uniquement : on remplace le bloc en mémoire, rien n'est
// persisté tant que l'utilisateur n'a pas cliqué sur « Enregistrer ».
function onContactInput(index: number, value: Contact): void {
contacts.value[index] = value
}
function onAddressInput(index: number, value: Address): void {
addresses.value[index] = value
}
function addContact(): void {
contacts.value.push(emptyContact())
}
function addAddress(): void {
addresses.value.push(emptyAddress())
}
// Suppression immédiate (comme la corbeille du formulaire de tâche) : un bloc
// déjà enregistré est supprimé côté serveur, une amorce non enregistrée est
// simplement retirée de la liste.
async function removeContact(index: number): Promise<void> {
const c = contacts.value[index]
if (c?.id && c.id > 0) await contactService.remove(c.id)
contacts.value.splice(index, 1)
}
async function removeAddress(index: number): Promise<void> {
const a = addresses.value[index]
if (a?.id && a.id > 0) await addressService.remove(a.id)
addresses.value.splice(index, 1)
}
// Confirmation de suppression d'un bloc (contact / adresse) : la corbeille du
// bloc ouvre une modal ; la suppression effective n'a lieu qu'à la confirmation.
const removeModalOpen = ref(false)
const pendingRemoval = ref<{ type: 'contact' | 'address', index: number } | null>(null)
const removeModalTitle = computed(() =>
pendingRemoval.value?.type === 'address'
? t('directory.addresses.deleteConfirmTitle')
: t('directory.contacts.deleteConfirmTitle'),
)
const removeModalMessage = computed(() =>
pendingRemoval.value?.type === 'address'
? t('directory.addresses.deleteConfirmMessage')
: t('directory.contacts.deleteConfirmMessage'),
)
function askRemoveContact(index: number): void {
pendingRemoval.value = { type: 'contact', index }
removeModalOpen.value = true
}
function askRemoveAddress(index: number): void {
pendingRemoval.value = { type: 'address', index }
removeModalOpen.value = true
}
async function confirmRemove(): Promise<void> {
const p = pendingRemoval.value
if (!p) return
if (p.type === 'contact') await removeContact(p.index)
else await removeAddress(p.index)
removeModalOpen.value = false
pendingRemoval.value = null
}
// Persistance au clic : met à jour les blocs existants, crée les nouveaux
// blocs renseignés. Les amorces vides (sans contenu) sont ignorées.
async function saveContacts(): Promise<void> {
if (savingContacts.value) return
savingContacts.value = true
try {
for (let i = 0; i < contacts.value.length; i++) {
const c = contacts.value[i]
if (!c) continue
const payload = { firstName: c.firstName, lastName: c.lastName, jobTitle: c.jobTitle, email: c.email, phonePrimary: c.phonePrimary, phoneSecondary: c.phoneSecondary, ...owner }
if (c.id && c.id > 0) {
contacts.value[i] = await contactService.update(c.id, payload)
} else if (c.lastName || c.firstName) {
contacts.value[i] = await contactService.create(payload)
}
}
} finally {
savingContacts.value = false
}
}
async function saveAddresses(): Promise<void> {
if (savingAddresses.value) return
savingAddresses.value = true
try {
for (let i = 0; i < addresses.value.length; i++) {
const a = addresses.value[i]
if (!a) continue
const payload = { label: a.label, street: a.street, streetComplement: a.streetComplement, postalCode: a.postalCode, city: a.city, country: a.country, ...owner }
if (a.id && a.id > 0) {
addresses.value[i] = await addressService.update(a.id, payload)
} else if (a.street || a.city || a.postalCode) {
addresses.value[i] = await addressService.create(payload)
}
}
} finally {
savingAddresses.value = false
}
}
async function load(): Promise<void> {
contacts.value = await contactService.getByOwner(owner)
addresses.value = await addressService.getByOwner(owner)
}
return {
contacts,
addresses,
savingContacts,
savingAddresses,
onContactInput,
addContact,
removeContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
saveAddresses,
load,
// Suppression de bloc avec confirmation (modal partagée contact/adresse).
removeModalOpen,
removeModalTitle,
removeModalMessage,
askRemoveContact,
askRemoveAddress,
confirmRemove,
}
}