From bbd8a38c95589da649155fe1f7f5dd4de41dfa01 Mon Sep 17 00:00:00 2001 From: Autin Date: Sat, 27 Jun 2026 13:29:56 +0000 Subject: [PATCH] =?UTF-8?q?feat(directory)=20:=20refonte=20UI=20du=20R?= =?UTF-8?q?=C3=A9pertoire=20(LST-72)=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: https://gitea.malio.fr/MALIO-DEV/Lesstime/pulls/27 --- CLAUDE.md | 2 +- README.md | 2 +- config/packages/lexik_jwt_authentication.yaml | 8 +- config/packages/security.yaml | 2 +- .../ui/ConfirmDeleteReportModal.vue | 61 ----- frontend/i18n/locales/fr.json | 15 +- .../components/CommercialReportTab.vue | 40 +++- .../components/ConfirmDeleteModal.vue | 58 ----- .../directory/components/ConfirmModal.vue | 54 +++++ .../components/DirectoryAddressBlock.vue | 212 +++++++++++------- .../components/DirectoryContactBlock.vue | 110 ++++----- .../components/ReportDocumentList.vue | 4 +- .../components/ReportDocumentUpload.vue | 30 +-- .../composables/useDirectoryDetail.ts | 41 ++++ .../pages/directory/clients/[id].vue | 46 ++-- .../directory/pages/directory/index.vue | 121 +++++++--- .../pages/directory/prestataires/[id].vue | 46 ++-- .../pages/directory/prospects/[id].vue | 55 +++-- frontend/utils/historyTab.ts | 35 +++ makefile | 9 +- 20 files changed, 590 insertions(+), 361 deletions(-) delete mode 100644 frontend/components/ui/ConfirmDeleteReportModal.vue delete mode 100644 frontend/modules/directory/components/ConfirmDeleteModal.vue create mode 100644 frontend/modules/directory/components/ConfirmModal.vue create mode 100644 frontend/utils/historyTab.ts diff --git a/CLAUDE.md b/CLAUDE.md index f1a7143..eae2298 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ Application de gestion de projet. Monorepo Symfony 8 (API Platform 4) + Nuxt 4. - **Backend** : PHP 8.4, Symfony 8.0, API Platform 4, Doctrine ORM, PostgreSQL 16 - **Frontend** : Nuxt 4 (SSR off / SPA), Vue 3, Pinia, Tailwind CSS, @malio/layer-ui, nuxt-toast, @nuxtjs/i18n, @nuxt/icon -- **Auth** : JWT HTTP-only cookie (lexik/jwt-authentication-bundle), login à `/login_check`, cookie `BEARER` +- **Auth** : JWT HTTP-only cookie (lexik/jwt-authentication-bundle), login à `/login_check`, cookie `BEARER_LESSTIME` (nommé par app pour éviter la collision avec d'autres apps Symfony sur `localhost` en dev) - **Docker** : PHP-FPM + Node 24, Nginx (port 8082), PostgreSQL (port 5435) ## Structure diff --git a/README.md b/README.md index 926ed2a..5f7af21 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,7 @@ Configuration : `infra/dev/.env.docker` (override local : `infra/dev/.env.docker Toutes les routes API sont préfixées `/api` (API Platform). - Documentation auto-générée : **http://localhost:8082/api** -- Auth : `POST /login_check` avec `{ username, password }` → cookie JWT `BEARER` +- Auth : `POST /login_check` avec `{ username, password }` → cookie JWT `BEARER_LESSTIME` ## Serveur MCP diff --git a/config/packages/lexik_jwt_authentication.yaml b/config/packages/lexik_jwt_authentication.yaml index ade6ecd..5d3979f 100644 --- a/config/packages/lexik_jwt_authentication.yaml +++ b/config/packages/lexik_jwt_authentication.yaml @@ -9,11 +9,15 @@ lexik_jwt_authentication: enabled: false cookie: enabled: true - name: BEARER + # Cookie nommé par app (BEARER_LESSTIME) pour éviter la collision avec + # d'autres apps Symfony servies sur le même domaine localhost en dev + # (ex: Starseed reste sur BEARER) : un cookie `BEARER` partagé se ferait + # écraser d'une app à l'autre → déconnexions croisées. + name: BEARER_LESSTIME query_parameter: enabled: false set_cookies: - BEARER: + BEARER_LESSTIME: lifetime: '%env(int:JWT_COOKIE_TTL)%' samesite: lax path: / diff --git a/config/packages/security.yaml b/config/packages/security.yaml index fdfe255..b18f34b 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -49,7 +49,7 @@ security: target: /login enable_csrf: false delete_cookies: - BEARER: + BEARER_LESSTIME: path: / # Activate different ways to authenticate: diff --git a/frontend/components/ui/ConfirmDeleteReportModal.vue b/frontend/components/ui/ConfirmDeleteReportModal.vue deleted file mode 100644 index 5010d48..0000000 --- a/frontend/components/ui/ConfirmDeleteReportModal.vue +++ /dev/null @@ -1,61 +0,0 @@ - - - - - diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 4eaebea..634e810 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -432,6 +432,7 @@ "edit": "Modifier", "delete": "Supprimer", "add": "Ajouter", + "actions": "Action", "loading": "Chargement...", "archived": "Archivé", "noClient": "Aucun client", @@ -926,6 +927,9 @@ "editProspect": "Modifier un prospect", "convert": "Convertir en client", "alreadyConverted": "Déjà converti en client", + "convertConfirmTitle": "Convertir le prospect", + "convertConfirmMessage": "Êtes-vous sûr de vouloir convertir le prospect « {name} » en client ? Le prospect deviendra un client.", + "convertConfirm": "Convertir", "deleteConfirmTitle": "Supprimer le prospect", "deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le prospect « {name} » ? Cette action est irréversible.", "fields": { @@ -1008,10 +1012,12 @@ "empty": "Aucun prestataire trouvé." }, "contacts": { - "add": "Ajouter un contact", + "add": "Nouveau contact", "item": "Contact {n}", "saved": "Contact enregistré.", "deleted": "Contact supprimé.", + "deleteConfirmTitle": "Supprimer le contact", + "deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce contact ? Cette action est irréversible.", "fields": { "lastName": "Nom", "firstName": "Prénom", @@ -1022,11 +1028,14 @@ } }, "addresses": { - "add": "Ajouter une adresse", + "add": "Nouvelle adresse", "item": "Adresse {n}", "saved": "Adresse enregistrée.", "deleted": "Adresse supprimée.", + "deleteConfirmTitle": "Supprimer l'adresse", + "deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer cette adresse ? Cette action est irréversible.", "streetNotFound": "Aucune adresse trouvée — saisie libre possible.", + "streetHint": "Renseignez d'abord le code postal et la ville.", "autocompleteUnavailable": "Recherche d'adresse indisponible : saisissez l'adresse manuellement.", "fields": { "label": "Libellé", @@ -1048,6 +1057,8 @@ "deleted": "Compte-rendu supprimé.", "confirmDeleteTitle": "Supprimer ce compte-rendu ?", "confirmDeleteMessage": "Cette action est irréversible. Les documents joints seront également supprimés.", + "documentDeleteTitle": "Supprimer le document", + "documentDeleteMessage": "Êtes-vous sûr de vouloir supprimer ce document ? Cette action est irréversible.", "fields": { "subject": "Objet", "type": "Type d'échange", diff --git a/frontend/modules/directory/components/CommercialReportTab.vue b/frontend/modules/directory/components/CommercialReportTab.vue index 7a61e51..7a0a3e3 100644 --- a/frontend/modules/directory/components/CommercialReportTab.vue +++ b/frontend/modules/directory/components/CommercialReportTab.vue @@ -9,8 +9,7 @@ v-if="canManage" icon-name="mdi:plus" icon-position="left" - button-class="w-auto px-4" - :label="$t('directory.reports.add')" + :label="$t('common.add')" @click="openCreate" /> @@ -108,7 +107,7 @@ v-if="report.documents?.length" :documents="report.documents" :can-manage="canManage" - @delete="(docId) => removeDocument(docId)" + @delete="(docId) => askDeleteDocument(docId)" /> - + @@ -158,6 +164,11 @@ const confirmOpen = ref(false) const pendingDelete = ref(null) const deleting = ref(false) +// Suppression d'un document joint : passe désormais par une modal de confirmation. +const docConfirmOpen = ref(false) +const pendingDocId = ref(null) +const deletingDoc = ref(false) + // Le plus récent en haut (l'API ne garantit pas l'ordre). const sortedReports = computed(() => [...reports.value].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)), @@ -222,9 +233,22 @@ async function confirmDelete(): Promise { } } -async function removeDocument(id: number): Promise { - await documentService.remove(id) - await reload() +function askDeleteDocument(id: number): void { + pendingDocId.value = id + docConfirmOpen.value = true +} + +async function confirmDeleteDocument(): Promise { + if (pendingDocId.value === null || deletingDoc.value) return + deletingDoc.value = true + try { + await documentService.remove(pendingDocId.value) + docConfirmOpen.value = false + pendingDocId.value = null + await reload() + } finally { + deletingDoc.value = false + } } async function reload(): Promise { diff --git a/frontend/modules/directory/components/ConfirmDeleteModal.vue b/frontend/modules/directory/components/ConfirmDeleteModal.vue deleted file mode 100644 index 983750c..0000000 --- a/frontend/modules/directory/components/ConfirmDeleteModal.vue +++ /dev/null @@ -1,58 +0,0 @@ - - - - - diff --git a/frontend/modules/directory/components/ConfirmModal.vue b/frontend/modules/directory/components/ConfirmModal.vue new file mode 100644 index 0000000..1770d13 --- /dev/null +++ b/frontend/modules/directory/components/ConfirmModal.vue @@ -0,0 +1,54 @@ + + + diff --git a/frontend/modules/directory/components/DirectoryAddressBlock.vue b/frontend/modules/directory/components/DirectoryAddressBlock.vue index 54fafd8..8e6fbdb 100644 --- a/frontend/modules/directory/components/DirectoryAddressBlock.vue +++ b/frontend/modules/directory/components/DirectoryAddressBlock.vue @@ -1,84 +1,93 @@ @@ -94,6 +103,8 @@ const props = defineProps<{ title: string removable?: boolean readonly?: boolean + /** Dernier bloc de la liste : supprime le filet de séparation bas. */ + last?: boolean }>() const emit = defineEmits<{ @@ -112,6 +123,16 @@ const addressOptions = ref([]) const fetchedCityOptions = ref([]) 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, @@ -140,6 +161,23 @@ function notifyUnavailable(): void { 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 { if (query.trim().length < 3) { @@ -180,10 +218,30 @@ function onAddressSelect(option: Option | null): void { }) } -/** Saisie du code postal → met à jour le champ + interroge la BAN pour la ville. */ +/** + * 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 { - update('postalCode', value) 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) diff --git a/frontend/modules/directory/components/DirectoryContactBlock.vue b/frontend/modules/directory/components/DirectoryContactBlock.vue index d34d828..b6b1060 100644 --- a/frontend/modules/directory/components/DirectoryContactBlock.vue +++ b/frontend/modules/directory/components/DirectoryContactBlock.vue @@ -1,57 +1,61 @@ @@ -64,6 +68,8 @@ const props = defineProps<{ title: string removable?: boolean readonly?: boolean + /** Dernier bloc de la liste : supprime le filet de séparation bas. */ + last?: boolean }>() const emit = defineEmits<{ diff --git a/frontend/modules/directory/components/ReportDocumentList.vue b/frontend/modules/directory/components/ReportDocumentList.vue index 83fda90..ea46408 100644 --- a/frontend/modules/directory/components/ReportDocumentList.vue +++ b/frontend/modules/directory/components/ReportDocumentList.vue @@ -16,8 +16,8 @@ diff --git a/frontend/modules/directory/components/ReportDocumentUpload.vue b/frontend/modules/directory/components/ReportDocumentUpload.vue index 14625bc..1b71361 100644 --- a/frontend/modules/directory/components/ReportDocumentUpload.vue +++ b/frontend/modules/directory/components/ReportDocumentUpload.vue @@ -1,20 +1,14 @@ @@ -25,21 +19,19 @@ const props = defineProps<{ reportId: number }>() const emit = defineEmits<{ uploaded: [] }>() const service = useReportDocumentService() -const fileInput = ref(null) +// Nom du fichier affiché par le champ Malio (v-model) ; réinitialisé après envoi. +const fileName = ref('') const uploading = ref(false) -async function onFileSelected(event: Event): Promise { - const input = event.target as HTMLInputElement - const file = input.files?.[0] - if (!file) return - +// L'upload se déclenche dès la sélection (event natif du composant Malio). +async function onFile(file: File): Promise { uploading.value = true try { await service.upload(props.reportId, file) emit('uploaded') } finally { uploading.value = false - input.value = '' + fileName.value = '' } } diff --git a/frontend/modules/directory/composables/useDirectoryDetail.ts b/frontend/modules/directory/composables/useDirectoryDetail.ts index 764e537..23caa16 100644 --- a/frontend/modules/directory/composables/useDirectoryDetail.ts +++ b/frontend/modules/directory/composables/useDirectoryDetail.ts @@ -14,6 +14,7 @@ type Owner = { client?: string, prospect?: string, prestataire?: string } * tel quel par les deux pages. */ export function useDirectoryDetail(owner: Owner) { + const { t } = useI18n() const contactService = useContactService() const addressService = useAddressService() @@ -59,6 +60,39 @@ export function useDirectoryDetail(owner: Owner) { 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 { + 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 { @@ -117,5 +151,12 @@ export function useDirectoryDetail(owner: Owner) { removeAddress, saveAddresses, load, + // Suppression de bloc avec confirmation (modal partagée contact/adresse). + removeModalOpen, + removeModalTitle, + removeModalMessage, + askRemoveContact, + askRemoveAddress, + confirmRemove, } } diff --git a/frontend/modules/directory/pages/directory/clients/[id].vue b/frontend/modules/directory/pages/directory/clients/[id].vue index 3d28983..d5a5990 100644 --- a/frontend/modules/directory/pages/directory/clients/[id].vue +++ b/frontend/modules/directory/pages/directory/clients/[id].vue @@ -2,7 +2,14 @@
- + {{ client?.name ?? '…' }} @@ -13,7 +20,7 @@
+ + @@ -141,13 +154,17 @@ const { savingAddresses, onContactInput, addContact, - removeContact, + askRemoveContact, saveContacts, onAddressInput, addAddress, - removeAddress, + askRemoveAddress, saveAddresses, load, + removeModalOpen, + removeModalTitle, + removeModalMessage, + confirmRemove, } = useDirectoryDetail(owner) const { can } = usePermissions() @@ -192,7 +209,8 @@ async function saveInfo(): Promise { } function goBack(): void { - router.push('/directory') + // Retour sur l'onglet Clients de la liste (via history.state, hors URL). + router.push({ path: '/directory', state: { tab: 'clients' } }) } onMounted(async () => { diff --git a/frontend/modules/directory/pages/directory/index.vue b/frontend/modules/directory/pages/directory/index.vue index b4e8005..4f7dd5a 100644 --- a/frontend/modules/directory/pages/directory/index.vue +++ b/frontend/modules/directory/pages/directory/index.vue @@ -9,12 +9,11 @@