Compare commits

...

6 Commits

Author SHA1 Message Date
tristan ba462a091b feat(directory) : refonte UI des fiches + onglet rapport (LST-72)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m17s
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m25s
Fiches Client / Prospect / Prestataire (onglet Rapport mis à part) :
- Champs email/téléphone : composants MalioInputEmail / MalioInputPhone
- Grilles en 4 colonnes (Info + blocs Contact/Adresse)
- Boutons « Nouveau contact/adresse » en secondary ; « Enregistrer » en
  taille Malio standard ; marge form↔bouton homogène entre onglets
- Bouton retour ghost (mdi:arrow-left-bold) comme Starseed
- 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 (logique
  centralisée dans useDirectoryDetail)

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:09:23 +02:00
tristan 81069915c1 fix(directory) : rend var/uploads writable en local (LST-72)
Le volume Docker nommé `uploads_data` est créé root:root, or PHP-FPM tourne
en www-data (= uid host) : sans chown, l'upload de documents échoue en local
avec « mkdir(): Permission denied ». Ajoute un target `fix-uploads-perm`
idempotent, branché sur `install` (donc relancé par `reset`).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:09:07 +02:00
tristan 71c6ba1ce5 fix(auth) : renomme le cookie JWT en BEARER_LESSTIME (LST-72)
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m22s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m37s
Évite la collision de cookie sur le domaine localhost en dev : plusieurs
apps Symfony (ex: Starseed) posaient toutes un cookie `BEARER` partagé,
se faisant écraser l'une l'autre → déconnexions croisées. Le cookie est
désormais nommé par app.

- lexik : token_extractors.cookie.name + clé set_cookies
- security : logout.delete_cookies
- docs (CLAUDE.md, README) mises à jour

Note : vider une fois les cookies de localhost pour purger l'ancien BEARER.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 16:02:51 +02:00
tristan 5d1cc09a49 style(directory) : style plat sans box-shadow sur les fiches (LST-72)
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 40s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m39s
Aligne les fiches Client / Prospect / Prestataire sur le design Starseed :
- Onglet Information : grille plate (suppression box blanche + box-shadow)
- Blocs Contact & Adresse : à plat, séparés par un filet noir 1px
  (header titre + bouton supprimer, prop `last` sans bordure en bas)
Onglet Rapport inchangé.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:57:55 +02:00
tristan 6b65839061 feat(directory) : entête Action, hover, modal de conversion et ajustements UI (LST-72)
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 40s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m9s
- Colonne « Action » avec entête (alignée à droite) sur les 3 tableaux
- Feedback hover sur les boutons d'action (poubelle / convertir)
- Conversion prospect → client passe par une modal de confirmation
- ConfirmModal basé sur MalioModal (design Starseed), remplace ConfirmDeleteModal
- Nom (client/prospect/prestataire) en gras dans les modals via <i18n-t>
- Boutons « Ajouter » : label raccourci + taille standard Malio (180px)
- Barres d'outils à hauteur homogène (48px) : le bouton ne saute plus entre onglets

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 15:43:49 +02:00
tristan be079cdbe2 feat(directory) : conserve l'onglet actif au retour liste ↔ fiche (LST-72)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m23s
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m31s
Au retour depuis une fiche (flèche de l'app ou du navigateur), la liste
revenait toujours sur l'onglet Clients. L'onglet actif est désormais
transmis via history.state (hors URL), comme sur Starseed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 20:43:38 +02:00
20 changed files with 583 additions and 361 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
- **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
+1 -1
View File
@@ -188,7 +188,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
@@ -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: /
+1 -1
View File
@@ -47,7 +47,7 @@ security:
target: /login
enable_csrf: false
delete_cookies:
BEARER:
BEARER_LESSTIME:
path: /
# Activate different ways to authenticate:
@@ -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>
+13 -2
View File
@@ -424,6 +424,7 @@
"edit": "Modifier",
"delete": "Supprimer",
"add": "Ajouter",
"actions": "Action",
"loading": "Chargement...",
"archived": "Archivé",
"noClient": "Aucun client",
@@ -918,6 +919,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": {
@@ -1000,10 +1004,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",
@@ -1014,11 +1020,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é",
@@ -1040,6 +1049,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",
@@ -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"
/>
</div>
@@ -108,7 +107,7 @@
v-if="report.documents?.length"
:documents="report.documents"
:can-manage="canManage"
@delete="(docId) => removeDocument(docId)"
@delete="(docId) => askDeleteDocument(docId)"
/>
<ReportDocumentUpload
v-if="canManage"
@@ -127,11 +126,18 @@
:owner="owner"
@saved="reload"
/>
<ConfirmDeleteReportModal
<ConfirmModal
v-model="confirmOpen"
:busy="deleting"
:title="$t('directory.reports.confirmDeleteTitle')"
:message="$t('directory.reports.confirmDeleteMessage')"
@confirm="confirmDelete"
/>
<ConfirmModal
v-model="docConfirmOpen"
:title="$t('directory.reports.documentDeleteTitle')"
:message="$t('directory.reports.documentDeleteMessage')"
@confirm="confirmDeleteDocument"
/>
</div>
</template>
@@ -158,6 +164,11 @@ const confirmOpen = ref(false)
const pendingDelete = ref<CommercialReport | null>(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<number | null>(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<void> {
}
}
async function removeDocument(id: number): Promise<void> {
await documentService.remove(id)
await reload()
function askDeleteDocument(id: number): void {
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()
} finally {
deletingDoc.value = false
}
}
async function reload(): Promise<void> {
@@ -1,58 +0,0 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-50 flex items-center justify-center">
<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">
<h3 class="text-lg font-bold text-neutral-900">{{ title }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ message }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
@click="cancel"
/>
<MalioButton
variant="danger"
:label="$t('common.delete')"
button-class="w-auto px-4"
@click="$emit('confirm')"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
title: string
message: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
function cancel() {
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>
@@ -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,84 +1,93 @@
<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)]">
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
{{ title }}
</h3>
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
class="absolute right-3 top-3"
:aria-label="$t('common.delete')"
@click="$emit('remove')"
/>
<MalioInputText
class="col-span-2"
:label="$t('directory.addresses.fields.label')"
:model-value="modelValue.label ?? ''"
:readonly="readonly"
@update:model-value="update('label', $event)"
/>
<!-- Rue : saisie assistée (BAN) en édition, champ texte en lecture seule.
allow-create conserve le texte saisi si la BAN ne propose rien
(erreur/timeout). Choisir une suggestion remplit rue + CP + ville. -->
<div class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly"
:model-value="modelValue.street ?? ''"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:allow-create="true"
:label="$t('directory.addresses.fields.street')"
:no-results-text="$t('directory.addresses.streetNotFound')"
@update:model-value="(v) => update('street', v === null ? '' : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:label="$t('directory.addresses.fields.street')"
:model-value="modelValue.street ?? ''"
:readonly="readonly"
@update:model-value="update('street', $event)"
<!-- Bloc à plat (sans box-shadow) : un filet noir 1px le sépare du suivant
(pas de bordure sous le dernier bloc), comme sur Starseed. -->
<div class="pb-5" :class="{ 'border-b border-black': !last }">
<div class="flex items-center justify-between">
<!-- Titre = libellé saisi ; repli sur « Adresse N » tant qu'il est vide. -->
<h3 class="text-[20px] font-semibold text-black">{{ blockTitle }}</h3>
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
:aria-label="$t('common.delete')"
@click="$emit('remove')"
/>
</div>
<MalioInputText
class="col-span-2"
:label="$t('directory.addresses.fields.streetComplement')"
:model-value="modelValue.streetComplement ?? ''"
:readonly="readonly"
@update:model-value="update('streetComplement', $event)"
/>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
class="col-span-2"
:label="$t('directory.addresses.fields.label')"
:model-value="modelValue.label ?? ''"
:readonly="readonly"
@update:model-value="update('label', $event)"
/>
<MalioInputText
:label="$t('directory.addresses.fields.postalCode')"
:model-value="modelValue.postalCode ?? ''"
:readonly="readonly"
@update:model-value="onPostalCodeInput"
/>
<!-- On commence par le code postal : il alimente la liste des villes (BAN)
et réinitialise ville/rue devenues incohérentes en cas de changement. -->
<MalioInputText
:label="$t('directory.addresses.fields.postalCode')"
:model-value="modelValue.postalCode ?? ''"
:readonly="readonly"
@update:model-value="onPostalCodeInput"
/>
<!-- Ville : select alimenté par le code postal (BAN). En mode dégradé
(BAN indispo) ou lecture seule, on bascule en saisie libre. -->
<MalioSelect
v-if="!readonly && !degraded"
:model-value="modelValue.city ?? ''"
:options="cityOptions"
:label="$t('directory.addresses.fields.city')"
empty-option-label=""
group-class="w-full"
@update:model-value="(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)"
/>
<!-- Ville : select alimenté par le code postal (BAN). En mode dégradé
(BAN indispo) ou lecture seule, on bascule en saisie libre. -->
<MalioSelect
v-if="!readonly && !degraded"
:model-value="modelValue.city ?? ''"
:options="cityOptions"
:label="$t('directory.addresses.fields.city')"
empty-option-label=""
group-class="w-full"
@update:model-value="onCityChange"
/>
<MalioInputText
v-else
:label="$t('directory.addresses.fields.city')"
:model-value="modelValue.city ?? ''"
:readonly="readonly"
@update:model-value="update('city', $event)"
/>
<!-- Rue : conditionnée au code postal + ville (comme Starseed). Saisie
assistée (BAN) filtrée par le code postal ; désactivée tant que CP et
ville ne sont pas renseignés. Champ texte simple en lecture seule. -->
<div class="col-span-2">
<MalioInputAutocomplete
v-if="!readonly"
:model-value="modelValue.street ?? ''"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:allow-create="true"
:disabled="!canEditStreet"
:hint="canEditStreet ? '' : $t('directory.addresses.streetHint')"
:label="$t('directory.addresses.fields.street')"
:no-results-text="$t('directory.addresses.streetNotFound')"
@update:model-value="(v) => update('street', v === null ? '' : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:label="$t('directory.addresses.fields.street')"
:model-value="modelValue.street ?? ''"
:readonly="readonly"
@update:model-value="update('street', $event)"
/>
</div>
<MalioInputText
class="col-span-2"
:label="$t('directory.addresses.fields.streetComplement')"
:model-value="modelValue.streetComplement ?? ''"
:readonly="readonly"
@update:model-value="update('streetComplement', $event)"
/>
</div>
</div>
</template>
@@ -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<Option[]>([])
const fetchedCityOptions = ref<Option[]>([])
const addressLoading = ref(false)
// Titre du bloc : le libellé saisi prime ; repli sur « Adresse N » (prop `title`).
const blockTitle = computed(() => (props.modelValue.label ?? '').trim() || props.title)
// La rue n'est éditable qu'une fois le code postal (5 chiffres) ET la ville
// renseignés — conditionnement métier repris de Starseed.
const canEditStreet = computed(() => {
const digits = (props.modelValue.postalCode ?? '').replace(/\D/g, '')
return digits.length === 5 && !!(props.modelValue.city ?? '').trim()
})
// Le select Ville n'affiche que les valeurs présentes dans ses options : on
// garantit donc que la ville déjà enregistrée (chargement d'une fiche) ou
// pré-remplie par l'autocomplétion d'adresse figure toujours dans la liste,
@@ -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<void> {
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> {
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)
@@ -1,57 +1,61 @@
<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)]">
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
{{ title }}
</h3>
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
class="absolute right-3 top-3"
:aria-label="$t('common.delete')"
@click="$emit('remove')"
/>
<!-- Bloc à plat (sans box-shadow) : un filet noir 1px le sépare du suivant
(pas de bordure sous le dernier bloc), comme sur Starseed. -->
<div class="pb-5" :class="{ 'border-b border-black': !last }">
<div class="flex items-center justify-between">
<h3 class="text-[20px] font-semibold text-black">{{ title }}</h3>
<MalioButtonIcon
v-if="removable && !readonly"
icon="mdi:delete-outline"
variant="ghost"
button-class="p-0"
:aria-label="$t('common.delete')"
@click="$emit('remove')"
/>
</div>
<MalioInputText
:label="$t('directory.contacts.fields.lastName')"
:model-value="modelValue.lastName ?? ''"
:readonly="readonly"
@update:model-value="update('lastName', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.firstName')"
:model-value="modelValue.firstName ?? ''"
:readonly="readonly"
@update:model-value="update('firstName', $event)"
/>
<MalioInputText
class="col-span-2"
:label="$t('directory.contacts.fields.jobTitle')"
:model-value="modelValue.jobTitle ?? ''"
:readonly="readonly"
@update:model-value="update('jobTitle', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.email')"
:model-value="modelValue.email ?? ''"
:readonly="readonly"
:error="emailError"
@update:model-value="update('email', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.phonePrimary')"
:model-value="modelValue.phonePrimary ?? ''"
:readonly="readonly"
:error="phonePrimaryError"
@update:model-value="update('phonePrimary', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.phoneSecondary')"
:model-value="modelValue.phoneSecondary ?? ''"
:readonly="readonly"
:error="phoneSecondaryError"
@update:model-value="update('phoneSecondary', $event)"
/>
<div class="mt-6 grid grid-cols-4 gap-x-[44px] gap-y-4">
<MalioInputText
:label="$t('directory.contacts.fields.lastName')"
:model-value="modelValue.lastName ?? ''"
:readonly="readonly"
@update:model-value="update('lastName', $event)"
/>
<MalioInputText
:label="$t('directory.contacts.fields.firstName')"
:model-value="modelValue.firstName ?? ''"
:readonly="readonly"
@update:model-value="update('firstName', $event)"
/>
<MalioInputText
class="col-span-2"
:label="$t('directory.contacts.fields.jobTitle')"
:model-value="modelValue.jobTitle ?? ''"
:readonly="readonly"
@update:model-value="update('jobTitle', $event)"
/>
<MalioInputEmail
:label="$t('directory.contacts.fields.email')"
:model-value="modelValue.email ?? ''"
:readonly="readonly"
:error="emailError"
@update:model-value="update('email', $event)"
/>
<MalioInputPhone
:label="$t('directory.contacts.fields.phonePrimary')"
:model-value="modelValue.phonePrimary ?? ''"
:readonly="readonly"
:error="phonePrimaryError"
@update:model-value="update('phonePrimary', $event)"
/>
<MalioInputPhone
:label="$t('directory.contacts.fields.phoneSecondary')"
:model-value="modelValue.phoneSecondary ?? ''"
:readonly="readonly"
:error="phoneSecondaryError"
@update:model-value="update('phoneSecondary', $event)"
/>
</div>
</div>
</template>
@@ -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<{
@@ -16,8 +16,8 @@
</a>
<MalioButtonIcon
v-if="canManage"
icon="mdi:trash-can-outline"
button-class="!text-red-600"
icon="mdi:delete-outline"
variant="ghost"
:aria-label="$t('common.delete')"
@click="$emit('delete', doc.id)"
/>
@@ -1,20 +1,14 @@
<template>
<div class="flex items-center gap-3">
<input
ref="fileInput"
type="file"
class="hidden"
@change="onFileSelected"
>
<MalioButton
icon-name="mdi:paperclip"
icon-position="left"
button-class="w-auto px-4"
<MalioInputUpload
v-model="fileName"
class="flex-1"
:label="$t('directory.documents.add')"
: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>
</template>
@@ -25,21 +19,19 @@ const props = defineProps<{ reportId: number }>()
const emit = defineEmits<{ uploaded: [] }>()
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)
async function onFileSelected(event: Event): Promise<void> {
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<void> {
uploading.value = true
try {
await service.upload(props.reportId, file)
emit('uploaded')
} finally {
uploading.value = false
input.value = ''
fileName.value = ''
}
}
</script>
@@ -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<void> {
const p = pendingRemoval.value
if (!p) return
if (p.type === 'contact') await removeContact(p.index)
else await removeAddress(p.index)
removeModalOpen.value = false
pendingRemoval.value = null
}
// Persistance au clic : met à jour les blocs existants, crée les nouveaux
// blocs renseignés. Les amorces vides (sans contenu) sont ignorées.
async function saveContacts(): Promise<void> {
@@ -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,
}
}
@@ -2,7 +2,14 @@
<div>
<PageHeader>
<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 ?? '…' }}
</span>
</PageHeader>
@@ -13,7 +20,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs">
<template #info>
<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
v-model="info.name"
class="col-span-2"
@@ -21,12 +28,12 @@
:error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''"
@blur="infoTouched.name = true"
/>
<MalioInputText
<MalioInputEmail
v-model="info.email"
:label="$t('directory.info.fields.email')"
:error="emailError"
/>
<MalioInputText
<MalioInputPhone
v-model="info.phone"
:label="$t('directory.info.fields.phone')"
:error="phoneError"
@@ -40,7 +47,6 @@
</div>
<div class="flex justify-center pt-2">
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingInfo || !infoValid"
@click="saveInfo"
@@ -57,12 +63,13 @@
:model-value="contact"
:title="$t('directory.contacts.item', { n: i + 1 })"
:removable="contacts.length > 0"
:last="i === contacts.length - 1"
@update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)"
@remove="askRemoveContact(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
variant="secondary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
@@ -70,7 +77,6 @@
@click="addContact"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingContacts"
@click="saveContacts"
@@ -87,12 +93,13 @@
:model-value="address"
:title="$t('directory.addresses.item', { n: i + 1 })"
:removable="addresses.length > 0"
:last="i === addresses.length - 1"
@update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)"
@remove="askRemoveAddress(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
variant="secondary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
@@ -100,7 +107,6 @@
@click="addAddress"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingAddresses"
@click="saveAddresses"
@@ -115,6 +121,13 @@
</MalioTabList>
</template>
</div>
<ConfirmModal
v-model="removeModalOpen"
:title="removeModalTitle"
:message="removeModalMessage"
@confirm="confirmRemove"
/>
</div>
</template>
@@ -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<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 () => {
@@ -9,12 +9,11 @@
<!-- Clients -->
<template #clients>
<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
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.clients.add')"
:label="$t('common.add')"
@click="openCreateClient"
/>
</div>
@@ -26,6 +25,9 @@
:empty-message="$t('directory.clients.empty')"
@row-click="openEditClient"
>
<template #header-actions>
<span class="block text-right font-semibold text-black">{{ $t('common.actions') }}</span>
</template>
<template #cell-email="{ item }">
{{ (item as Client).email ?? '—' }}
</template>
@@ -37,7 +39,7 @@
<MalioButtonIcon
icon="mdi:trash-can-outline"
: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"
@click="askDeleteClient(item as Client)"
/>
@@ -50,7 +52,7 @@
<!-- Prospects -->
<template #prospects>
<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
v-model="statusFilter"
:label="$t('prospects.fields.status')"
@@ -61,8 +63,7 @@
<MalioButton
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.prospects.add')"
:label="$t('common.add')"
@click="openCreateProspect"
/>
</div>
@@ -74,6 +75,9 @@
:empty-message="$t('directory.prospects.empty')"
@row-click="openEditProspect"
>
<template #header-actions>
<span class="block text-right font-semibold text-black">{{ $t('common.actions') }}</span>
</template>
<template #cell-status="{ item }">
<StatusBadge
:label="statusLabel((item as ProspectRow).status)"
@@ -92,14 +96,14 @@
v-if="!(item as ProspectRow).convertedClient"
icon="mdi:account-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"
@click="convertProspect(item as ProspectRow)"
@click="askConvertProspect(item as ProspectRow)"
/>
<MalioButtonIcon
icon="mdi:trash-can-outline"
: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"
@click="askDeleteProspect(item as ProspectRow)"
/>
@@ -111,12 +115,11 @@
<!-- Prestataires -->
<template #prestataires>
<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
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.prestataires.add')"
:label="$t('common.add')"
@click="openCreatePrestataire"
/>
</div>
@@ -128,6 +131,9 @@
:empty-message="$t('directory.prestataires.empty')"
@row-click="openEditPrestataire"
>
<template #header-actions>
<span class="block text-right font-semibold text-black">{{ $t('common.actions') }}</span>
</template>
<template #cell-email="{ item }">
{{ (item as Prestataire).email ?? '—' }}
</template>
@@ -139,7 +145,7 @@
<MalioButtonIcon
icon="mdi:trash-can-outline"
: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"
@click="askDeletePrestataire(item as Prestataire)"
/>
@@ -166,12 +172,31 @@
@saved="loadPrestataires"
/>
<ConfirmDeleteModal
<ConfirmModal
v-model="deleteModalOpen"
:title="deleteModalTitle"
:message="deleteModalMessage"
@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>
</template>
@@ -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<Client[]>([])
@@ -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<string, unknown>) {
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<ProspectRow[]>(() => prospects.value)
@@ -282,13 +316,26 @@ function openCreateProspect() {
}
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)
// 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<string, unknown>) {
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 <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
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()])
})
</script>
@@ -2,7 +2,14 @@
<div>
<PageHeader>
<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 ?? '…' }}
</span>
</PageHeader>
@@ -13,7 +20,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs">
<template #info>
<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
v-model="info.name"
class="col-span-2"
@@ -21,12 +28,12 @@
:error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''"
@blur="infoTouched.name = true"
/>
<MalioInputText
<MalioInputEmail
v-model="info.email"
:label="$t('directory.info.fields.email')"
:error="emailError"
/>
<MalioInputText
<MalioInputPhone
v-model="info.phone"
:label="$t('directory.info.fields.phone')"
:error="phoneError"
@@ -40,7 +47,6 @@
</div>
<div class="flex justify-center pt-2">
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingInfo || !infoValid"
@click="saveInfo"
@@ -57,12 +63,13 @@
:model-value="contact"
:title="$t('directory.contacts.item', { n: i + 1 })"
:removable="contacts.length > 0"
:last="i === contacts.length - 1"
@update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)"
@remove="askRemoveContact(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
variant="secondary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
@@ -70,7 +77,6 @@
@click="addContact"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingContacts"
@click="saveContacts"
@@ -87,12 +93,13 @@
:model-value="address"
:title="$t('directory.addresses.item', { n: i + 1 })"
:removable="addresses.length > 0"
:last="i === addresses.length - 1"
@update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)"
@remove="askRemoveAddress(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
variant="secondary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
@@ -100,7 +107,6 @@
@click="addAddress"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingAddresses"
@click="saveAddresses"
@@ -115,6 +121,13 @@
</MalioTabList>
</template>
</div>
<ConfirmModal
v-model="removeModalOpen"
:title="removeModalTitle"
:message="removeModalMessage"
@confirm="confirmRemove"
/>
</div>
</template>
@@ -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<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 () => {
@@ -2,7 +2,14 @@
<div>
<PageHeader>
<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 ?? '…' }}
</span>
</PageHeader>
@@ -13,7 +20,7 @@
<MalioTabList v-model="activeTab" :tabs="tabs">
<template #info>
<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
v-model="info.company"
class="col-span-2"
@@ -32,12 +39,12 @@
:label="$t('prospects.fields.website')"
:error="websiteError"
/>
<MalioInputText
<MalioInputEmail
v-model="info.email"
:label="$t('prospects.fields.email')"
:error="emailError"
/>
<MalioInputText
<MalioInputPhone
v-model="info.phone"
:label="$t('prospects.fields.phone')"
:error="phoneError"
@@ -49,13 +56,12 @@
/>
<MalioInputTextArea
v-model="info.notes"
class="col-span-2"
class="col-span-4"
:label="$t('prospects.fields.notes')"
/>
</div>
<div class="flex justify-center pt-2">
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingInfo || !infoValid"
@click="saveInfo"
@@ -72,12 +78,13 @@
:model-value="contact"
:title="$t('directory.contacts.item', { n: i + 1 })"
:removable="contacts.length > 0"
:last="i === contacts.length - 1"
@update:model-value="(v) => onContactInput(i, v)"
@remove="removeContact(i)"
@remove="askRemoveContact(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
variant="secondary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
@@ -85,7 +92,6 @@
@click="addContact"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingContacts"
@click="saveContacts"
@@ -102,12 +108,13 @@
:model-value="address"
:title="$t('directory.addresses.item', { n: i + 1 })"
:removable="addresses.length > 0"
:last="i === addresses.length - 1"
@update:model-value="(v) => onAddressInput(i, v)"
@remove="removeAddress(i)"
@remove="askRemoveAddress(i)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
variant="secondary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
@@ -115,7 +122,6 @@
@click="addAddress"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingAddresses"
@click="saveAddresses"
@@ -130,6 +136,13 @@
</MalioTabList>
</template>
</div>
<ConfirmModal
v-model="removeModalOpen"
:title="removeModalTitle"
:message="removeModalMessage"
@confirm="confirmRemove"
/>
</div>
</template>
@@ -156,13 +169,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 +243,8 @@ async function saveInfo(): Promise<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 () => {
+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
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