Compare commits

...

6 Commits

Author SHA1 Message Date
gitea-actions 93852875ad chore: bump version to v0.4.48
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 1m16s
2026-06-27 13:30:06 +00:00
tristan bbd8a38c95 feat(directory) : refonte UI du Répertoire (LST-72) (#27)
Auto Tag Develop / tag (push) Successful in 9s
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
gitea-actions 0ee164c302 chore: bump version to v0.4.47
Auto Tag Develop / tag (push) Successful in 6s
Build & Push Docker Image / build (push) Successful in 41s
2026-06-27 09:34:38 +00:00
matthieu d56381b4b8 Merge pull request 'feat(user) : soft-delete (archivage) des utilisateurs + UI archivage/désarchivage' (#30) from fix/user-soft-delete-orphan-references into develop
Auto Tag Develop / tag (push) Successful in 11s
Reviewed-on: #30
2026-06-27 09:34:25 +00:00
matthieu edf5fcc5f5 Merge branch 'develop' into fix/user-soft-delete-orphan-references
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m12s
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m23s
2026-06-27 09:32:17 +00:00
Matthieu 89ce523019 feat(user) : UI archivage/désarchivage des utilisateurs côté admin
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m18s
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m29s
- badge « Archivé » et libellé barré dans la liste admin
- popup de confirmation avant archivage (rappelle que c'est réversible)
- bouton de restauration (PATCH archived:false) pour les archivés
- case « Afficher les utilisateurs archivés » (filtre ?archived=true)
- masque l'action d'archivage sur son propre compte (évite le 403)
- service users : getArchived/restore, toast remove -> users.archived
- i18n FR : clés archived/restored/badge/confirmation
2026-06-26 17:08:20 +02:00
24 changed files with 706 additions and 319 deletions
+1 -1
View File
@@ -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 - **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 - **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) - **Docker** : PHP-FPM + Node 24, Nginx (port 8082), PostgreSQL (port 5435)
## Structure ## Structure
+1 -1
View File
@@ -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). Toutes les routes API sont préfixées `/api` (API Platform).
- Documentation auto-générée : **http://localhost:8082/api** - 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 ## Serveur MCP
@@ -9,11 +9,15 @@ lexik_jwt_authentication:
enabled: false enabled: false
cookie: cookie:
enabled: true 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: query_parameter:
enabled: false enabled: false
set_cookies: set_cookies:
BEARER: BEARER_LESSTIME:
lifetime: '%env(int:JWT_COOKIE_TTL)%' lifetime: '%env(int:JWT_COOKIE_TTL)%'
samesite: lax samesite: lax
path: / path: /
+1 -1
View File
@@ -49,7 +49,7 @@ security:
target: /login target: /login
enable_csrf: false enable_csrf: false
delete_cookies: delete_cookies:
BEARER: BEARER_LESSTIME:
path: / path: /
# Activate different ways to authenticate: # Activate different ways to authenticate:
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.4.46' app.version: '0.4.48'
+81 -5
View File
@@ -11,15 +11,33 @@
/> />
</div> </div>
<div class="mt-4">
<MalioCheckbox
v-model="showArchived"
:label="$t('users.showArchived')"
:reserve-message-space="false"
/>
</div>
<DataTable <DataTable
:columns="columns" :columns="columns"
:items="items" :items="items"
:loading="isLoading" :loading="isLoading"
empty-message="Aucun utilisateur trouvé." empty-message="Aucun utilisateur trouvé."
deletable
@row-click="openEdit" @row-click="openEdit"
@delete="(item) => handleDelete(item.id)"
> >
<template #cell-username="{ item }">
<span :class="{ 'text-neutral-400 line-through': item.archived }">
{{ item.username }}
</span>
<span
v-if="item.archived"
class="ml-2 rounded-full bg-red-100 px-2 py-0.5 text-xs font-semibold text-red-700"
>
{{ $t('users.archivedBadge') }}
</span>
</template>
<template #cell-roles="{ item }"> <template #cell-roles="{ item }">
<span <span
v-for="role in item.roles" v-for="role in item.roles"
@@ -29,6 +47,27 @@
{{ role }} {{ role }}
</span> </span>
</template> </template>
<template #actions="{ item }">
<MalioButtonIcon
v-if="item.archived"
icon="mdi:restore"
:aria-label="$t('users.restore')"
variant="ghost"
icon-size="20"
button-class="text-neutral-400 hover:text-primary-500"
@click.stop="handleRestore(item)"
/>
<MalioButtonIcon
v-else-if="item.id !== currentUserId"
icon="mdi:delete-outline"
:aria-label="$t('users.archive')"
variant="ghost"
icon-size="20"
button-class="text-neutral-400 hover:text-red-500"
@click.stop="openArchiveConfirm(item)"
/>
</template>
</DataTable> </DataTable>
<UserDrawer <UserDrawer
@@ -36,12 +75,19 @@
:item="selectedItem" :item="selectedItem"
@saved="onSaved" @saved="onSaved"
/> />
<ConfirmArchiveUserModal
v-model="archiveConfirmOpen"
:username="userToArchive?.username ?? ''"
@confirm="confirmArchive"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { UserData } from '~/services/dto/user-data' import type { UserData } from '~/services/dto/user-data'
import { useUserService } from '~/services/users' import { useUserService } from '~/services/users'
import { useAuthStore } from '~/shared/stores/auth'
import type { DataTableColumn } from '~/components/ui/DataTable.vue' import type { DataTableColumn } from '~/components/ui/DataTable.vue'
@@ -50,16 +96,27 @@ const columns: DataTableColumn[] = [
{ key: 'roles', label: 'Rôles' }, { key: 'roles', label: 'Rôles' },
] ]
const { getAll, remove } = useUserService() const { getAll, getArchived, remove, restore } = useUserService()
const authStore = useAuthStore()
const currentUserId = computed(() => authStore.user?.id)
const items = ref<UserData[]>([]) const items = ref<UserData[]>([])
const isLoading = ref(true) const isLoading = ref(true)
const drawerOpen = ref(false) const drawerOpen = ref(false)
const selectedItem = ref<UserData | null>(null) const selectedItem = ref<UserData | null>(null)
const showArchived = ref(false)
const archiveConfirmOpen = ref(false)
const userToArchive = ref<UserData | null>(null)
async function loadItems() { async function loadItems() {
isLoading.value = true isLoading.value = true
try { try {
if (showArchived.value) {
const [active, archived] = await Promise.all([getAll(), getArchived()])
items.value = [...active, ...archived]
} else {
items.value = await getAll() items.value = await getAll()
}
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@@ -75,8 +132,23 @@ function openEdit(item: UserData) {
drawerOpen.value = true drawerOpen.value = true
} }
async function handleDelete(id: number) { function openArchiveConfirm(item: UserData) {
await remove(id) userToArchive.value = item
archiveConfirmOpen.value = true
}
async function confirmArchive() {
if (!userToArchive.value) {
return
}
await remove(userToArchive.value.id)
archiveConfirmOpen.value = false
userToArchive.value = null
await loadItems()
}
async function handleRestore(item: UserData) {
await restore(item.id)
await loadItems() await loadItems()
} }
@@ -84,6 +156,10 @@ async function onSaved() {
await loadItems() await loadItems()
} }
watch(showArchived, () => {
loadItems()
})
onMounted(() => { onMounted(() => {
loadItems() loadItems()
}) })
@@ -1,61 +0,0 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-[70] flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click.stop="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('directory.reports.confirmDeleteTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ $t('directory.reports.confirmDeleteMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
:disabled="busy"
@click="cancel"
/>
<MalioButton
variant="danger"
:label="$t('common.delete')"
button-class="w-auto px-4"
:disabled="busy"
@click="$emit('confirm')"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: boolean
// Suppression en cours : on désactive les actions pour éviter un double envoi.
busy?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
function cancel() {
if (props.busy) return
emit('update:modelValue', false)
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>
@@ -4,20 +4,20 @@
<div class="fixed inset-0 z-50 flex items-center justify-center"> <div class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancel" /> <div class="absolute inset-0 bg-black/30" @click="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl"> <div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ title }}</h3> <h3 class="text-lg font-bold text-neutral-900">{{ $t('users.archiveConfirmTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600"> <p class="mt-3 text-sm text-neutral-600">
{{ message }} {{ $t('users.archiveConfirmMessage', { username }) }}
</p> </p>
<div class="mt-6 flex justify-end gap-3"> <div class="mt-6 flex justify-end gap-3">
<MalioButton <MalioButton
variant="tertiary" variant="tertiary"
:label="$t('common.cancel')" label="Annuler"
button-class="w-auto px-4" button-class="w-auto px-4"
@click="cancel" @click="cancel"
/> />
<MalioButton <MalioButton
variant="danger" variant="danger"
:label="$t('common.delete')" :label="$t('users.archive')"
button-class="w-auto px-4" button-class="w-auto px-4"
@click="$emit('confirm')" @click="$emit('confirm')"
/> />
@@ -31,8 +31,7 @@
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ defineProps<{
modelValue: boolean modelValue: boolean
title: string username: string
message: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
+22 -3
View File
@@ -194,8 +194,16 @@
"created": "Utilisateur créé avec succès.", "created": "Utilisateur créé avec succès.",
"updated": "Utilisateur mis à jour avec succès.", "updated": "Utilisateur mis à jour avec succès.",
"deleted": "Utilisateur supprimé avec succès.", "deleted": "Utilisateur supprimé avec succès.",
"archived": "Utilisateur archivé avec succès.",
"restored": "Utilisateur restauré avec succès.",
"addUser": "Ajouter un utilisateur", "addUser": "Ajouter un utilisateur",
"editUser": "Modifier un utilisateur" "editUser": "Modifier un utilisateur",
"archivedBadge": "Archivé",
"showArchived": "Afficher les utilisateurs archivés",
"archive": "Archiver",
"restore": "Restaurer",
"archiveConfirmTitle": "Archiver l'utilisateur",
"archiveConfirmMessage": "Êtes-vous sûr de vouloir archiver l'utilisateur « {username} » ? Son compte sera désactivé (il ne pourra plus se connecter), mais ses données et son historique restent conservés. Vous pourrez le restaurer plus tard."
}, },
"admin": { "admin": {
"roles": { "roles": {
@@ -424,6 +432,7 @@
"edit": "Modifier", "edit": "Modifier",
"delete": "Supprimer", "delete": "Supprimer",
"add": "Ajouter", "add": "Ajouter",
"actions": "Action",
"loading": "Chargement...", "loading": "Chargement...",
"archived": "Archivé", "archived": "Archivé",
"noClient": "Aucun client", "noClient": "Aucun client",
@@ -918,6 +927,9 @@
"editProspect": "Modifier un prospect", "editProspect": "Modifier un prospect",
"convert": "Convertir en client", "convert": "Convertir en client",
"alreadyConverted": "Déjà converti 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", "deleteConfirmTitle": "Supprimer le prospect",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le prospect « {name} » ? Cette action est irréversible.", "deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le prospect « {name} » ? Cette action est irréversible.",
"fields": { "fields": {
@@ -1000,10 +1012,12 @@
"empty": "Aucun prestataire trouvé." "empty": "Aucun prestataire trouvé."
}, },
"contacts": { "contacts": {
"add": "Ajouter un contact", "add": "Nouveau contact",
"item": "Contact {n}", "item": "Contact {n}",
"saved": "Contact enregistré.", "saved": "Contact enregistré.",
"deleted": "Contact supprimé.", "deleted": "Contact supprimé.",
"deleteConfirmTitle": "Supprimer le contact",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer ce contact ? Cette action est irréversible.",
"fields": { "fields": {
"lastName": "Nom", "lastName": "Nom",
"firstName": "Prénom", "firstName": "Prénom",
@@ -1014,11 +1028,14 @@
} }
}, },
"addresses": { "addresses": {
"add": "Ajouter une adresse", "add": "Nouvelle adresse",
"item": "Adresse {n}", "item": "Adresse {n}",
"saved": "Adresse enregistrée.", "saved": "Adresse enregistrée.",
"deleted": "Adresse supprimé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.", "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.", "autocompleteUnavailable": "Recherche d'adresse indisponible : saisissez l'adresse manuellement.",
"fields": { "fields": {
"label": "Libellé", "label": "Libellé",
@@ -1040,6 +1057,8 @@
"deleted": "Compte-rendu supprimé.", "deleted": "Compte-rendu supprimé.",
"confirmDeleteTitle": "Supprimer ce compte-rendu ?", "confirmDeleteTitle": "Supprimer ce compte-rendu ?",
"confirmDeleteMessage": "Cette action est irréversible. Les documents joints seront également supprimés.", "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": { "fields": {
"subject": "Objet", "subject": "Objet",
"type": "Type d'échange", "type": "Type d'échange",
@@ -9,8 +9,7 @@
v-if="canManage" v-if="canManage"
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" :label="$t('common.add')"
:label="$t('directory.reports.add')"
@click="openCreate" @click="openCreate"
/> />
</div> </div>
@@ -108,7 +107,7 @@
v-if="report.documents?.length" v-if="report.documents?.length"
:documents="report.documents" :documents="report.documents"
:can-manage="canManage" :can-manage="canManage"
@delete="(docId) => removeDocument(docId)" @delete="(docId) => askDeleteDocument(docId)"
/> />
<ReportDocumentUpload <ReportDocumentUpload
v-if="canManage" v-if="canManage"
@@ -127,11 +126,18 @@
:owner="owner" :owner="owner"
@saved="reload" @saved="reload"
/> />
<ConfirmDeleteReportModal <ConfirmModal
v-model="confirmOpen" v-model="confirmOpen"
:busy="deleting" :title="$t('directory.reports.confirmDeleteTitle')"
:message="$t('directory.reports.confirmDeleteMessage')"
@confirm="confirmDelete" @confirm="confirmDelete"
/> />
<ConfirmModal
v-model="docConfirmOpen"
:title="$t('directory.reports.documentDeleteTitle')"
:message="$t('directory.reports.documentDeleteMessage')"
@confirm="confirmDeleteDocument"
/>
</div> </div>
</template> </template>
@@ -158,6 +164,11 @@ const confirmOpen = ref(false)
const pendingDelete = ref<CommercialReport | null>(null) const pendingDelete = ref<CommercialReport | null>(null)
const deleting = ref(false) const deleting = ref(false)
// Suppression d'un document joint : passe désormais par une modal de confirmation.
const docConfirmOpen = ref(false)
const pendingDocId = ref<number | null>(null)
const deletingDoc = ref(false)
// Le plus récent en haut (l'API ne garantit pas l'ordre). // Le plus récent en haut (l'API ne garantit pas l'ordre).
const sortedReports = computed(() => const sortedReports = computed(() =>
[...reports.value].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)), [...reports.value].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)),
@@ -222,9 +233,22 @@ async function confirmDelete(): Promise<void> {
} }
} }
async function removeDocument(id: number): Promise<void> { function askDeleteDocument(id: number): void {
await documentService.remove(id) pendingDocId.value = id
docConfirmOpen.value = true
}
async function confirmDeleteDocument(): Promise<void> {
if (pendingDocId.value === null || deletingDoc.value) return
deletingDoc.value = true
try {
await documentService.remove(pendingDocId.value)
docConfirmOpen.value = false
pendingDocId.value = null
await reload() await reload()
} finally {
deletingDoc.value = false
}
} }
async function reload(): Promise<void> { async function reload(): Promise<void> {
@@ -0,0 +1,54 @@
<template>
<MalioModal
:model-value="modelValue"
modal-class="max-w-md"
@update:model-value="$emit('update:modelValue', $event)"
>
<template #header>
<h2 class="text-[24px] font-bold">{{ title }}</h2>
</template>
<!-- Corps : slot par défaut pour permettre du texte enrichi (nom en gras
via <i18n-t>) ; sinon repli sur le message texte simple. -->
<slot>
<p>{{ message }}</p>
</slot>
<template #footer>
<MalioButton
variant="secondary"
button-class="flex-1"
:label="cancelLabel ?? $t('common.cancel')"
@click="$emit('update:modelValue', false)"
/>
<MalioButton
:variant="confirmVariant"
button-class="flex-1"
:label="confirmLabel ?? $t('common.delete')"
@click="$emit('confirm')"
/>
</template>
</MalioModal>
</template>
<script setup lang="ts">
withDefaults(
defineProps<{
modelValue: boolean
title: string
message?: string
confirmLabel?: string
cancelLabel?: string
confirmVariant?: 'primary' | 'secondary' | 'tertiary' | 'danger'
}>(),
{
message: undefined,
confirmLabel: undefined,
cancelLabel: undefined,
confirmVariant: 'danger',
},
)
defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
</script>
@@ -1,17 +1,21 @@
<template> <template>
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]"> <!-- Bloc à plat (sans box-shadow) : un filet noir 1px le sépare du suivant
<h3 class="col-span-2 text-sm font-semibold text-neutral-700"> (pas de bordure sous le dernier bloc), comme sur Starseed. -->
{{ title }} <div class="pb-5" :class="{ 'border-b border-black': !last }">
</h3> <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 <MalioButtonIcon
v-if="removable && !readonly" v-if="removable && !readonly"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
class="absolute right-3 top-3" button-class="p-0"
:aria-label="$t('common.delete')" :aria-label="$t('common.delete')"
@click="$emit('remove')" @click="$emit('remove')"
/> />
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
class="col-span-2" class="col-span-2"
:label="$t('directory.addresses.fields.label')" :label="$t('directory.addresses.fields.label')"
@@ -20,9 +24,37 @@
@update:model-value="update('label', $event)" @update:model-value="update('label', $event)"
/> />
<!-- Rue : saisie assistée (BAN) en édition, champ texte en lecture seule. <!-- On commence par le code postal : il alimente la liste des villes (BAN)
allow-create conserve le texte saisi si la BAN ne propose rien et réinitialise ville/rue devenues incohérentes en cas de changement. -->
(erreur/timeout). Choisir une suggestion remplit rue + CP + ville. --> <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"> <div class="col-span-2">
<MalioInputAutocomplete <MalioInputAutocomplete
v-if="!readonly" v-if="!readonly"
@@ -31,6 +63,8 @@
:loading="addressLoading" :loading="addressLoading"
:min-search-length="3" :min-search-length="3"
:allow-create="true" :allow-create="true"
:disabled="!canEditStreet"
:hint="canEditStreet ? '' : $t('directory.addresses.streetHint')"
:label="$t('directory.addresses.fields.street')" :label="$t('directory.addresses.fields.street')"
:no-results-text="$t('directory.addresses.streetNotFound')" :no-results-text="$t('directory.addresses.streetNotFound')"
@update:model-value="(v) => update('street', v === null ? '' : String(v))" @update:model-value="(v) => update('street', v === null ? '' : String(v))"
@@ -53,32 +87,7 @@
:readonly="readonly" :readonly="readonly"
@update:model-value="update('streetComplement', $event)" @update:model-value="update('streetComplement', $event)"
/> />
</div>
<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="(v) => update('city', v === null ? '' : String(v))"
/>
<MalioInputText
v-else
:label="$t('directory.addresses.fields.city')"
:model-value="modelValue.city ?? ''"
:readonly="readonly"
@update:model-value="update('city', $event)"
/>
</div> </div>
</template> </template>
@@ -94,6 +103,8 @@ const props = defineProps<{
title: string title: string
removable?: boolean removable?: boolean
readonly?: boolean readonly?: boolean
/** Dernier bloc de la liste : supprime le filet de séparation bas. */
last?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -112,6 +123,16 @@ const addressOptions = ref<Option[]>([])
const fetchedCityOptions = ref<Option[]>([]) const fetchedCityOptions = ref<Option[]>([])
const addressLoading = ref(false) 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 // 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 // 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, // 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') }) 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). */ /** Recherche d'adresse assistée (event de MalioInputAutocomplete). */
async function onAddressSearch(query: string): Promise<void> { async function onAddressSearch(query: string): Promise<void> {
if (query.trim().length < 3) { 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<void> { async function onPostalCodeInput(value: string): Promise<void> {
update('postalCode', value)
const digits = (value ?? '').replace(/\D/g, '') 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 if (digits.length < 5) return
try { try {
const suggestions = await autocomplete.searchCity(digits) const suggestions = await autocomplete.searchCity(digits)
@@ -1,17 +1,20 @@
<template> <template>
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]"> <!-- Bloc à plat (sans box-shadow) : un filet noir 1px le sépare du suivant
<h3 class="col-span-2 text-sm font-semibold text-neutral-700"> (pas de bordure sous le dernier bloc), comme sur Starseed. -->
{{ title }} <div class="pb-5" :class="{ 'border-b border-black': !last }">
</h3> <div class="flex items-center justify-between">
<h3 class="text-[20px] font-semibold text-black">{{ title }}</h3>
<MalioButtonIcon <MalioButtonIcon
v-if="removable && !readonly" v-if="removable && !readonly"
icon="mdi:delete-outline" icon="mdi:delete-outline"
variant="ghost" variant="ghost"
class="absolute right-3 top-3" button-class="p-0"
:aria-label="$t('common.delete')" :aria-label="$t('common.delete')"
@click="$emit('remove')" @click="$emit('remove')"
/> />
</div>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText <MalioInputText
:label="$t('directory.contacts.fields.lastName')" :label="$t('directory.contacts.fields.lastName')"
:model-value="modelValue.lastName ?? ''" :model-value="modelValue.lastName ?? ''"
@@ -31,21 +34,21 @@
:readonly="readonly" :readonly="readonly"
@update:model-value="update('jobTitle', $event)" @update:model-value="update('jobTitle', $event)"
/> />
<MalioInputText <MalioInputEmail
:label="$t('directory.contacts.fields.email')" :label="$t('directory.contacts.fields.email')"
:model-value="modelValue.email ?? ''" :model-value="modelValue.email ?? ''"
:readonly="readonly" :readonly="readonly"
:error="emailError" :error="emailError"
@update:model-value="update('email', $event)" @update:model-value="update('email', $event)"
/> />
<MalioInputText <MalioInputPhone
:label="$t('directory.contacts.fields.phonePrimary')" :label="$t('directory.contacts.fields.phonePrimary')"
:model-value="modelValue.phonePrimary ?? ''" :model-value="modelValue.phonePrimary ?? ''"
:readonly="readonly" :readonly="readonly"
:error="phonePrimaryError" :error="phonePrimaryError"
@update:model-value="update('phonePrimary', $event)" @update:model-value="update('phonePrimary', $event)"
/> />
<MalioInputText <MalioInputPhone
:label="$t('directory.contacts.fields.phoneSecondary')" :label="$t('directory.contacts.fields.phoneSecondary')"
:model-value="modelValue.phoneSecondary ?? ''" :model-value="modelValue.phoneSecondary ?? ''"
:readonly="readonly" :readonly="readonly"
@@ -53,6 +56,7 @@
@update:model-value="update('phoneSecondary', $event)" @update:model-value="update('phoneSecondary', $event)"
/> />
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -64,6 +68,8 @@ const props = defineProps<{
title: string title: string
removable?: boolean removable?: boolean
readonly?: boolean readonly?: boolean
/** Dernier bloc de la liste : supprime le filet de séparation bas. */
last?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -16,8 +16,8 @@
</a> </a>
<MalioButtonIcon <MalioButtonIcon
v-if="canManage" v-if="canManage"
icon="mdi:trash-can-outline" icon="mdi:delete-outline"
button-class="!text-red-600" variant="ghost"
:aria-label="$t('common.delete')" :aria-label="$t('common.delete')"
@click="$emit('delete', doc.id)" @click="$emit('delete', doc.id)"
/> />
@@ -1,20 +1,14 @@
<template> <template>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<input <MalioInputUpload
ref="fileInput" v-model="fileName"
type="file" class="flex-1"
class="hidden"
@change="onFileSelected"
>
<MalioButton
icon-name="mdi:paperclip"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.documents.add')" :label="$t('directory.documents.add')"
:disabled="uploading" :disabled="uploading"
@click="fileInput?.click()" :reserve-message-space="false"
@file-selected="onFile"
/> />
<span v-if="uploading" class="text-sm text-neutral-500">{{ $t('directory.documents.uploading') }}</span> <span v-if="uploading" class="shrink-0 text-sm text-neutral-500">{{ $t('directory.documents.uploading') }}</span>
</div> </div>
</template> </template>
@@ -25,21 +19,19 @@ const props = defineProps<{ reportId: number }>()
const emit = defineEmits<{ uploaded: [] }>() const emit = defineEmits<{ uploaded: [] }>()
const service = useReportDocumentService() const service = useReportDocumentService()
const fileInput = ref<HTMLInputElement | null>(null) // Nom du fichier affiché par le champ Malio (v-model) ; réinitialisé après envoi.
const fileName = ref('')
const uploading = ref(false) const uploading = ref(false)
async function onFileSelected(event: Event): Promise<void> { // L'upload se déclenche dès la sélection (event natif du composant Malio).
const input = event.target as HTMLInputElement async function onFile(file: File): Promise<void> {
const file = input.files?.[0]
if (!file) return
uploading.value = true uploading.value = true
try { try {
await service.upload(props.reportId, file) await service.upload(props.reportId, file)
emit('uploaded') emit('uploaded')
} finally { } finally {
uploading.value = false uploading.value = false
input.value = '' fileName.value = ''
} }
} }
</script> </script>
@@ -14,6 +14,7 @@ type Owner = { client?: string, prospect?: string, prestataire?: string }
* tel quel par les deux pages. * tel quel par les deux pages.
*/ */
export function useDirectoryDetail(owner: Owner) { export function useDirectoryDetail(owner: Owner) {
const { t } = useI18n()
const contactService = useContactService() const contactService = useContactService()
const addressService = useAddressService() const addressService = useAddressService()
@@ -59,6 +60,39 @@ export function useDirectoryDetail(owner: Owner) {
addresses.value.splice(index, 1) 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 // Persistance au clic : met à jour les blocs existants, crée les nouveaux
// blocs renseignés. Les amorces vides (sans contenu) sont ignorées. // blocs renseignés. Les amorces vides (sans contenu) sont ignorées.
async function saveContacts(): Promise<void> { async function saveContacts(): Promise<void> {
@@ -117,5 +151,12 @@ export function useDirectoryDetail(owner: Owner) {
removeAddress, removeAddress,
saveAddresses, saveAddresses,
load, load,
// Suppression de bloc avec confirmation (modal partagée contact/adresse).
removeModalOpen,
removeModalTitle,
removeModalMessage,
askRemoveContact,
askRemoveAddress,
confirmRemove,
} }
} }
@@ -2,7 +2,14 @@
<div> <div>
<PageHeader> <PageHeader>
<span class="inline-flex items-center gap-3"> <span class="inline-flex items-center gap-3">
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" /> <MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="$t('common.back')"
:aria-label="$t('common.back')"
@click="goBack"
/>
{{ client?.name ?? '…' }} {{ client?.name ?? '…' }}
</span> </span>
</PageHeader> </PageHeader>
@@ -13,7 +20,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs"> <MalioTabList v-model="activeTab" :tabs="tabs">
<template #info> <template #info>
<div class="flex flex-col gap-4 pt-6"> <div class="flex flex-col gap-4 pt-6">
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4 pb-5">
<MalioInputText <MalioInputText
v-model="info.name" v-model="info.name"
class="col-span-2" class="col-span-2"
@@ -21,12 +28,12 @@
:error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''" :error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''"
@blur="infoTouched.name = true" @blur="infoTouched.name = true"
/> />
<MalioInputText <MalioInputEmail
v-model="info.email" v-model="info.email"
:label="$t('directory.info.fields.email')" :label="$t('directory.info.fields.email')"
:error="emailError" :error="emailError"
/> />
<MalioInputText <MalioInputPhone
v-model="info.phone" v-model="info.phone"
:label="$t('directory.info.fields.phone')" :label="$t('directory.info.fields.phone')"
:error="phoneError" :error="phoneError"
@@ -40,7 +47,6 @@
</div> </div>
<div class="flex justify-center pt-2"> <div class="flex justify-center pt-2">
<MalioButton <MalioButton
button-class="w-auto px-6"
:label="$t('common.save')" :label="$t('common.save')"
:disabled="savingInfo || !infoValid" :disabled="savingInfo || !infoValid"
@click="saveInfo" @click="saveInfo"
@@ -57,12 +63,13 @@
:model-value="contact" :model-value="contact"
:title="$t('directory.contacts.item', { n: i + 1 })" :title="$t('directory.contacts.item', { n: i + 1 })"
:removable="contacts.length > 0" :removable="contacts.length > 0"
:last="i === contacts.length - 1"
@update:model-value="(v) => onContactInput(i, v)" @update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)" @remove="askRemoveContact(i)"
/> />
<div class="flex justify-center gap-3 pt-2"> <div class="flex justify-center gap-3 pt-2">
<MalioButton <MalioButton
variant="tertiary" variant="secondary"
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" button-class="w-auto px-4"
@@ -70,7 +77,6 @@
@click="addContact" @click="addContact"
/> />
<MalioButton <MalioButton
button-class="w-auto px-6"
:label="$t('common.save')" :label="$t('common.save')"
:disabled="savingContacts" :disabled="savingContacts"
@click="saveContacts" @click="saveContacts"
@@ -87,12 +93,13 @@
:model-value="address" :model-value="address"
:title="$t('directory.addresses.item', { n: i + 1 })" :title="$t('directory.addresses.item', { n: i + 1 })"
:removable="addresses.length > 0" :removable="addresses.length > 0"
:last="i === addresses.length - 1"
@update:model-value="(v) => onAddressInput(i, v)" @update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)" @remove="askRemoveAddress(i)"
/> />
<div class="flex justify-center gap-3 pt-2"> <div class="flex justify-center gap-3 pt-2">
<MalioButton <MalioButton
variant="tertiary" variant="secondary"
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" button-class="w-auto px-4"
@@ -100,7 +107,6 @@
@click="addAddress" @click="addAddress"
/> />
<MalioButton <MalioButton
button-class="w-auto px-6"
:label="$t('common.save')" :label="$t('common.save')"
:disabled="savingAddresses" :disabled="savingAddresses"
@click="saveAddresses" @click="saveAddresses"
@@ -115,6 +121,13 @@
</MalioTabList> </MalioTabList>
</template> </template>
</div> </div>
<ConfirmModal
v-model="removeModalOpen"
:title="removeModalTitle"
:message="removeModalMessage"
@confirm="confirmRemove"
/>
</div> </div>
</template> </template>
@@ -141,13 +154,17 @@ const {
savingAddresses, savingAddresses,
onContactInput, onContactInput,
addContact, addContact,
removeContact, askRemoveContact,
saveContacts, saveContacts,
onAddressInput, onAddressInput,
addAddress, addAddress,
removeAddress, askRemoveAddress,
saveAddresses, saveAddresses,
load, load,
removeModalOpen,
removeModalTitle,
removeModalMessage,
confirmRemove,
} = useDirectoryDetail(owner) } = useDirectoryDetail(owner)
const { can } = usePermissions() const { can } = usePermissions()
@@ -192,7 +209,8 @@ async function saveInfo(): Promise<void> {
} }
function goBack(): void { 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 () => { onMounted(async () => {
@@ -9,12 +9,11 @@
<!-- Clients --> <!-- Clients -->
<template #clients> <template #clients>
<div class="flex min-h-[30rem] flex-col gap-4 pt-10"> <div class="flex min-h-[30rem] flex-col gap-4 pt-10">
<div class="flex items-center justify-end"> <div class="flex min-h-[48px] items-center justify-end">
<MalioButton <MalioButton
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" :label="$t('common.add')"
:label="$t('directory.clients.add')"
@click="openCreateClient" @click="openCreateClient"
/> />
</div> </div>
@@ -26,6 +25,9 @@
:empty-message="$t('directory.clients.empty')" :empty-message="$t('directory.clients.empty')"
@row-click="openEditClient" @row-click="openEditClient"
> >
<template #header-actions>
<span class="block text-right font-semibold text-black">{{ $t('common.actions') }}</span>
</template>
<template #cell-email="{ item }"> <template #cell-email="{ item }">
{{ (item as Client).email ?? '—' }} {{ (item as Client).email ?? '—' }}
</template> </template>
@@ -37,7 +39,7 @@
<MalioButtonIcon <MalioButtonIcon
icon="mdi:trash-can-outline" icon="mdi:trash-can-outline"
:aria-label="$t('common.delete')" :aria-label="$t('common.delete')"
button-class="!bg-red-100 !text-red-700" button-class="!bg-red-100 !text-red-700 hover:!bg-red-200"
:icon-size="18" :icon-size="18"
@click="askDeleteClient(item as Client)" @click="askDeleteClient(item as Client)"
/> />
@@ -50,7 +52,7 @@
<!-- Prospects --> <!-- Prospects -->
<template #prospects> <template #prospects>
<div class="flex min-h-[30rem] flex-col gap-4 pt-10"> <div class="flex min-h-[30rem] flex-col gap-4 pt-10">
<div class="flex flex-wrap items-end justify-between gap-3"> <div class="flex min-h-[48px] flex-wrap items-center justify-between gap-3">
<MalioSelect <MalioSelect
v-model="statusFilter" v-model="statusFilter"
:label="$t('prospects.fields.status')" :label="$t('prospects.fields.status')"
@@ -61,8 +63,7 @@
<MalioButton <MalioButton
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" :label="$t('common.add')"
:label="$t('directory.prospects.add')"
@click="openCreateProspect" @click="openCreateProspect"
/> />
</div> </div>
@@ -74,6 +75,9 @@
:empty-message="$t('directory.prospects.empty')" :empty-message="$t('directory.prospects.empty')"
@row-click="openEditProspect" @row-click="openEditProspect"
> >
<template #header-actions>
<span class="block text-right font-semibold text-black">{{ $t('common.actions') }}</span>
</template>
<template #cell-status="{ item }"> <template #cell-status="{ item }">
<StatusBadge <StatusBadge
:label="statusLabel((item as ProspectRow).status)" :label="statusLabel((item as ProspectRow).status)"
@@ -92,14 +96,14 @@
v-if="!(item as ProspectRow).convertedClient" v-if="!(item as ProspectRow).convertedClient"
icon="mdi:account-convert" icon="mdi:account-convert"
:aria-label="$t('prospects.convert')" :aria-label="$t('prospects.convert')"
button-class="!bg-green-100 !text-green-700" button-class="!bg-green-100 !text-green-700 hover:!bg-green-200"
:icon-size="18" :icon-size="18"
@click="convertProspect(item as ProspectRow)" @click="askConvertProspect(item as ProspectRow)"
/> />
<MalioButtonIcon <MalioButtonIcon
icon="mdi:trash-can-outline" icon="mdi:trash-can-outline"
:aria-label="$t('common.delete')" :aria-label="$t('common.delete')"
button-class="!bg-red-100 !text-red-700" button-class="!bg-red-100 !text-red-700 hover:!bg-red-200"
:icon-size="18" :icon-size="18"
@click="askDeleteProspect(item as ProspectRow)" @click="askDeleteProspect(item as ProspectRow)"
/> />
@@ -111,12 +115,11 @@
<!-- Prestataires --> <!-- Prestataires -->
<template #prestataires> <template #prestataires>
<div class="flex min-h-[30rem] flex-col gap-4 pt-10"> <div class="flex min-h-[30rem] flex-col gap-4 pt-10">
<div class="flex items-center justify-end"> <div class="flex min-h-[48px] items-center justify-end">
<MalioButton <MalioButton
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" :label="$t('common.add')"
:label="$t('directory.prestataires.add')"
@click="openCreatePrestataire" @click="openCreatePrestataire"
/> />
</div> </div>
@@ -128,6 +131,9 @@
:empty-message="$t('directory.prestataires.empty')" :empty-message="$t('directory.prestataires.empty')"
@row-click="openEditPrestataire" @row-click="openEditPrestataire"
> >
<template #header-actions>
<span class="block text-right font-semibold text-black">{{ $t('common.actions') }}</span>
</template>
<template #cell-email="{ item }"> <template #cell-email="{ item }">
{{ (item as Prestataire).email ?? '—' }} {{ (item as Prestataire).email ?? '—' }}
</template> </template>
@@ -139,7 +145,7 @@
<MalioButtonIcon <MalioButtonIcon
icon="mdi:trash-can-outline" icon="mdi:trash-can-outline"
:aria-label="$t('common.delete')" :aria-label="$t('common.delete')"
button-class="!bg-red-100 !text-red-700" button-class="!bg-red-100 !text-red-700 hover:!bg-red-200"
:icon-size="18" :icon-size="18"
@click="askDeletePrestataire(item as Prestataire)" @click="askDeletePrestataire(item as Prestataire)"
/> />
@@ -166,12 +172,31 @@
@saved="loadPrestataires" @saved="loadPrestataires"
/> />
<ConfirmDeleteModal <ConfirmModal
v-model="deleteModalOpen" v-model="deleteModalOpen"
:title="deleteModalTitle" :title="deleteModalTitle"
:message="deleteModalMessage"
@confirm="confirmDelete" @confirm="confirmDelete"
/> >
<i18n-t :keypath="deleteModalKeypath" tag="p" scope="global">
<template #name>
<strong class="font-semibold">{{ deleteTargetName }}</strong>
</template>
</i18n-t>
</ConfirmModal>
<ConfirmModal
v-model="convertModalOpen"
:title="$t('prospects.convertConfirmTitle')"
:confirm-label="$t('prospects.convertConfirm')"
confirm-variant="primary"
@confirm="confirmConvert"
>
<i18n-t keypath="prospects.convertConfirmMessage" tag="p" scope="global">
<template #name>
<strong class="font-semibold">{{ convertTarget?.company }}</strong>
</template>
</i18n-t>
</ConfirmModal>
</div> </div>
</div> </div>
</template> </template>
@@ -183,6 +208,7 @@ import type { Prospect, ProspectStatus } from '~/modules/directory/services/dto/
import { useProspectService } from '~/modules/directory/services/prospects' import { useProspectService } from '~/modules/directory/services/prospects'
import type { Prestataire } from '~/modules/directory/services/dto/prestataire' import type { Prestataire } from '~/modules/directory/services/dto/prestataire'
import { usePrestataireService } from '~/modules/directory/services/prestataires' import { usePrestataireService } from '~/modules/directory/services/prestataires'
import { readHistoryTab, stampHistoryTab } from '~/utils/historyTab'
definePageMeta({ middleware: ['admin'] }) definePageMeta({ middleware: ['admin'] })
@@ -201,6 +227,14 @@ const tabs = [
{ key: 'prospects', label: t('directory.tabs.prospects'), icon: 'mdi:account-search-outline' }, { 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' }, { 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 --- // --- Clients ---
const clients = ref<Client[]>([]) const clients = ref<Client[]>([])
@@ -211,7 +245,7 @@ const clientColumns = [
{ key: 'name', label: t('prospects.fields.company') }, { key: 'name', label: t('prospects.fields.company') },
{ key: 'email', label: t('prospects.fields.email') }, { key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') }, { key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: '' }, { key: 'actions', label: t('common.actions') },
] ]
async function loadClients() { async function loadClients() {
@@ -224,7 +258,7 @@ function openCreateClient() {
} }
function openEditClient(item: Record<string, unknown>) { function openEditClient(item: Record<string, unknown>) {
navigateTo(`/directory/clients/${(item as Client).id}`) navigateToDetail(`/directory/clients/${(item as Client).id}`)
} }
// --- Prospects --- // --- Prospects ---
@@ -246,7 +280,7 @@ const prospectColumns = [
{ key: 'status', label: t('prospects.fields.status') }, { key: 'status', label: t('prospects.fields.status') },
{ key: 'email', label: t('prospects.fields.email') }, { key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') }, { key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: '' }, { key: 'actions', label: t('common.actions') },
] ]
const prospectRows = computed<ProspectRow[]>(() => prospects.value) const prospectRows = computed<ProspectRow[]>(() => prospects.value)
@@ -282,13 +316,26 @@ function openCreateProspect() {
} }
function openEditProspect(item: Record<string, unknown>) { function openEditProspect(item: Record<string, unknown>) {
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<ProspectRow | null>(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) await prospectService.convert(row.id)
// La conversion crée un client et retire le prospect : rafraîchir les deux listes. // La conversion crée un client et retire le prospect : rafraîchir les deux listes.
await Promise.all([loadProspects(), loadClients()]) await Promise.all([loadProspects(), loadClients()])
convertModalOpen.value = false
convertTarget.value = null
} }
// Le ProspectDrawer porte aussi le bouton « Convertir » : son event 'saved' peut // 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: 'name', label: t('prospects.fields.company') },
{ key: 'email', label: t('prospects.fields.email') }, { key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') }, { key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: '' }, { key: 'actions', label: t('common.actions') },
] ]
async function loadPrestataires() { async function loadPrestataires() {
@@ -319,7 +366,7 @@ function openCreatePrestataire() {
} }
function openEditPrestataire(item: Record<string, unknown>) { function openEditPrestataire(item: Record<string, unknown>) {
navigateTo(`/directory/prestataires/${(item as Prestataire).id}`) navigateToDetail(`/directory/prestataires/${(item as Prestataire).id}`)
} }
// --- Suppression (clients, prospects & prestataires) --- // --- 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 <i18n-t> 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 const target = deleteTarget.value
if (!target) return '' if (!target) return ''
switch (target.type) { return target.type === 'prospect' ? target.item.company : target.item.name
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 })
}
}) })
function askDeleteClient(item: Client) { function askDeleteClient(item: Client) {
@@ -392,6 +444,9 @@ async function confirmDelete() {
watch(statusFilter, loadProspects) watch(statusFilter, loadProspects)
onMounted(async () => { 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()]) await Promise.all([loadClients(), loadProspects(), loadPrestataires()])
}) })
</script> </script>
@@ -2,7 +2,14 @@
<div> <div>
<PageHeader> <PageHeader>
<span class="inline-flex items-center gap-3"> <span class="inline-flex items-center gap-3">
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" /> <MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="$t('common.back')"
:aria-label="$t('common.back')"
@click="goBack"
/>
{{ prestataire?.name ?? '…' }} {{ prestataire?.name ?? '…' }}
</span> </span>
</PageHeader> </PageHeader>
@@ -13,7 +20,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs"> <MalioTabList v-model="activeTab" :tabs="tabs">
<template #info> <template #info>
<div class="flex flex-col gap-4 pt-6"> <div class="flex flex-col gap-4 pt-6">
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4 pb-5">
<MalioInputText <MalioInputText
v-model="info.name" v-model="info.name"
class="col-span-2" class="col-span-2"
@@ -21,12 +28,12 @@
:error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''" :error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''"
@blur="infoTouched.name = true" @blur="infoTouched.name = true"
/> />
<MalioInputText <MalioInputEmail
v-model="info.email" v-model="info.email"
:label="$t('directory.info.fields.email')" :label="$t('directory.info.fields.email')"
:error="emailError" :error="emailError"
/> />
<MalioInputText <MalioInputPhone
v-model="info.phone" v-model="info.phone"
:label="$t('directory.info.fields.phone')" :label="$t('directory.info.fields.phone')"
:error="phoneError" :error="phoneError"
@@ -40,7 +47,6 @@
</div> </div>
<div class="flex justify-center pt-2"> <div class="flex justify-center pt-2">
<MalioButton <MalioButton
button-class="w-auto px-6"
:label="$t('common.save')" :label="$t('common.save')"
:disabled="savingInfo || !infoValid" :disabled="savingInfo || !infoValid"
@click="saveInfo" @click="saveInfo"
@@ -57,12 +63,13 @@
:model-value="contact" :model-value="contact"
:title="$t('directory.contacts.item', { n: i + 1 })" :title="$t('directory.contacts.item', { n: i + 1 })"
:removable="contacts.length > 0" :removable="contacts.length > 0"
:last="i === contacts.length - 1"
@update:model-value="(v) => onContactInput(i, v)" @update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)" @remove="askRemoveContact(i)"
/> />
<div class="flex justify-center gap-3 pt-2"> <div class="flex justify-center gap-3 pt-2">
<MalioButton <MalioButton
variant="tertiary" variant="secondary"
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" button-class="w-auto px-4"
@@ -70,7 +77,6 @@
@click="addContact" @click="addContact"
/> />
<MalioButton <MalioButton
button-class="w-auto px-6"
:label="$t('common.save')" :label="$t('common.save')"
:disabled="savingContacts" :disabled="savingContacts"
@click="saveContacts" @click="saveContacts"
@@ -87,12 +93,13 @@
:model-value="address" :model-value="address"
:title="$t('directory.addresses.item', { n: i + 1 })" :title="$t('directory.addresses.item', { n: i + 1 })"
:removable="addresses.length > 0" :removable="addresses.length > 0"
:last="i === addresses.length - 1"
@update:model-value="(v) => onAddressInput(i, v)" @update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)" @remove="askRemoveAddress(i)"
/> />
<div class="flex justify-center gap-3 pt-2"> <div class="flex justify-center gap-3 pt-2">
<MalioButton <MalioButton
variant="tertiary" variant="secondary"
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" button-class="w-auto px-4"
@@ -100,7 +107,6 @@
@click="addAddress" @click="addAddress"
/> />
<MalioButton <MalioButton
button-class="w-auto px-6"
:label="$t('common.save')" :label="$t('common.save')"
:disabled="savingAddresses" :disabled="savingAddresses"
@click="saveAddresses" @click="saveAddresses"
@@ -115,6 +121,13 @@
</MalioTabList> </MalioTabList>
</template> </template>
</div> </div>
<ConfirmModal
v-model="removeModalOpen"
:title="removeModalTitle"
:message="removeModalMessage"
@confirm="confirmRemove"
/>
</div> </div>
</template> </template>
@@ -141,13 +154,17 @@ const {
savingAddresses, savingAddresses,
onContactInput, onContactInput,
addContact, addContact,
removeContact, askRemoveContact,
saveContacts, saveContacts,
onAddressInput, onAddressInput,
addAddress, addAddress,
removeAddress, askRemoveAddress,
saveAddresses, saveAddresses,
load, load,
removeModalOpen,
removeModalTitle,
removeModalMessage,
confirmRemove,
} = useDirectoryDetail(owner) } = useDirectoryDetail(owner)
const { can } = usePermissions() const { can } = usePermissions()
@@ -190,7 +207,8 @@ async function saveInfo(): Promise<void> {
} }
function goBack(): void { 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 () => { onMounted(async () => {
@@ -2,7 +2,14 @@
<div> <div>
<PageHeader> <PageHeader>
<span class="inline-flex items-center gap-3"> <span class="inline-flex items-center gap-3">
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" /> <MalioButtonIcon
icon="mdi:arrow-left-bold"
icon-size="24"
variant="ghost"
:title="$t('common.back')"
:aria-label="$t('common.back')"
@click="goBack"
/>
{{ prospect?.company ?? '…' }} {{ prospect?.company ?? '…' }}
</span> </span>
</PageHeader> </PageHeader>
@@ -13,7 +20,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs"> <MalioTabList v-model="activeTab" :tabs="tabs">
<template #info> <template #info>
<div class="flex flex-col gap-4 pt-6"> <div class="flex flex-col gap-4 pt-6">
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]"> <div class="grid grid-cols-4 gap-x-[44px] gap-y-4 pb-5">
<MalioInputText <MalioInputText
v-model="info.company" v-model="info.company"
class="col-span-2" class="col-span-2"
@@ -32,12 +39,12 @@
:label="$t('prospects.fields.website')" :label="$t('prospects.fields.website')"
:error="websiteError" :error="websiteError"
/> />
<MalioInputText <MalioInputEmail
v-model="info.email" v-model="info.email"
:label="$t('prospects.fields.email')" :label="$t('prospects.fields.email')"
:error="emailError" :error="emailError"
/> />
<MalioInputText <MalioInputPhone
v-model="info.phone" v-model="info.phone"
:label="$t('prospects.fields.phone')" :label="$t('prospects.fields.phone')"
:error="phoneError" :error="phoneError"
@@ -47,15 +54,21 @@
class="col-span-2" class="col-span-2"
:label="$t('prospects.fields.source')" :label="$t('prospects.fields.source')"
/> />
<!-- Notes : 2 colonnes, hauteur fixe (~2 lignes) avec scroll
interne. Pas de row-span (il déréglait l'auto-placement).
!max-w-none : neutralise le max-width:640px inline du
composant Malio (sinon la textarea ne remplit pas 2 colonnes). -->
<MalioInputTextArea <MalioInputTextArea
v-model="info.notes" v-model="info.notes"
class="col-span-2" group-class="col-span-2"
text-input="!h-28 !max-w-none text-lg"
resize="none"
:reserve-message-space="false"
:label="$t('prospects.fields.notes')" :label="$t('prospects.fields.notes')"
/> />
</div> </div>
<div class="flex justify-center pt-2"> <div class="flex justify-center pt-2">
<MalioButton <MalioButton
button-class="w-auto px-6"
:label="$t('common.save')" :label="$t('common.save')"
:disabled="savingInfo || !infoValid" :disabled="savingInfo || !infoValid"
@click="saveInfo" @click="saveInfo"
@@ -72,12 +85,13 @@
:model-value="contact" :model-value="contact"
:title="$t('directory.contacts.item', { n: i + 1 })" :title="$t('directory.contacts.item', { n: i + 1 })"
:removable="contacts.length > 0" :removable="contacts.length > 0"
:last="i === contacts.length - 1"
@update:model-value="(v) => onContactInput(i, v)" @update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)" @remove="askRemoveContact(i)"
/> />
<div class="flex justify-center gap-3 pt-2"> <div class="flex justify-center gap-3 pt-2">
<MalioButton <MalioButton
variant="tertiary" variant="secondary"
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" button-class="w-auto px-4"
@@ -85,7 +99,6 @@
@click="addContact" @click="addContact"
/> />
<MalioButton <MalioButton
button-class="w-auto px-6"
:label="$t('common.save')" :label="$t('common.save')"
:disabled="savingContacts" :disabled="savingContacts"
@click="saveContacts" @click="saveContacts"
@@ -102,12 +115,13 @@
:model-value="address" :model-value="address"
:title="$t('directory.addresses.item', { n: i + 1 })" :title="$t('directory.addresses.item', { n: i + 1 })"
:removable="addresses.length > 0" :removable="addresses.length > 0"
:last="i === addresses.length - 1"
@update:model-value="(v) => onAddressInput(i, v)" @update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)" @remove="askRemoveAddress(i)"
/> />
<div class="flex justify-center gap-3 pt-2"> <div class="flex justify-center gap-3 pt-2">
<MalioButton <MalioButton
variant="tertiary" variant="secondary"
icon-name="mdi:plus" icon-name="mdi:plus"
icon-position="left" icon-position="left"
button-class="w-auto px-4" button-class="w-auto px-4"
@@ -115,7 +129,6 @@
@click="addAddress" @click="addAddress"
/> />
<MalioButton <MalioButton
button-class="w-auto px-6"
:label="$t('common.save')" :label="$t('common.save')"
:disabled="savingAddresses" :disabled="savingAddresses"
@click="saveAddresses" @click="saveAddresses"
@@ -130,6 +143,13 @@
</MalioTabList> </MalioTabList>
</template> </template>
</div> </div>
<ConfirmModal
v-model="removeModalOpen"
:title="removeModalTitle"
:message="removeModalMessage"
@confirm="confirmRemove"
/>
</div> </div>
</template> </template>
@@ -156,13 +176,17 @@ const {
savingAddresses, savingAddresses,
onContactInput, onContactInput,
addContact, addContact,
removeContact, askRemoveContact,
saveContacts, saveContacts,
onAddressInput, onAddressInput,
addAddress, addAddress,
removeAddress, askRemoveAddress,
saveAddresses, saveAddresses,
load, load,
removeModalOpen,
removeModalTitle,
removeModalMessage,
confirmRemove,
} = useDirectoryDetail(owner) } = useDirectoryDetail(owner)
const { can } = usePermissions() const { can } = usePermissions()
@@ -226,7 +250,8 @@ async function saveInfo(): Promise<void> {
} }
function goBack(): void { 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 () => { onMounted(async () => {
+2
View File
@@ -10,6 +10,8 @@ export type UserData = {
effectivePermissions?: string[] effectivePermissions?: string[]
avatarUrl?: string | null avatarUrl?: string | null
apiToken?: string | null apiToken?: string | null
// Soft-delete flag: an archived user keeps its data but cannot log in
archived?: boolean
// HR / absence management // HR / absence management
isEmployee?: boolean isEmployee?: boolean
hireDate?: string | null hireDate?: string | null
+17 -2
View File
@@ -10,6 +10,13 @@ export function useUserService() {
return extractHydraMembers(data) return extractHydraMembers(data)
} }
// Archived users are hidden from the default collection; an admin lists
// them explicitly via the `archived` filter (handled server-side).
async function getArchived(): Promise<UserData[]> {
const data = await api.get<HydraCollection<UserData>>('/users?archived=true')
return extractHydraMembers(data)
}
async function getById(id: number): Promise<UserData> { async function getById(id: number): Promise<UserData> {
return api.get<UserData>(`/users/${id}`) return api.get<UserData>(`/users/${id}`)
} }
@@ -26,11 +33,19 @@ export function useUserService() {
}) })
} }
// Deleting a user is a soft delete server-side: the account is archived
// (kept for referential integrity) rather than removed.
async function remove(id: number): Promise<void> { async function remove(id: number): Promise<void> {
await api.delete(`/users/${id}`, {}, { await api.delete(`/users/${id}`, {}, {
toastSuccessKey: 'users.deleted', toastSuccessKey: 'users.archived',
}) })
} }
return { getAll, getById, create, update, remove } async function restore(id: number): Promise<UserData> {
return api.patch<UserData>(`/users/${id}`, { archived: false }, {
toastSuccessKey: 'users.restored',
})
}
return { getAll, getArchived, getById, create, update, remove, restore }
} }
+35
View File
@@ -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<string, unknown> | 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 }, '')
}
+8 -1
View File
@@ -38,7 +38,7 @@ restart: env-init
$(DOCKER_COMPOSE) down $(DOCKER_COMPOSE) down
CURRENT_UID=$(shell id -u) CURRENT_GID=$(shell id -g) $(DOCKER_COMPOSE) up -d 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) # Supprime tout est réinstalle tout (Attention ça supprime la bdd aussi)
reset: delete_built_dir remove_orphans build-without-cache start wait install reset: delete_built_dir remove_orphans build-without-cache start wait install
@@ -81,6 +81,13 @@ migration-migrate:
sync-permissions: sync-permissions:
$(SYMFONY_CONSOLE) app: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: fixtures:
$(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load