feat(directory) : revamp commercial report tab and polish info tab
- report tab redesigned as a reverse-chronological timeline with type badges/icons, relative dates and author - add/edit moved to a side drawer; body now uses the rich text editor (MalioInputRichText), displayed read-only as inline prose - delete now asks for confirmation (ConfirmDeleteReportModal) - empty state with CTA and pluralized count - info tab: use v-model, neutral i18n validation key, real admin flag instead of hardcoded true on CommercialReportTab
This commit is contained in:
@@ -0,0 +1,56 @@
|
|||||||
|
<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"
|
||||||
|
@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
|
||||||
|
}>()
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -948,6 +948,10 @@
|
|||||||
"phone": "Téléphone"
|
"phone": "Téléphone"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"validation": {
|
||||||
|
"nameRequired": "Le nom est requis.",
|
||||||
|
"subjectRequired": "L'objet est requis."
|
||||||
|
},
|
||||||
"clients": {
|
"clients": {
|
||||||
"add": "Ajouter un client",
|
"add": "Ajouter un client",
|
||||||
"empty": "Aucun client trouvé."
|
"empty": "Aucun client trouvé."
|
||||||
@@ -986,9 +990,16 @@
|
|||||||
},
|
},
|
||||||
"reports": {
|
"reports": {
|
||||||
"add": "Ajouter un compte-rendu",
|
"add": "Ajouter un compte-rendu",
|
||||||
"empty": "Aucun compte-rendu.",
|
"addTitle": "Nouveau compte-rendu",
|
||||||
|
"editTitle": "Modifier le compte-rendu",
|
||||||
|
"empty": "Aucun compte-rendu",
|
||||||
|
"emptyHint": "Consignez vos échanges (appels, rendez-vous, emails) pour garder l'historique de la relation.",
|
||||||
|
"count": "{n} compte-rendu | {n} comptes-rendus",
|
||||||
|
"documentsLabel": "Documents",
|
||||||
"saved": "Compte-rendu enregistré.",
|
"saved": "Compte-rendu enregistré.",
|
||||||
"deleted": "Compte-rendu supprimé.",
|
"deleted": "Compte-rendu supprimé.",
|
||||||
|
"confirmDeleteTitle": "Supprimer ce compte-rendu ?",
|
||||||
|
"confirmDeleteMessage": "Cette action est irréversible. Les documents joints seront également supprimés.",
|
||||||
"fields": {
|
"fields": {
|
||||||
"subject": "Objet",
|
"subject": "Objet",
|
||||||
"type": "Type d'échange",
|
"type": "Type d'échange",
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<template>
|
||||||
|
<MalioDrawer v-model="isOpen">
|
||||||
|
<template #header>
|
||||||
|
<h2 class="text-xl font-bold">
|
||||||
|
{{ isEditing ? $t('directory.reports.editTitle') : $t('directory.reports.addTitle') }}
|
||||||
|
</h2>
|
||||||
|
</template>
|
||||||
|
<form @submit.prevent="handleSubmit" class="flex flex-col gap-4">
|
||||||
|
<MalioInputText
|
||||||
|
v-model="form.subject"
|
||||||
|
:label="$t('directory.reports.fields.subject')"
|
||||||
|
input-class="w-full"
|
||||||
|
:error="touched.subject && !form.subject.trim() ? $t('directory.validation.subjectRequired') : ''"
|
||||||
|
@blur="touched.subject = true"
|
||||||
|
/>
|
||||||
|
<MalioSelect
|
||||||
|
v-model="form.type"
|
||||||
|
:label="$t('directory.reports.fields.type')"
|
||||||
|
:options="typeOptions"
|
||||||
|
group-class="w-full"
|
||||||
|
/>
|
||||||
|
<MalioDate
|
||||||
|
v-model="form.occurredAt"
|
||||||
|
:label="$t('directory.reports.fields.occurredAt')"
|
||||||
|
/>
|
||||||
|
<MalioInputRichText
|
||||||
|
v-model="form.body"
|
||||||
|
:label="$t('directory.reports.fields.body')"
|
||||||
|
min-height="180px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-end gap-3">
|
||||||
|
<MalioButton
|
||||||
|
variant="tertiary"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
:label="$t('common.cancel')"
|
||||||
|
@click="isOpen = false"
|
||||||
|
/>
|
||||||
|
<MalioButton
|
||||||
|
button-class="w-auto px-6"
|
||||||
|
:label="$t('common.save')"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="handleSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</MalioDrawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { CommercialReport, CommercialReportWrite, ReportType } from '~/modules/directory/services/dto/commercial-report'
|
||||||
|
import { useCommercialReportService } from '~/modules/directory/services/commercial-reports'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
report: CommercialReport | null
|
||||||
|
owner: { client?: string, prospect?: string }
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:modelValue', value: boolean): void
|
||||||
|
(e: 'saved'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { create, update } = useCommercialReportService()
|
||||||
|
|
||||||
|
const isOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v) => emit('update:modelValue', v),
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEditing = computed(() => !!props.report)
|
||||||
|
const isSubmitting = ref(false)
|
||||||
|
|
||||||
|
const typeOptions: { label: string, value: ReportType }[] = [
|
||||||
|
{ label: t('directory.reports.types.call'), value: 'call' },
|
||||||
|
{ label: t('directory.reports.types.meeting'), value: 'meeting' },
|
||||||
|
{ label: t('directory.reports.types.email'), value: 'email' },
|
||||||
|
{ label: t('directory.reports.types.note'), value: 'note' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function today(): string {
|
||||||
|
return new Date().toISOString().slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'éditeur riche émet du HTML : un contenu « vide » vaut `<p></p>`. On le
|
||||||
|
// normalise en null pour ne pas persister une coquille vide.
|
||||||
|
function normalizeBody(html: string): string | null {
|
||||||
|
const stripped = html.replace(/<[^>]*>/g, '').replace(/ /g, ' ').trim()
|
||||||
|
return stripped ? html : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = reactive<{ subject: string, type: ReportType, occurredAt: string, body: string }>({
|
||||||
|
subject: '',
|
||||||
|
type: 'note',
|
||||||
|
occurredAt: today(),
|
||||||
|
body: '',
|
||||||
|
})
|
||||||
|
const touched = reactive({ subject: false })
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (open) => {
|
||||||
|
if (!open) return
|
||||||
|
if (props.report) {
|
||||||
|
form.subject = props.report.subject
|
||||||
|
form.type = props.report.type
|
||||||
|
form.occurredAt = props.report.occurredAt.slice(0, 10)
|
||||||
|
form.body = props.report.body ?? ''
|
||||||
|
} else {
|
||||||
|
form.subject = ''
|
||||||
|
form.type = 'note'
|
||||||
|
form.occurredAt = today()
|
||||||
|
form.body = ''
|
||||||
|
}
|
||||||
|
touched.subject = false
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleSubmit(): Promise<void> {
|
||||||
|
touched.subject = true
|
||||||
|
if (!form.subject.trim() || isSubmitting.value) return
|
||||||
|
|
||||||
|
isSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const payload: CommercialReportWrite = {
|
||||||
|
subject: form.subject.trim(),
|
||||||
|
type: form.type,
|
||||||
|
occurredAt: form.occurredAt,
|
||||||
|
body: normalizeBody(form.body),
|
||||||
|
...props.owner,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing.value && props.report) {
|
||||||
|
await update(props.report.id, payload)
|
||||||
|
} else {
|
||||||
|
await create(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('saved')
|
||||||
|
isOpen.value = false
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,83 +1,141 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-6 pt-6">
|
<div class="flex flex-col gap-5 pt-6">
|
||||||
<!-- Formulaire d'ajout / édition -->
|
<!-- Barre d'action -->
|
||||||
<div v-if="isAdmin" class="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="flex items-center justify-between gap-3">
|
||||||
<MalioInputText
|
<p class="text-sm text-neutral-500">
|
||||||
class="col-span-2"
|
<span v-if="reports.length">{{ $t('directory.reports.count', { n: reports.length }, reports.length) }}</span>
|
||||||
:label="$t('directory.reports.fields.subject')"
|
</p>
|
||||||
v-model="draft.subject"
|
<MalioButton
|
||||||
|
v-if="isAdmin"
|
||||||
|
icon-name="mdi:plus"
|
||||||
|
icon-position="left"
|
||||||
|
button-class="w-auto px-4"
|
||||||
|
:label="$t('directory.reports.add')"
|
||||||
|
@click="openCreate"
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
|
||||||
:label="$t('directory.reports.fields.type')"
|
|
||||||
v-model="draft.type"
|
|
||||||
:options="typeOptions"
|
|
||||||
group-class="w-full"
|
|
||||||
/>
|
|
||||||
<MalioDate
|
|
||||||
:label="$t('directory.reports.fields.occurredAt')"
|
|
||||||
v-model="draft.occurredAt"
|
|
||||||
/>
|
|
||||||
<MalioInputTextArea
|
|
||||||
class="col-span-2"
|
|
||||||
:label="$t('directory.reports.fields.body')"
|
|
||||||
v-model="draft.body"
|
|
||||||
/>
|
|
||||||
<div class="col-span-2 flex justify-end gap-3">
|
|
||||||
<MalioButton
|
|
||||||
v-if="editingId"
|
|
||||||
variant="secondary"
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
:label="$t('common.cancel')"
|
|
||||||
@click="resetDraft"
|
|
||||||
/>
|
|
||||||
<MalioButton
|
|
||||||
button-class="w-auto px-4"
|
|
||||||
:label="editingId ? $t('common.save') : $t('directory.reports.add')"
|
|
||||||
:disabled="!draft.subject"
|
|
||||||
@click="save"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Liste des comptes-rendus -->
|
<!-- État vide -->
|
||||||
<div v-for="report in reports" :key="report.id" class="rounded border border-neutral-200 p-4">
|
<div
|
||||||
<div class="flex items-start justify-between">
|
v-if="!loading && !reports.length"
|
||||||
<div>
|
class="flex flex-col items-center gap-2 rounded-lg border border-dashed border-neutral-200 bg-neutral-50 px-6 py-12 text-center"
|
||||||
<p class="font-semibold text-neutral-800">{{ report.subject }}</p>
|
>
|
||||||
<p class="text-xs text-neutral-500">
|
<Icon name="mdi:message-text-outline" class="text-4xl text-neutral-300" />
|
||||||
{{ formatDate(report.occurredAt) }} · {{ $t(`directory.reports.types.${report.type}`) }}
|
<p class="font-medium text-neutral-600">{{ $t('directory.reports.empty') }}</p>
|
||||||
<span v-if="report.author"> · {{ report.author.username }}</span>
|
<p class="max-w-sm text-sm text-neutral-400">{{ $t('directory.reports.emptyHint') }}</p>
|
||||||
</p>
|
<MalioButton
|
||||||
</div>
|
v-if="isAdmin"
|
||||||
<div v-if="isAdmin" class="flex gap-2">
|
variant="tertiary"
|
||||||
<MalioButtonIcon icon="mdi:pencil-outline" variant="ghost" :aria-label="$t('common.edit')" @click="edit(report)" />
|
icon-name="mdi:plus"
|
||||||
<MalioButtonIcon icon="mdi:delete-outline" variant="ghost" :aria-label="$t('common.delete')" @click="remove(report.id)" />
|
icon-position="left"
|
||||||
</div>
|
button-class="mt-2 w-auto px-4"
|
||||||
</div>
|
:label="$t('directory.reports.add')"
|
||||||
<p v-if="report.body" class="mt-2 whitespace-pre-wrap text-sm text-neutral-700">{{ report.body }}</p>
|
@click="openCreate"
|
||||||
|
/>
|
||||||
<div class="mt-3 flex flex-col gap-2">
|
|
||||||
<ReportDocumentList
|
|
||||||
:documents="report.documents ?? []"
|
|
||||||
:is-admin="isAdmin"
|
|
||||||
@delete="(id) => removeDocument(report, id)"
|
|
||||||
/>
|
|
||||||
<ReportDocumentUpload
|
|
||||||
v-if="isAdmin"
|
|
||||||
:report-id="report.id"
|
|
||||||
@uploaded="reload"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="!reports.length" class="text-sm text-neutral-400">
|
<!-- Timeline antéchronologique -->
|
||||||
{{ $t('directory.reports.empty') }}
|
<ol v-else class="flex flex-col">
|
||||||
</p>
|
<li
|
||||||
|
v-for="report in sortedReports"
|
||||||
|
:key="report.id"
|
||||||
|
class="relative flex gap-4 pb-6 last:pb-0"
|
||||||
|
>
|
||||||
|
<!-- Rail + pastille de type -->
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<span
|
||||||
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full"
|
||||||
|
:class="typeStyle(report.type).badge"
|
||||||
|
>
|
||||||
|
<Icon :name="typeStyle(report.type).icon" class="text-lg" />
|
||||||
|
</span>
|
||||||
|
<span class="mt-1 w-px grow bg-neutral-200" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Carte -->
|
||||||
|
<div class="flex-1 rounded-lg border border-neutral-200 bg-white p-4 shadow-[0_1px_2px_0_rgba(0,0,0,0.05)]">
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="rounded-full px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="typeStyle(report.type).chip"
|
||||||
|
>
|
||||||
|
{{ $t(`directory.reports.types.${report.type}`) }}
|
||||||
|
</span>
|
||||||
|
<p class="truncate font-semibold text-neutral-800">{{ report.subject }}</p>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-neutral-500">
|
||||||
|
<span :title="absoluteDate(report.occurredAt)">{{ relativeDate(report.occurredAt) }}</span>
|
||||||
|
<span v-if="report.author"> · {{ report.author.username }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="isAdmin" class="flex shrink-0 gap-1">
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:pencil-outline"
|
||||||
|
variant="ghost"
|
||||||
|
:aria-label="$t('common.edit')"
|
||||||
|
@click="openEdit(report)"
|
||||||
|
/>
|
||||||
|
<MalioButtonIcon
|
||||||
|
icon="mdi:delete-outline"
|
||||||
|
variant="ghost"
|
||||||
|
:aria-label="$t('common.delete')"
|
||||||
|
@click="askDelete(report)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MalioInputRichText
|
||||||
|
v-if="report.body"
|
||||||
|
:model-value="report.body"
|
||||||
|
:editable="false"
|
||||||
|
:reserve-message-space="false"
|
||||||
|
editor-class="!border-0 !rounded-none !bg-transparent !p-0 text-sm text-neutral-700"
|
||||||
|
class="mt-2"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Documents joints -->
|
||||||
|
<div
|
||||||
|
v-if="(report.documents?.length ?? 0) || isAdmin"
|
||||||
|
class="mt-3 border-t border-neutral-100 pt-3"
|
||||||
|
>
|
||||||
|
<p class="mb-2 text-xs font-medium uppercase tracking-wide text-neutral-400">
|
||||||
|
{{ $t('directory.reports.documentsLabel') }}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<ReportDocumentList
|
||||||
|
v-if="report.documents?.length"
|
||||||
|
:documents="report.documents"
|
||||||
|
:is-admin="isAdmin"
|
||||||
|
@delete="(docId) => removeDocument(docId)"
|
||||||
|
/>
|
||||||
|
<ReportDocumentUpload
|
||||||
|
v-if="isAdmin"
|
||||||
|
:report-id="report.id"
|
||||||
|
@uploaded="reload"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<CommercialReportDrawer
|
||||||
|
v-model="drawerOpen"
|
||||||
|
:report="editing"
|
||||||
|
:owner="owner"
|
||||||
|
@saved="reload"
|
||||||
|
/>
|
||||||
|
<ConfirmDeleteReportModal
|
||||||
|
v-model="confirmOpen"
|
||||||
|
@confirm="confirmDelete"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { CommercialReport, CommercialReportWrite, ReportType } from '~/modules/directory/services/dto/commercial-report'
|
import type { CommercialReport, ReportType } from '~/modules/directory/services/dto/commercial-report'
|
||||||
import { useCommercialReportService } from '~/modules/directory/services/commercial-reports'
|
import { useCommercialReportService } from '~/modules/directory/services/commercial-reports'
|
||||||
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
|
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
|
||||||
|
|
||||||
@@ -86,73 +144,85 @@ const props = defineProps<{
|
|||||||
isAdmin: boolean
|
isAdmin: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const reportService = useCommercialReportService()
|
const reportService = useCommercialReportService()
|
||||||
const documentService = useReportDocumentService()
|
const documentService = useReportDocumentService()
|
||||||
|
|
||||||
const reports = ref<CommercialReport[]>([])
|
const reports = ref<CommercialReport[]>([])
|
||||||
const editingId = ref<number | null>(null)
|
const loading = ref(true)
|
||||||
|
|
||||||
function emptyDraft(): CommercialReportWrite {
|
const drawerOpen = ref(false)
|
||||||
return {
|
const editing = ref<CommercialReport | null>(null)
|
||||||
subject: '',
|
|
||||||
body: null,
|
const confirmOpen = ref(false)
|
||||||
occurredAt: new Date().toISOString().slice(0, 10),
|
const pendingDelete = ref<CommercialReport | null>(null)
|
||||||
type: 'note',
|
|
||||||
...props.owner,
|
// 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)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const typeStyles: Record<ReportType, { icon: string, badge: string, chip: string }> = {
|
||||||
|
call: { icon: 'mdi:phone-outline', badge: 'bg-emerald-100 text-emerald-700', chip: 'bg-emerald-50 text-emerald-700' },
|
||||||
|
meeting: { icon: 'mdi:account-group-outline', badge: 'bg-violet-100 text-violet-700', chip: 'bg-violet-50 text-violet-700' },
|
||||||
|
email: { icon: 'mdi:email-outline', badge: 'bg-sky-100 text-sky-700', chip: 'bg-sky-50 text-sky-700' },
|
||||||
|
note: { icon: 'mdi:note-text-outline', badge: 'bg-amber-100 text-amber-700', chip: 'bg-amber-50 text-amber-700' },
|
||||||
|
}
|
||||||
|
function typeStyle(type: ReportType) {
|
||||||
|
return typeStyles[type]
|
||||||
}
|
}
|
||||||
const draft = ref<CommercialReportWrite>(emptyDraft())
|
|
||||||
|
|
||||||
const typeOptions: { label: string, value: ReportType }[] = [
|
function startOfDay(d: Date): number {
|
||||||
{ label: t('directory.reports.types.call'), value: 'call' },
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
|
||||||
{ label: t('directory.reports.types.meeting'), value: 'meeting' },
|
}
|
||||||
{ label: t('directory.reports.types.email'), value: 'email' },
|
|
||||||
{ label: t('directory.reports.types.note'), value: 'note' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
function absoluteDate(iso: string): string {
|
||||||
return new Date(iso).toLocaleDateString('fr-FR')
|
return new Date(iso).toLocaleDateString('fr-FR')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Date relative lisible (« aujourd'hui », « il y a 3 jours »…) avec repli sur la
|
||||||
|
// date absolue au-delà d'un an. La date exacte reste disponible en infobulle.
|
||||||
|
function relativeDate(iso: string): string {
|
||||||
|
const diffDays = Math.round((startOfDay(new Date(iso)) - startOfDay(new Date())) / 86400000)
|
||||||
|
const rtf = new Intl.RelativeTimeFormat('fr-FR', { numeric: 'auto' })
|
||||||
|
const abs = Math.abs(diffDays)
|
||||||
|
if (abs < 1) return rtf.format(0, 'day')
|
||||||
|
if (abs < 7) return rtf.format(diffDays, 'day')
|
||||||
|
if (abs < 31) return rtf.format(Math.round(diffDays / 7), 'week')
|
||||||
|
if (abs < 365) return rtf.format(Math.round(diffDays / 30), 'month')
|
||||||
|
return absoluteDate(iso)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate(): void {
|
||||||
|
editing.value = null
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(report: CommercialReport): void {
|
||||||
|
editing.value = report
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function askDelete(report: CommercialReport): void {
|
||||||
|
pendingDelete.value = report
|
||||||
|
confirmOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDelete(): Promise<void> {
|
||||||
|
if (!pendingDelete.value) return
|
||||||
|
await reportService.remove(pendingDelete.value.id)
|
||||||
|
confirmOpen.value = false
|
||||||
|
pendingDelete.value = null
|
||||||
|
await reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeDocument(id: number): Promise<void> {
|
||||||
|
await documentService.remove(id)
|
||||||
|
await reload()
|
||||||
|
}
|
||||||
|
|
||||||
async function reload(): Promise<void> {
|
async function reload(): Promise<void> {
|
||||||
reports.value = await reportService.getByOwner(props.owner)
|
reports.value = await reportService.getByOwner(props.owner)
|
||||||
}
|
loading.value = false
|
||||||
|
|
||||||
function resetDraft(): void {
|
|
||||||
editingId.value = null
|
|
||||||
draft.value = emptyDraft()
|
|
||||||
}
|
|
||||||
|
|
||||||
function edit(report: CommercialReport): void {
|
|
||||||
editingId.value = report.id
|
|
||||||
draft.value = {
|
|
||||||
subject: report.subject,
|
|
||||||
body: report.body,
|
|
||||||
occurredAt: report.occurredAt.slice(0, 10),
|
|
||||||
type: report.type,
|
|
||||||
...props.owner,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save(): Promise<void> {
|
|
||||||
if (editingId.value) {
|
|
||||||
await reportService.update(editingId.value, draft.value)
|
|
||||||
} else {
|
|
||||||
await reportService.create(draft.value)
|
|
||||||
}
|
|
||||||
resetDraft()
|
|
||||||
await reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function remove(id: number): Promise<void> {
|
|
||||||
await reportService.remove(id)
|
|
||||||
await reload()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeDocument(report: CommercialReport, id: number): Promise<void> {
|
|
||||||
await documentService.remove(id)
|
|
||||||
await reload()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(reload)
|
onMounted(reload)
|
||||||
|
|||||||
@@ -12,22 +12,19 @@
|
|||||||
<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="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)]">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-model="info.name"
|
||||||
class="col-span-2"
|
class="col-span-2"
|
||||||
:model-value="info.name"
|
|
||||||
:label="$t('directory.info.fields.name')"
|
:label="$t('directory.info.fields.name')"
|
||||||
:error="infoTouched.name && !info.name.trim() ? $t('prospects.validation.nameRequired') : ''"
|
:error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''"
|
||||||
@update:model-value="info.name = $event"
|
|
||||||
@blur="infoTouched.name = true"
|
@blur="infoTouched.name = true"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
:model-value="info.email"
|
v-model="info.email"
|
||||||
:label="$t('directory.info.fields.email')"
|
:label="$t('directory.info.fields.email')"
|
||||||
@update:model-value="info.email = $event"
|
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
:model-value="info.phone"
|
v-model="info.phone"
|
||||||
:label="$t('directory.info.fields.phone')"
|
:label="$t('directory.info.fields.phone')"
|
||||||
@update:model-value="info.phone = $event"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center pt-2">
|
<div class="flex justify-center pt-2">
|
||||||
@@ -102,7 +99,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #report>
|
<template #report>
|
||||||
<CommercialReportTab :owner="owner" :is-admin="true" />
|
<CommercialReportTab :owner="owner" :is-admin="isAdmin" />
|
||||||
</template>
|
</template>
|
||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
</template>
|
</template>
|
||||||
@@ -140,6 +137,9 @@ const {
|
|||||||
load,
|
load,
|
||||||
} = useDirectoryDetail(owner)
|
} = useDirectoryDetail(owner)
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
|
|
||||||
const client = ref<Client | null>(null)
|
const client = ref<Client | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
|
|||||||
@@ -12,17 +12,15 @@
|
|||||||
<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="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)]">
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-model="info.name"
|
||||||
class="col-span-2"
|
class="col-span-2"
|
||||||
:model-value="info.name"
|
|
||||||
:label="$t('prospects.fields.name')"
|
:label="$t('prospects.fields.name')"
|
||||||
:error="infoTouched.name && !info.name.trim() ? $t('prospects.validation.nameRequired') : ''"
|
:error="infoTouched.name && !info.name.trim() ? $t('prospects.validation.nameRequired') : ''"
|
||||||
@update:model-value="info.name = $event"
|
|
||||||
@blur="infoTouched.name = true"
|
@blur="infoTouched.name = true"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
:model-value="info.company"
|
v-model="info.company"
|
||||||
:label="$t('prospects.fields.company')"
|
:label="$t('prospects.fields.company')"
|
||||||
@update:model-value="info.company = $event"
|
|
||||||
/>
|
/>
|
||||||
<MalioSelect
|
<MalioSelect
|
||||||
v-model="info.status"
|
v-model="info.status"
|
||||||
@@ -31,26 +29,22 @@
|
|||||||
group-class="w-full"
|
group-class="w-full"
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
:model-value="info.email"
|
v-model="info.email"
|
||||||
:label="$t('prospects.fields.email')"
|
:label="$t('prospects.fields.email')"
|
||||||
@update:model-value="info.email = $event"
|
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
:model-value="info.phone"
|
v-model="info.phone"
|
||||||
:label="$t('prospects.fields.phone')"
|
:label="$t('prospects.fields.phone')"
|
||||||
@update:model-value="info.phone = $event"
|
|
||||||
/>
|
/>
|
||||||
<MalioInputText
|
<MalioInputText
|
||||||
|
v-model="info.source"
|
||||||
class="col-span-2"
|
class="col-span-2"
|
||||||
:model-value="info.source"
|
|
||||||
:label="$t('prospects.fields.source')"
|
:label="$t('prospects.fields.source')"
|
||||||
@update:model-value="info.source = $event"
|
|
||||||
/>
|
/>
|
||||||
<MalioInputTextArea
|
<MalioInputTextArea
|
||||||
|
v-model="info.notes"
|
||||||
class="col-span-2"
|
class="col-span-2"
|
||||||
:model-value="info.notes"
|
|
||||||
:label="$t('prospects.fields.notes')"
|
:label="$t('prospects.fields.notes')"
|
||||||
@update:model-value="info.notes = $event"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center pt-2">
|
<div class="flex justify-center pt-2">
|
||||||
@@ -125,7 +119,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #report>
|
<template #report>
|
||||||
<CommercialReportTab :owner="owner" :is-admin="true" />
|
<CommercialReportTab :owner="owner" :is-admin="isAdmin" />
|
||||||
</template>
|
</template>
|
||||||
</MalioTabList>
|
</MalioTabList>
|
||||||
</template>
|
</template>
|
||||||
@@ -163,6 +157,9 @@ const {
|
|||||||
load,
|
load,
|
||||||
} = useDirectoryDetail(owner)
|
} = useDirectoryDetail(owner)
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const isAdmin = computed(() => authStore.user?.roles?.includes('ROLE_ADMIN') ?? false)
|
||||||
|
|
||||||
const prospect = ref<Prospect | null>(null)
|
const prospect = ref<Prospect | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user