-
- {{ title }}
-
-
+
+
+
+
{{ title }}
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
@@ -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 @@
-
-
- {{ $t('directory.documents.uploading') }}
+ {{ $t('directory.documents.uploading') }}
@@ -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 @@
-
+
-
-
onContactInput(i, v)"
- @remove="removeContact(i)"
+ @remove="askRemoveContact(i)"
/>
onAddressInput(i, v)"
- @remove="removeAddress(i)"
+ @remove="askRemoveAddress(i)"
/>
+
+
@@ -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 @@
-
+
@@ -26,6 +25,9 @@
:empty-message="$t('directory.clients.empty')"
@row-click="openEditClient"
>
+
+ {{ $t('common.actions') }}
+
{{ (item as Client).email ?? '—' }}
@@ -37,7 +39,7 @@
@@ -50,7 +52,7 @@
-
+
@@ -74,6 +75,9 @@
:empty-message="$t('directory.prospects.empty')"
@row-click="openEditProspect"
>
+
+ {{ $t('common.actions') }}
+
@@ -111,12 +115,11 @@
-
+
@@ -128,6 +131,9 @@
:empty-message="$t('directory.prestataires.empty')"
@row-click="openEditPrestataire"
>
+
+ {{ $t('common.actions') }}
+
{{ (item as Prestataire).email ?? '—' }}
@@ -139,7 +145,7 @@
@@ -166,12 +172,31 @@
@saved="loadPrestataires"
/>
-
+ >
+
+
+ {{ deleteTargetName }}
+
+
+
+
+
+
+
+ {{ convertTarget?.company }}
+
+
+
@@ -183,6 +208,7 @@ import type { Prospect, ProspectStatus } from '~/modules/directory/services/dto/
import { useProspectService } from '~/modules/directory/services/prospects'
import type { Prestataire } from '~/modules/directory/services/dto/prestataire'
import { usePrestataireService } from '~/modules/directory/services/prestataires'
+import { readHistoryTab, stampHistoryTab } from '~/utils/historyTab'
definePageMeta({ middleware: ['admin'] })
@@ -201,6 +227,14 @@ const tabs = [
{ key: 'prospects', label: t('directory.tabs.prospects'), icon: 'mdi:account-search-outline' },
{ key: 'prestataires', label: t('directory.tabs.prestataires'), icon: 'mdi:account-hard-hat-outline' },
]
+const tabKeys = tabs.map((tab) => tab.key)
+
+// Avant d'ouvrir une fiche : on estampille l'entrée d'historique courante avec
+// l'onglet actif → la flèche « précédent » du navigateur restaure le bon onglet.
+function navigateToDetail(path: string): void {
+ stampHistoryTab(activeTab.value)
+ navigateTo(path)
+}
// --- Clients ---
const clients = ref([])
@@ -211,7 +245,7 @@ const clientColumns = [
{ key: 'name', label: t('prospects.fields.company') },
{ key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') },
- { key: 'actions', label: '' },
+ { key: 'actions', label: t('common.actions') },
]
async function loadClients() {
@@ -224,7 +258,7 @@ function openCreateClient() {
}
function openEditClient(item: Record) {
- navigateTo(`/directory/clients/${(item as Client).id}`)
+ navigateToDetail(`/directory/clients/${(item as Client).id}`)
}
// --- Prospects ---
@@ -246,7 +280,7 @@ const prospectColumns = [
{ key: 'status', label: t('prospects.fields.status') },
{ key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') },
- { key: 'actions', label: '' },
+ { key: 'actions', label: t('common.actions') },
]
const prospectRows = computed(() => prospects.value)
@@ -282,13 +316,26 @@ function openCreateProspect() {
}
function openEditProspect(item: Record) {
- navigateTo(`/directory/prospects/${(item as Prospect).id}`)
+ navigateToDetail(`/directory/prospects/${(item as Prospect).id}`)
}
-async function convertProspect(row: ProspectRow) {
+// La conversion passe par une modal de confirmation (le prospect devient client).
+const convertModalOpen = ref(false)
+const convertTarget = ref(null)
+
+function askConvertProspect(row: ProspectRow) {
+ convertTarget.value = row
+ convertModalOpen.value = true
+}
+
+async function confirmConvert() {
+ const row = convertTarget.value
+ if (!row) return
await prospectService.convert(row.id)
// La conversion crée un client et retire le prospect : rafraîchir les deux listes.
await Promise.all([loadProspects(), loadClients()])
+ convertModalOpen.value = false
+ convertTarget.value = null
}
// Le ProspectDrawer porte aussi le bouton « Convertir » : son event 'saved' peut
@@ -306,7 +353,7 @@ const prestataireColumns = [
{ key: 'name', label: t('prospects.fields.company') },
{ key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') },
- { key: 'actions', label: '' },
+ { key: 'actions', label: t('common.actions') },
]
async function loadPrestataires() {
@@ -319,7 +366,7 @@ function openCreatePrestataire() {
}
function openEditPrestataire(item: Record) {
- navigateTo(`/directory/prestataires/${(item as Prestataire).id}`)
+ navigateToDetail(`/directory/prestataires/${(item as Prestataire).id}`)
}
// --- Suppression (clients, prospects & prestataires) ---
@@ -342,17 +389,22 @@ const deleteModalTitle = computed(() => {
}
})
-const deleteModalMessage = computed(() => {
+// Clé i18n du message (le nom y est injecté en gras via côté template).
+const deleteModalKeypath = computed(() => {
+ switch (deleteTarget.value?.type) {
+ case 'prospect':
+ return 'prospects.deleteConfirmMessage'
+ case 'prestataire':
+ return 'prestataires.deleteConfirmMessage'
+ default:
+ return 'clients.deleteConfirmMessage'
+ }
+})
+
+const deleteTargetName = computed(() => {
const target = deleteTarget.value
if (!target) return ''
- switch (target.type) {
- case 'prospect':
- return t('prospects.deleteConfirmMessage', { name: target.item.company })
- case 'prestataire':
- return t('prestataires.deleteConfirmMessage', { name: target.item.name })
- default:
- return t('clients.deleteConfirmMessage', { name: target.item.name })
- }
+ return target.type === 'prospect' ? target.item.company : target.item.name
})
function askDeleteClient(item: Client) {
@@ -392,6 +444,9 @@ async function confirmDelete() {
watch(statusFilter, loadProspects)
onMounted(async () => {
+ // Restaure l'onglet quitté lors d'un retour depuis une fiche (flèche app ou
+ // navigateur). `null` (deep link / reload) → onglet Clients par défaut.
+ activeTab.value = readHistoryTab(tabKeys) ?? 'clients'
await Promise.all([loadClients(), loadProspects(), loadPrestataires()])
})
diff --git a/frontend/modules/directory/pages/directory/prestataires/[id].vue b/frontend/modules/directory/pages/directory/prestataires/[id].vue
index d677e9b..4319fa6 100644
--- a/frontend/modules/directory/pages/directory/prestataires/[id].vue
+++ b/frontend/modules/directory/pages/directory/prestataires/[id].vue
@@ -2,7 +2,14 @@
-
+
{{ prestataire?.name ?? '…' }}
@@ -13,7 +20,7 @@
-
+
-
-
onContactInput(i, v)"
- @remove="removeContact(i)"
+ @remove="askRemoveContact(i)"
/>
onAddressInput(i, v)"
- @remove="removeAddress(i)"
+ @remove="askRemoveAddress(i)"
/>
+
+
@@ -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()
@@ -190,7 +207,8 @@ async function saveInfo(): Promise {
}
function goBack(): void {
- router.push('/directory')
+ // Retour sur l'onglet Prestataires de la liste (via history.state, hors URL).
+ router.push({ path: '/directory', state: { tab: 'prestataires' } })
}
onMounted(async () => {
diff --git a/frontend/modules/directory/pages/directory/prospects/[id].vue b/frontend/modules/directory/pages/directory/prospects/[id].vue
index ef8d568..6c4344b 100644
--- a/frontend/modules/directory/pages/directory/prospects/[id].vue
+++ b/frontend/modules/directory/pages/directory/prospects/[id].vue
@@ -2,7 +2,14 @@
-
+
{{ prospect?.company ?? '…' }}
@@ -13,7 +20,7 @@
-
+
-
-
+
onContactInput(i, v)"
- @remove="removeContact(i)"
+ @remove="askRemoveContact(i)"
/>
onAddressInput(i, v)"
- @remove="removeAddress(i)"
+ @remove="askRemoveAddress(i)"
/>
+
+
@@ -156,13 +176,17 @@ const {
savingAddresses,
onContactInput,
addContact,
- removeContact,
+ askRemoveContact,
saveContacts,
onAddressInput,
addAddress,
- removeAddress,
+ askRemoveAddress,
saveAddresses,
load,
+ removeModalOpen,
+ removeModalTitle,
+ removeModalMessage,
+ confirmRemove,
} = useDirectoryDetail(owner)
const { can } = usePermissions()
@@ -226,7 +250,8 @@ async function saveInfo(): Promise {
}
function goBack(): void {
- router.push('/directory')
+ // Retour sur l'onglet Prospects de la liste (via history.state, hors URL).
+ router.push({ path: '/directory', state: { tab: 'prospects' } })
}
onMounted(async () => {
diff --git a/frontend/utils/historyTab.ts b/frontend/utils/historyTab.ts
new file mode 100644
index 0000000..4656aa8
--- /dev/null
+++ b/frontend/utils/historyTab.ts
@@ -0,0 +1,35 @@
+/**
+ * Onglet actif transmis d'une page à l'autre via l'état d'historique
+ * (`history.state`), SANS le mettre dans l'URL. Sert à préserver l'onglet courant
+ * du Répertoire (Clients / Prospects / Prestataires) lors de l'aller-retour
+ * liste ↔ fiche, dans les deux sens (flèche de l'app ET flèche du navigateur).
+ *
+ * On reste fidèle à la règle « état d'UI local, pas dans l'URL » : l'onglet
+ * voyage dans l'entrée d'historique de la navigation, l'URL ne change pas.
+ */
+
+/**
+ * Lit la clé d'onglet posée dans `history.state.tab` si elle fait partie des
+ * onglets valides. Retourne `null` sinon : navigation directe / deep link,
+ * rechargement de page, ou onglet inexistant.
+ */
+export function readHistoryTab(validKeys: string[]): string | null {
+ if (typeof window === 'undefined') {
+ return null
+ }
+ const tab = (window.history.state as Record | null)?.tab
+ return typeof tab === 'string' && validKeys.includes(tab) ? tab : null
+}
+
+/**
+ * Estampille l'entrée d'historique COURANTE avec l'onglet actif, sans créer de
+ * nouvelle entrée ni changer l'URL. À appeler juste avant de naviguer vers une
+ * fiche : au retour via la flèche du navigateur (popstate), cette entrée
+ * « liste » est restaurée avec son onglet.
+ */
+export function stampHistoryTab(tab: string): void {
+ if (typeof window === 'undefined') {
+ return
+ }
+ window.history.replaceState({ ...window.history.state, tab }, '')
+}
diff --git a/makefile b/makefile
index 440a548..0c242fa 100644
--- a/makefile
+++ b/makefile
@@ -38,7 +38,7 @@ restart: env-init
$(DOCKER_COMPOSE) down
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d
-install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate sync-permissions
+install: copy-git-hook composer-install cache-clear node-use build-nuxtJS migration-migrate sync-permissions fix-uploads-perm
# Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi)
reset: delete_built_dir remove_orphans build-without-cache start wait install
@@ -81,6 +81,13 @@ migration-migrate:
sync-permissions:
$(SYMFONY_CONSOLE) app:sync-permissions
+# Le volume nommé `uploads_data` est créé root:root par Docker (il masque le
+# bind-mount), or PHP-FPM tourne en www-data (= uid host) : sans ce chown, les
+# uploads (documents de compte-rendu, avatars, justificatifs…) échouent en local
+# avec « mkdir(): Permission denied ». Idempotent — relancé par `install`/`reset`.
+fix-uploads-perm:
+ $(EXEC_PHP_ROOT) chown -R www-data:www-data /var/www/html/var/uploads
+
fixtures:
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load