feat(directory) : refonte UI du Répertoire (LST-72) (#27)
Auto Tag Develop / tag (push) Successful in 9s
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
This commit was merged in pull request #27.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user