Migration modular monolith DDD (0.1 → 3.3) (#17)
Auto Tag Develop / tag (push) Successful in 9s
Auto Tag Develop / tag (push) Successful in 9s
## Migration modular monolith DDD — Lesstime (0.1 → 3.3) Cette MR regroupe l'intégralité de la refonte en monolithe modulaire (strangler progressif, additif). Elle remplace les MR stackées de Phase 1 (#12–#16), désormais incluses ici. **Ne pas merger avant validation fonctionnelle** : branche destinée à être testée telle quelle. ### Périmètre — 9 modules sous `src/Module/` | Phase | Module | Contenu | |------|--------|---------| | 0.1 | (socle) | infrastructure modulaire, `ModuleInterface`, mapping Doctrine par module | | 0.2 | (socle front) | auto-détection des layers Nuxt sous `frontend/modules/*` | | 1.1 | **Core** | Identité (User/Auth), Notifications, Notifier | | 1.2 | Core | RBAC fin (permissions `module.resource.action`, sidebar gated) | | 1.3 | Core | Audit log (`#[Auditable]`, listener, provider DBAL) | | 2.1 | **TimeTracking** | TimeEntry + MCP + export | | 2.2 | **ProjectManagement** | cœur métier Projets/Tâches + 38 MCP tools | | 2.3 | **Absence** | demandes, soldes, policies, justificatifs | | 2.4 | **Directory** | Clients (migrés) + **Prospects** (nouveau, conversion → Client) | | 2.5 | **Mail** | intégration IMAP OVH + liens tâches | | 2.6 | **Integration** | Gitea / BookStack / Zimbra / Share | | 3.1 | **Reporting** | rapports transverses (DBAL read-only, 0 import inter-module) | | 3.2 | **ClientPortal** | portail client (ROLE_CLIENT cloisonné, tickets, notifications) | | 3.3 | (finition) | nettoyage legacy — `src/Entity` vide, app 100% modulaire | ### Architecture - Découplage inter-modules par **contrats** (`UserInterface`, `ProjectInterface`, `TaskInterface`, `TaskTagInterface`, `ClientInterface`, `ClientTicketInterface`, `LeaveProfileInterface`) + `resolve_target_entities` 100% modulaire (aucune cible legacy). - Repositories : interface `Domain/Repository` + implémentation `Infrastructure/Doctrine`, bindées. - Reporting en DBAL read-only pur (aucun import d'entité d'un autre module). - Chaque migration de module : déplacement à comportement préservé (API publique et noms d'outils MCP inchangés), migrations **additives** uniquement (zéro destructif). ### Sécurité - ROLE_CLIENT cloisonné : un utilisateur client n'accède qu'à `/portal` et à ses propres tickets (filtrés par `allowedProjects`), interdit sur toute l'API interne. - Correctif : interdiction pour un client de créer un lien vers le partage SMB (upload uniquement). ### QA non-régression (branche reconstruite from scratch) - Migrations from scratch + fixtures : OK. - Compilation dev + prod : OK. - **180 tests PHPUnit verts**, php-cs-fixer clean, ~96 routes, **66 outils MCP** tous sous `App\Module\*`. - Smoke test runtime multi-rôles (admin / ROLE_USER / ROLE_CLIENT) : 44 vérifications HTTP, **0 écart**, cloisonnement client étanche. - Build Nuxt OK, 9 layers, 0 import legacy résiduel. ### Points à arbitrer (hors périmètre de cette migration) - Durcissement MCP/IDOR pré-existant (`userId` explicite sans scoping sur certains tools TimeTracking/Absence/TaskDocument) — ticket dédié recommandé. - Validation fonctionnelle de **Prospect** et **ClientPortal** (conçus depuis les specs disque). - **Harmonisation visuelle Malio finale** (3.3) — finition esthétique inter-modules laissée au PO. --- ## ⚠️ Déploiement / migration des données — à ne pas oublier ### 1. Resynchroniser les séquences PostgreSQL après tout import/restore de dump Si la prod (ou tout environnement) est **montée depuis un dump** (`pg_restore` / `COPY`), les lignes sont chargées avec leurs `id` explicites **sans avancer les séquences** → au premier `INSERT` : `duplicate key value violates unique constraint "..._pkey"` (constaté en local sur `notification`, `task`, `time_entry`…). À lancer **juste après chaque restore/import** : ```sql DO $$ DECLARE r RECORD; maxid BIGINT; seq TEXT; BEGIN FOR r IN SELECT table_name, column_name FROM information_schema.columns WHERE table_schema='public' LOOP seq := pg_get_serial_sequence(quote_ident(r.table_name), r.column_name); IF seq IS NOT NULL THEN EXECUTE format('SELECT COALESCE(MAX(%I),0) FROM %I', r.column_name, r.table_name) INTO maxid; PERFORM setval(seq, GREATEST(maxid,1), maxid > 0); END IF; END LOOP; END $$; ``` > Ne concerne **pas** une prod qui tourne déjà (séquences avancées organiquement) — uniquement le cas restore/import. Idempotent, sans risque. ### 2. Fix dénormalisation des collections typées-contrat (code, inclus dans la branche) Les relations **to-many** typées par une interface `Shared\Domain\Contract\*` (`TimeEntry::tags` → `TaskTagInterface`, `Task::collaborators` → `UserInterface`) étaient **indénormalisables par API Platform** (mono-valué OK via IRI, collection KO) → **tout POST/PATCH portant une telle collection renvoyait 400/500**. Corrigé par un dénormaliseur générique `ContractRelationDenormalizer` (réutilise `resolve_target_entities`, zéro couplage par-entité) + test fonctionnel de non-régression. --------- Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('clients.editClient') : $t('clients.addClient') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
label="Nom"
|
||||
input-class="w-full"
|
||||
:error="touched.name && !form.name.trim() ? 'Le nom est requis' : ''"
|
||||
@blur="touched.name = true"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.email"
|
||||
label="Email"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.phone"
|
||||
label="Téléphone"
|
||||
input-class="w-full"
|
||||
/>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<MalioButton
|
||||
label="Enregistrer"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Client, ClientWrite } from '~/modules/directory/services/dto/client'
|
||||
import { useClientService } from '~/modules/directory/services/clients'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
client: Client | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.client)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
name: false,
|
||||
email: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.client) {
|
||||
form.name = props.client.name ?? ''
|
||||
form.email = props.client.email ?? ''
|
||||
form.phone = props.client.phone ?? ''
|
||||
} else {
|
||||
form.name = ''
|
||||
form.email = ''
|
||||
form.phone = ''
|
||||
}
|
||||
touched.name = false
|
||||
touched.email = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update } = useClientService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.name = true
|
||||
if (!form.name.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: ClientWrite = {
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim() || null,
|
||||
phone: form.phone.trim() || null,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.client) {
|
||||
await update(props.client.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 pt-6">
|
||||
<!-- Formulaire d'ajout / édition -->
|
||||
<div v-if="isAdmin" class="grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
|
||||
<MalioInputText
|
||||
class="col-span-2"
|
||||
:label="$t('directory.reports.fields.subject')"
|
||||
v-model="draft.subject"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<!-- Liste des comptes-rendus -->
|
||||
<div v-for="report in reports" :key="report.id" class="rounded border border-neutral-200 p-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="font-semibold text-neutral-800">{{ report.subject }}</p>
|
||||
<p class="text-xs text-neutral-500">
|
||||
{{ formatDate(report.occurredAt) }} · {{ $t(`directory.reports.types.${report.type}`) }}
|
||||
<span v-if="report.author"> · {{ report.author.username }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="isAdmin" class="flex gap-2">
|
||||
<MalioButtonIcon icon="mdi:pencil-outline" :aria-label="$t('common.edit')" @click="edit(report)" />
|
||||
<MalioButtonIcon icon="mdi:trash-can-outline" button-class="!text-red-600" :aria-label="$t('common.delete')" @click="remove(report.id)" />
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="report.body" class="mt-2 whitespace-pre-wrap text-sm text-neutral-700">{{ report.body }}</p>
|
||||
|
||||
<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>
|
||||
|
||||
<p v-if="!reports.length" class="text-sm text-neutral-400">
|
||||
{{ $t('directory.reports.empty') }}
|
||||
</p>
|
||||
</div>
|
||||
</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'
|
||||
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
|
||||
|
||||
const props = defineProps<{
|
||||
owner: { client?: string, prospect?: string }
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const reportService = useCommercialReportService()
|
||||
const documentService = useReportDocumentService()
|
||||
|
||||
const reports = ref<CommercialReport[]>([])
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
function emptyDraft(): CommercialReportWrite {
|
||||
return {
|
||||
subject: '',
|
||||
body: null,
|
||||
occurredAt: new Date().toISOString().slice(0, 10),
|
||||
type: 'note',
|
||||
...props.owner,
|
||||
}
|
||||
}
|
||||
const draft = ref<CommercialReportWrite>(emptyDraft())
|
||||
|
||||
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 formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('fr-FR')
|
||||
}
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
reports.value = await reportService.getByOwner(props.owner)
|
||||
}
|
||||
|
||||
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)
|
||||
watch(() => props.owner, reload, { deep: true })
|
||||
</script>
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
|
||||
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:trash-can-outline"
|
||||
class="absolute right-2 top-2"
|
||||
button-class="!text-red-600"
|
||||
: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)"
|
||||
/>
|
||||
<MalioInputText
|
||||
class="col-span-2"
|
||||
:label="$t('directory.addresses.fields.street')"
|
||||
:model-value="modelValue.street ?? ''"
|
||||
:readonly="readonly"
|
||||
@update:model-value="update('street', $event)"
|
||||
/>
|
||||
<MalioInputText
|
||||
class="col-span-2"
|
||||
:label="$t('directory.addresses.fields.streetComplement')"
|
||||
:model-value="modelValue.streetComplement ?? ''"
|
||||
:readonly="readonly"
|
||||
@update:model-value="update('streetComplement', $event)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:label="$t('directory.addresses.fields.postalCode')"
|
||||
:model-value="modelValue.postalCode ?? ''"
|
||||
:readonly="readonly"
|
||||
@update:model-value="update('postalCode', $event)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:label="$t('directory.addresses.fields.city')"
|
||||
:model-value="modelValue.city ?? ''"
|
||||
:readonly="readonly"
|
||||
@update:model-value="update('city', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Address } from '~/modules/directory/services/dto/address'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Address
|
||||
title: string
|
||||
removable?: boolean
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: Address]
|
||||
'remove': []
|
||||
}>()
|
||||
|
||||
function update(field: keyof Address, value: string): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-2 gap-x-8 gap-y-3 rounded bg-white p-4 shadow">
|
||||
<h3 class="col-span-2 text-sm font-semibold text-neutral-700">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:trash-can-outline"
|
||||
class="absolute right-2 top-2"
|
||||
button-class="!text-red-600"
|
||||
:aria-label="$t('common.delete')"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<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"
|
||||
@update:model-value="update('email', $event)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:label="$t('directory.contacts.fields.phonePrimary')"
|
||||
:model-value="modelValue.phonePrimary ?? ''"
|
||||
:readonly="readonly"
|
||||
@update:model-value="update('phonePrimary', $event)"
|
||||
/>
|
||||
<MalioInputText
|
||||
:label="$t('directory.contacts.fields.phoneSecondary')"
|
||||
:model-value="modelValue.phoneSecondary ?? ''"
|
||||
:readonly="readonly"
|
||||
@update:model-value="update('phoneSecondary', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Contact } from '~/modules/directory/services/dto/contact'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: Contact
|
||||
title: string
|
||||
removable?: boolean
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: Contact]
|
||||
'remove': []
|
||||
}>()
|
||||
|
||||
function update(field: keyof Contact, value: string): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,191 @@
|
||||
<template>
|
||||
<MalioDrawer v-model="isOpen">
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold">{{ isEditing ? $t('prospects.editProspect') : $t('prospects.addProspect') }}</h2>
|
||||
</template>
|
||||
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
|
||||
<MalioInputText
|
||||
v-model="form.name"
|
||||
:label="$t('prospects.fields.name')"
|
||||
input-class="w-full"
|
||||
:error="touched.name && !form.name.trim() ? $t('prospects.validation.nameRequired') : ''"
|
||||
@blur="touched.name = true"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.company"
|
||||
:label="$t('prospects.fields.company')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.email"
|
||||
:label="$t('prospects.fields.email')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.phone"
|
||||
:label="$t('prospects.fields.phone')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioSelect
|
||||
v-model="form.status"
|
||||
:label="$t('prospects.fields.status')"
|
||||
:options="statusOptions"
|
||||
group-class="w-full"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-model="form.source"
|
||||
:label="$t('prospects.fields.source')"
|
||||
input-class="w-full"
|
||||
/>
|
||||
<MalioInputTextArea
|
||||
v-model="form.notes"
|
||||
:label="$t('prospects.fields.notes')"
|
||||
/>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between gap-2">
|
||||
<MalioButton
|
||||
v-if="isEditing && !isConverted"
|
||||
:label="$t('prospects.convert')"
|
||||
variant="secondary"
|
||||
icon-name="mdi:account-convert"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleConvert"
|
||||
/>
|
||||
<span v-else-if="isConverted" class="text-sm text-green-700">
|
||||
{{ $t('prospects.alreadyConverted') }}
|
||||
</span>
|
||||
<span v-else />
|
||||
<MalioButton
|
||||
:label="$t('common.save')"
|
||||
button-class="w-auto px-6"
|
||||
:disabled="isSubmitting"
|
||||
@click="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</MalioDrawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Prospect, ProspectStatus, ProspectWrite } from '~/modules/directory/services/dto/prospect'
|
||||
import { useProspectService } from '~/modules/directory/services/prospects'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
prospect: Prospect | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'saved'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const isOpen = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v),
|
||||
})
|
||||
|
||||
const isEditing = computed(() => !!props.prospect)
|
||||
const isConverted = computed(() => !!props.prospect?.convertedClient)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const statusOptions = [
|
||||
{ label: t('prospects.status.new'), value: 'new' },
|
||||
{ label: t('prospects.status.contacted'), value: 'contacted' },
|
||||
{ label: t('prospects.status.qualified'), value: 'qualified' },
|
||||
{ label: t('prospects.status.won'), value: 'won' },
|
||||
{ label: t('prospects.status.lost'), value: 'lost' },
|
||||
]
|
||||
|
||||
const form = reactive<{
|
||||
name: string
|
||||
company: string
|
||||
email: string
|
||||
phone: string
|
||||
status: ProspectStatus
|
||||
source: string
|
||||
notes: string
|
||||
}>({
|
||||
name: '',
|
||||
company: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
status: 'new',
|
||||
source: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const touched = reactive({
|
||||
name: false,
|
||||
})
|
||||
|
||||
watch(() => props.modelValue, (open) => {
|
||||
if (open) {
|
||||
if (props.prospect) {
|
||||
form.name = props.prospect.name ?? ''
|
||||
form.company = props.prospect.company ?? ''
|
||||
form.email = props.prospect.email ?? ''
|
||||
form.phone = props.prospect.phone ?? ''
|
||||
form.status = props.prospect.status ?? 'new'
|
||||
form.source = props.prospect.source ?? ''
|
||||
form.notes = props.prospect.notes ?? ''
|
||||
} else {
|
||||
form.name = ''
|
||||
form.company = ''
|
||||
form.email = ''
|
||||
form.phone = ''
|
||||
form.status = 'new'
|
||||
form.source = ''
|
||||
form.notes = ''
|
||||
}
|
||||
touched.name = false
|
||||
}
|
||||
})
|
||||
|
||||
const { create, update, convert } = useProspectService()
|
||||
|
||||
async function handleSubmit() {
|
||||
touched.name = true
|
||||
if (!form.name.trim()) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const payload: ProspectWrite = {
|
||||
name: form.name.trim(),
|
||||
company: form.company.trim() || null,
|
||||
email: form.email.trim() || null,
|
||||
phone: form.phone.trim() || null,
|
||||
status: form.status,
|
||||
source: form.source.trim() || null,
|
||||
notes: form.notes.trim() || null,
|
||||
}
|
||||
|
||||
if (isEditing.value && props.prospect) {
|
||||
await update(props.prospect.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConvert() {
|
||||
if (!props.prospect) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await convert(props.prospect.id)
|
||||
emit('saved')
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<ul v-if="documents.length" class="flex flex-col gap-2">
|
||||
<li
|
||||
v-for="doc in documents"
|
||||
:key="doc.id"
|
||||
class="flex items-center justify-between rounded border border-neutral-200 px-3 py-2"
|
||||
>
|
||||
<a
|
||||
:href="downloadUrl(doc.id)"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="flex items-center gap-2 text-sm text-blue-700 hover:underline"
|
||||
>
|
||||
<Icon name="mdi:file-document-outline" />
|
||||
{{ doc.originalName }}
|
||||
</a>
|
||||
<MalioButtonIcon
|
||||
v-if="isAdmin"
|
||||
icon="mdi:trash-can-outline"
|
||||
button-class="!text-red-600"
|
||||
:aria-label="$t('common.delete')"
|
||||
@click="$emit('delete', doc.id)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="text-sm text-neutral-400">
|
||||
{{ $t('directory.documents.empty') }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ReportDocument } from '~/modules/directory/services/dto/report-document'
|
||||
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
|
||||
|
||||
defineProps<{ documents: ReportDocument[], isAdmin: boolean }>()
|
||||
defineEmits<{ delete: [id: number] }>()
|
||||
|
||||
const { getDownloadUrl } = useReportDocumentService()
|
||||
function downloadUrl(id: number): string {
|
||||
return getDownloadUrl(id)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<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"
|
||||
:label="$t('directory.documents.add')"
|
||||
:disabled="uploading"
|
||||
@click="fileInput?.click()"
|
||||
/>
|
||||
<span v-if="uploading" class="text-sm text-neutral-500">{{ $t('directory.documents.uploading') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useReportDocumentService } from '~/modules/directory/services/report-documents'
|
||||
|
||||
const props = defineProps<{ reportId: number }>()
|
||||
const emit = defineEmits<{ uploaded: [] }>()
|
||||
|
||||
const service = useReportDocumentService()
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
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
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
await service.upload(props.reportId, file)
|
||||
emit('uploaded')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Contact } from '~/modules/directory/services/dto/contact'
|
||||
import type { Address } from '~/modules/directory/services/dto/address'
|
||||
import { useContactService } from '~/modules/directory/services/contacts'
|
||||
import { useAddressService } from '~/modules/directory/services/addresses'
|
||||
|
||||
type Owner = { client?: string, prospect?: string }
|
||||
|
||||
/**
|
||||
* Logique partagée des fiches détail Client/Prospect : gestion des blocs
|
||||
* répétables Contact et Adresse (chargement, ajout, édition par bloc avec
|
||||
* persistance immédiate, suppression). Paramétré par l'IRI du propriétaire
|
||||
* (`{ client }` ou `{ prospect }`), réutilisé tel quel par les deux pages.
|
||||
*/
|
||||
export function useDirectoryDetail(owner: Owner) {
|
||||
const contactService = useContactService()
|
||||
const addressService = useAddressService()
|
||||
|
||||
const contacts = ref<Contact[]>([])
|
||||
const addresses = ref<Address[]>([])
|
||||
|
||||
function emptyContact(): Contact {
|
||||
return { id: 0, firstName: null, lastName: null, jobTitle: null, email: null, phonePrimary: null, phoneSecondary: null, ...owner }
|
||||
}
|
||||
function emptyAddress(): Address {
|
||||
return { id: 0, label: null, street: null, streetComplement: null, postalCode: null, city: null, country: 'FR', ...owner }
|
||||
}
|
||||
|
||||
async function onContactInput(index: number, value: Contact): Promise<void> {
|
||||
contacts.value[index] = value
|
||||
await persistContact(index)
|
||||
}
|
||||
async function persistContact(index: number): Promise<void> {
|
||||
const c = contacts.value[index]
|
||||
if (!c) return
|
||||
const payload = { firstName: c.firstName, lastName: c.lastName, jobTitle: c.jobTitle, email: c.email, phonePrimary: c.phonePrimary, phoneSecondary: c.phoneSecondary, ...owner }
|
||||
if (c.id && c.id > 0) {
|
||||
await contactService.update(c.id, payload)
|
||||
} else if (c.lastName || c.firstName) {
|
||||
const created = await contactService.create(payload)
|
||||
contacts.value[index] = created
|
||||
}
|
||||
}
|
||||
function addContact(): void {
|
||||
contacts.value.push(emptyContact())
|
||||
}
|
||||
async function removeContact(index: number): Promise<void> {
|
||||
const c = contacts.value[index]
|
||||
if (c?.id && c.id > 0) await contactService.remove(c.id)
|
||||
contacts.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function onAddressInput(index: number, value: Address): Promise<void> {
|
||||
addresses.value[index] = value
|
||||
await persistAddress(index)
|
||||
}
|
||||
async function persistAddress(index: number): Promise<void> {
|
||||
const a = addresses.value[index]
|
||||
if (!a) return
|
||||
const payload = { label: a.label, street: a.street, streetComplement: a.streetComplement, postalCode: a.postalCode, city: a.city, country: a.country, ...owner }
|
||||
if (a.id && a.id > 0) {
|
||||
await addressService.update(a.id, payload)
|
||||
} else if (a.street || a.city || a.postalCode) {
|
||||
const created = await addressService.create(payload)
|
||||
addresses.value[index] = created
|
||||
}
|
||||
}
|
||||
function addAddress(): void {
|
||||
addresses.value.push(emptyAddress())
|
||||
}
|
||||
async function removeAddress(index: number): Promise<void> {
|
||||
const a = addresses.value[index]
|
||||
if (a?.id && a.id > 0) await addressService.remove(a.id)
|
||||
addresses.value.splice(index, 1)
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
contacts.value = await contactService.getByOwner(owner)
|
||||
addresses.value = await addressService.getByOwner(owner)
|
||||
}
|
||||
|
||||
return {
|
||||
contacts,
|
||||
addresses,
|
||||
onContactInput,
|
||||
addContact,
|
||||
removeContact,
|
||||
onAddressInput,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
load,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export default defineNuxtConfig({})
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
||||
<h1 class="text-2xl font-bold text-neutral-900">{{ client?.name ?? '…' }}</h1>
|
||||
</div>
|
||||
|
||||
<p v-if="loading">{{ $t('common.loading') }}</p>
|
||||
<template v-else-if="client">
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||
<template #contact>
|
||||
<div class="flex flex-col gap-4 pt-6">
|
||||
<DirectoryContactBlock
|
||||
v-for="(contact, i) in contacts"
|
||||
:key="contact.id || `new-${i}`"
|
||||
:model-value="contact"
|
||||
:title="$t('directory.contacts.item', { n: i + 1 })"
|
||||
:removable="contacts.length > 0"
|
||||
@update:model-value="(v) => onContactInput(i, v)"
|
||||
@remove="removeContact(i)"
|
||||
/>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('directory.contacts.add')"
|
||||
@click="addContact"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #address>
|
||||
<div class="flex flex-col gap-4 pt-6">
|
||||
<DirectoryAddressBlock
|
||||
v-for="(address, i) in addresses"
|
||||
:key="address.id || `new-${i}`"
|
||||
:model-value="address"
|
||||
:title="$t('directory.addresses.item', { n: i + 1 })"
|
||||
:removable="addresses.length > 0"
|
||||
@update:model-value="(v) => onAddressInput(i, v)"
|
||||
@remove="removeAddress(i)"
|
||||
/>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('directory.addresses.add')"
|
||||
@click="addAddress"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #report>
|
||||
<CommercialReportTab :owner="owner" :is-admin="true" />
|
||||
</template>
|
||||
</MalioTabList>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Client } from '~/modules/directory/services/dto/client'
|
||||
import { useClientService } from '~/modules/directory/services/clients'
|
||||
|
||||
definePageMeta({ middleware: ['admin'] })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const id = Number(route.params.id)
|
||||
const ownerIri = `/api/clients/${id}`
|
||||
const owner = { client: ownerIri }
|
||||
|
||||
const clientService = useClientService()
|
||||
const {
|
||||
contacts,
|
||||
addresses,
|
||||
onContactInput,
|
||||
addContact,
|
||||
removeContact,
|
||||
onAddressInput,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
load,
|
||||
} = useDirectoryDetail(owner)
|
||||
|
||||
const client = ref<Client | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const activeTab = ref('contact')
|
||||
const tabs = [
|
||||
{ key: 'contact', label: t('directory.tabs.contact'), icon: 'mdi:account-outline' },
|
||||
{ key: 'address', label: t('directory.tabs.address'), icon: 'mdi:map-marker-outline' },
|
||||
{ key: 'report', label: t('directory.tabs.report'), icon: 'mdi:file-document-outline' },
|
||||
]
|
||||
|
||||
function goBack(): void {
|
||||
router.push('/directory')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
client.value = await clientService.getById(id)
|
||||
await load()
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<h1 class="text-2xl font-bold text-neutral-900">
|
||||
{{ $t('directory.title') }}
|
||||
</h1>
|
||||
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||
<!-- Clients -->
|
||||
<template #clients>
|
||||
<div class="flex min-h-[30rem] flex-col gap-4 pt-10">
|
||||
<div class="flex items-center justify-end">
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('directory.clients.add')"
|
||||
@click="openCreateClient"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioDataTable
|
||||
:columns="clientColumns"
|
||||
:items="clients"
|
||||
:total-items="clients.length"
|
||||
:empty-message="$t('directory.clients.empty')"
|
||||
@row-click="openEditClient"
|
||||
>
|
||||
<template #cell-email="{ item }">
|
||||
{{ (item as Client).email ?? '—' }}
|
||||
</template>
|
||||
<template #cell-phone="{ item }">
|
||||
{{ (item as Client).phone ?? '—' }}
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 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">
|
||||
<MalioSelect
|
||||
v-model="statusFilter"
|
||||
:label="$t('prospects.fields.status')"
|
||||
:options="statusOptions"
|
||||
:empty-option-label="$t('directory.prospects.allStatuses')"
|
||||
group-class="w-48"
|
||||
/>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('directory.prospects.add')"
|
||||
@click="openCreateProspect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MalioDataTable
|
||||
:columns="prospectColumns"
|
||||
:items="prospectRows"
|
||||
:total-items="prospectRows.length"
|
||||
:empty-message="$t('directory.prospects.empty')"
|
||||
@row-click="openEditProspect"
|
||||
>
|
||||
<template #cell-status="{ item }">
|
||||
<StatusBadge
|
||||
:label="statusLabel((item as ProspectRow).status)"
|
||||
:variant="statusVariant((item as ProspectRow).status)"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-email="{ item }">
|
||||
{{ (item as ProspectRow).email ?? '—' }}
|
||||
</template>
|
||||
<template #cell-phone="{ item }">
|
||||
{{ (item as ProspectRow).phone ?? '—' }}
|
||||
</template>
|
||||
<template #cell-actions="{ item }">
|
||||
<div
|
||||
v-if="!(item as ProspectRow).convertedClient"
|
||||
class="flex justify-end"
|
||||
@click.stop
|
||||
>
|
||||
<MalioButtonIcon
|
||||
icon="mdi:account-convert"
|
||||
:aria-label="$t('prospects.convert')"
|
||||
button-class="!bg-green-100 !text-green-700"
|
||||
:icon-size="18"
|
||||
@click="convertProspect(item as ProspectRow)"
|
||||
/>
|
||||
</div>
|
||||
<span v-else class="text-neutral-300">—</span>
|
||||
</template>
|
||||
</MalioDataTable>
|
||||
</div>
|
||||
</template>
|
||||
</MalioTabList>
|
||||
|
||||
<ClientDrawer
|
||||
v-model="clientDrawerOpen"
|
||||
:client="selectedClient"
|
||||
@saved="loadClients"
|
||||
/>
|
||||
<ProspectDrawer
|
||||
v-model="prospectDrawerOpen"
|
||||
:prospect="selectedProspect"
|
||||
@saved="onProspectSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Client } from '~/modules/directory/services/dto/client'
|
||||
import { useClientService } from '~/modules/directory/services/clients'
|
||||
import type { Prospect, ProspectStatus } from '~/modules/directory/services/dto/prospect'
|
||||
import { useProspectService } from '~/modules/directory/services/prospects'
|
||||
|
||||
definePageMeta({ middleware: ['admin'] })
|
||||
|
||||
type ProspectRow = Prospect
|
||||
|
||||
const { t } = useI18n()
|
||||
useHead({ title: t('directory.title') })
|
||||
|
||||
const clientService = useClientService()
|
||||
const prospectService = useProspectService()
|
||||
|
||||
const activeTab = ref('clients')
|
||||
const tabs = [
|
||||
{ key: 'clients', label: t('directory.tabs.clients'), icon: 'mdi:account-tie-outline' },
|
||||
{ key: 'prospects', label: t('directory.tabs.prospects'), icon: 'mdi:account-search-outline' },
|
||||
]
|
||||
|
||||
// --- Clients ---
|
||||
const clients = ref<Client[]>([])
|
||||
const clientDrawerOpen = ref(false)
|
||||
const selectedClient = ref<Client | null>(null)
|
||||
|
||||
const clientColumns = [
|
||||
{ key: 'name', label: t('prospects.fields.name') },
|
||||
{ key: 'email', label: t('prospects.fields.email') },
|
||||
{ key: 'phone', label: t('prospects.fields.phone') },
|
||||
]
|
||||
|
||||
async function loadClients() {
|
||||
clients.value = await clientService.getAll()
|
||||
}
|
||||
|
||||
function openCreateClient() {
|
||||
selectedClient.value = null
|
||||
clientDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEditClient(item: Record<string, unknown>) {
|
||||
navigateTo(`/directory/clients/${(item as Client).id}`)
|
||||
}
|
||||
|
||||
// --- Prospects ---
|
||||
const prospects = ref<Prospect[]>([])
|
||||
const prospectDrawerOpen = ref(false)
|
||||
const selectedProspect = ref<Prospect | null>(null)
|
||||
const statusFilter = ref<ProspectStatus | null>(null)
|
||||
|
||||
const statusOptions = [
|
||||
{ label: t('prospects.status.new'), value: 'new' },
|
||||
{ label: t('prospects.status.contacted'), value: 'contacted' },
|
||||
{ label: t('prospects.status.qualified'), value: 'qualified' },
|
||||
{ label: t('prospects.status.won'), value: 'won' },
|
||||
{ label: t('prospects.status.lost'), value: 'lost' },
|
||||
]
|
||||
|
||||
const prospectColumns = [
|
||||
{ key: 'name', label: t('prospects.fields.name') },
|
||||
{ key: 'company', label: t('prospects.fields.company') },
|
||||
{ key: 'status', label: t('prospects.fields.status') },
|
||||
{ key: 'email', label: t('prospects.fields.email') },
|
||||
{ key: 'phone', label: t('prospects.fields.phone') },
|
||||
{ key: 'actions', label: '' },
|
||||
]
|
||||
|
||||
const prospectRows = computed<ProspectRow[]>(() => prospects.value)
|
||||
|
||||
function statusLabel(status: ProspectStatus): string {
|
||||
return t(`prospects.status.${status}`)
|
||||
}
|
||||
|
||||
function statusVariant(status: ProspectStatus): 'neutral' | 'info' | 'success' | 'warning' | 'danger' {
|
||||
switch (status) {
|
||||
case 'new':
|
||||
return 'info'
|
||||
case 'contacted':
|
||||
return 'warning'
|
||||
case 'qualified':
|
||||
return 'neutral'
|
||||
case 'won':
|
||||
return 'success'
|
||||
case 'lost':
|
||||
return 'danger'
|
||||
default:
|
||||
return 'neutral'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProspects() {
|
||||
prospects.value = await prospectService.getAll(statusFilter.value ?? undefined)
|
||||
}
|
||||
|
||||
function openCreateProspect() {
|
||||
selectedProspect.value = null
|
||||
prospectDrawerOpen.value = true
|
||||
}
|
||||
|
||||
function openEditProspect(item: Record<string, unknown>) {
|
||||
navigateTo(`/directory/prospects/${(item as Prospect).id}`)
|
||||
}
|
||||
|
||||
async function convertProspect(row: ProspectRow) {
|
||||
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()])
|
||||
}
|
||||
|
||||
// Le ProspectDrawer porte aussi le bouton « Convertir » : son event 'saved' peut
|
||||
// donc être une conversion → toujours rafraîchir les deux listes par sécurité.
|
||||
async function onProspectSaved() {
|
||||
await Promise.all([loadProspects(), loadClients()])
|
||||
}
|
||||
|
||||
watch(statusFilter, loadProspects)
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadClients(), loadProspects()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* MalioTabList (lib) : aère les onglets verticalement (espace haut/bas du texte) */
|
||||
:deep([role="tab"]) {
|
||||
padding-top: 0.9rem;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex items-center gap-3 pt-4">
|
||||
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
|
||||
<h1 class="text-2xl font-bold text-neutral-900">{{ prospect?.name ?? '…' }}</h1>
|
||||
</div>
|
||||
|
||||
<p v-if="loading">{{ $t('common.loading') }}</p>
|
||||
<template v-else-if="prospect">
|
||||
<MalioTabList v-model="activeTab" :tabs="tabs">
|
||||
<template #contact>
|
||||
<div class="flex flex-col gap-4 pt-6">
|
||||
<DirectoryContactBlock
|
||||
v-for="(contact, i) in contacts"
|
||||
:key="contact.id || `new-${i}`"
|
||||
:model-value="contact"
|
||||
:title="$t('directory.contacts.item', { n: i + 1 })"
|
||||
:removable="contacts.length > 0"
|
||||
@update:model-value="(v) => onContactInput(i, v)"
|
||||
@remove="removeContact(i)"
|
||||
/>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('directory.contacts.add')"
|
||||
@click="addContact"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #address>
|
||||
<div class="flex flex-col gap-4 pt-6">
|
||||
<DirectoryAddressBlock
|
||||
v-for="(address, i) in addresses"
|
||||
:key="address.id || `new-${i}`"
|
||||
:model-value="address"
|
||||
:title="$t('directory.addresses.item', { n: i + 1 })"
|
||||
:removable="addresses.length > 0"
|
||||
@update:model-value="(v) => onAddressInput(i, v)"
|
||||
@remove="removeAddress(i)"
|
||||
/>
|
||||
<MalioButton
|
||||
icon-name="mdi:plus"
|
||||
icon-position="left"
|
||||
button-class="w-auto px-4"
|
||||
:label="$t('directory.addresses.add')"
|
||||
@click="addAddress"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #report>
|
||||
<CommercialReportTab :owner="owner" :is-admin="true" />
|
||||
</template>
|
||||
</MalioTabList>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Prospect } from '~/modules/directory/services/dto/prospect'
|
||||
import { useProspectService } from '~/modules/directory/services/prospects'
|
||||
|
||||
definePageMeta({ middleware: ['admin'] })
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const id = Number(route.params.id)
|
||||
const ownerIri = `/api/prospects/${id}`
|
||||
const owner = { prospect: ownerIri }
|
||||
|
||||
const prospectService = useProspectService()
|
||||
const {
|
||||
contacts,
|
||||
addresses,
|
||||
onContactInput,
|
||||
addContact,
|
||||
removeContact,
|
||||
onAddressInput,
|
||||
addAddress,
|
||||
removeAddress,
|
||||
load,
|
||||
} = useDirectoryDetail(owner)
|
||||
|
||||
const prospect = ref<Prospect | null>(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const activeTab = ref('contact')
|
||||
const tabs = [
|
||||
{ key: 'contact', label: t('directory.tabs.contact'), icon: 'mdi:account-outline' },
|
||||
{ key: 'address', label: t('directory.tabs.address'), icon: 'mdi:map-marker-outline' },
|
||||
{ key: 'report', label: t('directory.tabs.report'), icon: 'mdi:file-document-outline' },
|
||||
]
|
||||
|
||||
function goBack(): void {
|
||||
router.push('/directory')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
prospect.value = await prospectService.getById(id)
|
||||
await load()
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Address, AddressWrite } from './dto/address'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
type Owner = { client?: string, prospect?: string }
|
||||
|
||||
export function useAddressService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getByOwner(owner: Owner): Promise<Address[]> {
|
||||
const data = await api.get<HydraCollection<Address>>('/addresses', owner as Record<string, unknown>)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function create(payload: AddressWrite): Promise<Address> {
|
||||
return api.post<Address>('/addresses', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'directory.addresses.saved',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<AddressWrite>): Promise<Address> {
|
||||
return api.patch<Address>(`/addresses/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'directory.addresses.saved',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/addresses/${id}`, {}, { toastSuccessKey: 'directory.addresses.deleted' })
|
||||
}
|
||||
|
||||
return { getByOwner, create, update, remove }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Client, ClientWrite } from './dto/client'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useClientService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(): Promise<Client[]> {
|
||||
const data = await api.get<HydraCollection<Client>>('/clients')
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getById(id: number): Promise<Client> {
|
||||
return api.get<Client>(`/clients/${id}`)
|
||||
}
|
||||
|
||||
async function create(payload: ClientWrite): Promise<Client> {
|
||||
return api.post<Client>('/clients', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'clients.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<ClientWrite>): Promise<Client> {
|
||||
return api.patch<Client>(`/clients/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'clients.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/clients/${id}`, {}, {
|
||||
toastSuccessKey: 'clients.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, getById, create, update, remove }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { CommercialReport, CommercialReportWrite } from './dto/commercial-report'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
type Owner = { client?: string, prospect?: string }
|
||||
|
||||
export function useCommercialReportService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getByOwner(owner: Owner): Promise<CommercialReport[]> {
|
||||
const data = await api.get<HydraCollection<CommercialReport>>('/commercial_reports', owner as Record<string, unknown>)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function create(payload: CommercialReportWrite): Promise<CommercialReport> {
|
||||
return api.post<CommercialReport>('/commercial_reports', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'directory.reports.saved',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<CommercialReportWrite>): Promise<CommercialReport> {
|
||||
return api.patch<CommercialReport>(`/commercial_reports/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'directory.reports.saved',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/commercial_reports/${id}`, {}, { toastSuccessKey: 'directory.reports.deleted' })
|
||||
}
|
||||
|
||||
return { getByOwner, create, update, remove }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Contact, ContactWrite } from './dto/contact'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
type Owner = { client?: string, prospect?: string }
|
||||
|
||||
export function useContactService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getByOwner(owner: Owner): Promise<Contact[]> {
|
||||
const data = await api.get<HydraCollection<Contact>>('/contacts', owner as Record<string, unknown>)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function create(payload: ContactWrite): Promise<Contact> {
|
||||
return api.post<Contact>('/contacts', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'directory.contacts.saved',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<ContactWrite>): Promise<Contact> {
|
||||
return api.patch<Contact>(`/contacts/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'directory.contacts.saved',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/contacts/${id}`, {}, { toastSuccessKey: 'directory.contacts.deleted' })
|
||||
}
|
||||
|
||||
return { getByOwner, create, update, remove }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type Address = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
label: string | null
|
||||
street: string | null
|
||||
streetComplement: string | null
|
||||
postalCode: string | null
|
||||
city: string | null
|
||||
country: string
|
||||
client?: string | null
|
||||
prospect?: string | null
|
||||
}
|
||||
|
||||
export type AddressWrite = {
|
||||
label: string | null
|
||||
street: string | null
|
||||
streetComplement: string | null
|
||||
postalCode: string | null
|
||||
city: string | null
|
||||
country: string
|
||||
client?: string | null
|
||||
prospect?: string | null
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export type Client = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
name: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
}
|
||||
|
||||
export type ClientWrite = {
|
||||
name: string
|
||||
email: string | null
|
||||
phone: string | null
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { ReportDocument } from './report-document'
|
||||
|
||||
export type ReportType = 'call' | 'meeting' | 'email' | 'note'
|
||||
|
||||
export type CommercialReport = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
subject: string
|
||||
body: string | null
|
||||
occurredAt: string
|
||||
type: ReportType
|
||||
author?: { id: number, username: string } | null
|
||||
client?: string | null
|
||||
prospect?: string | null
|
||||
documents?: ReportDocument[]
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type CommercialReportWrite = {
|
||||
subject: string
|
||||
body: string | null
|
||||
occurredAt: string
|
||||
type: ReportType
|
||||
client?: string | null
|
||||
prospect?: string | null
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type Contact = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
jobTitle: string | null
|
||||
email: string | null
|
||||
phonePrimary: string | null
|
||||
phoneSecondary: string | null
|
||||
client?: string | null
|
||||
prospect?: string | null
|
||||
}
|
||||
|
||||
export type ContactWrite = {
|
||||
firstName: string | null
|
||||
lastName: string | null
|
||||
jobTitle: string | null
|
||||
email: string | null
|
||||
phonePrimary: string | null
|
||||
phoneSecondary: string | null
|
||||
client?: string | null
|
||||
prospect?: string | null
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Client } from './client'
|
||||
|
||||
export type ProspectStatus = 'new' | 'contacted' | 'qualified' | 'won' | 'lost'
|
||||
|
||||
export type Prospect = {
|
||||
id: number
|
||||
'@id'?: string
|
||||
name: string
|
||||
company: string | null
|
||||
email: string | null
|
||||
phone: string | null
|
||||
status: ProspectStatus
|
||||
source: string | null
|
||||
notes: string | null
|
||||
convertedClient: Client | string | null
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type ProspectWrite = {
|
||||
name: string
|
||||
company: string | null
|
||||
email: string | null
|
||||
phone: string | null
|
||||
status: ProspectStatus
|
||||
source: string | null
|
||||
notes: string | null
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { UserData } from '~/services/dto/user-data'
|
||||
|
||||
export type ReportDocument = {
|
||||
'@id'?: string
|
||||
id: number
|
||||
commercialReport: string
|
||||
originalName: string
|
||||
fileName?: string | null
|
||||
mimeType: string
|
||||
size: number
|
||||
createdAt: string
|
||||
uploadedBy?: UserData | null
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Prospect, ProspectStatus, ProspectWrite } from './dto/prospect'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
|
||||
export function useProspectService() {
|
||||
const api = useApi()
|
||||
|
||||
async function getAll(status?: ProspectStatus): Promise<Prospect[]> {
|
||||
const query: Record<string, unknown> = {}
|
||||
if (status) query.status = status
|
||||
const data = await api.get<HydraCollection<Prospect>>('/prospects', query)
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function getById(id: number): Promise<Prospect> {
|
||||
return api.get<Prospect>(`/prospects/${id}`)
|
||||
}
|
||||
|
||||
async function create(payload: ProspectWrite): Promise<Prospect> {
|
||||
return api.post<Prospect>('/prospects', payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'prospects.created',
|
||||
})
|
||||
}
|
||||
|
||||
async function update(id: number, payload: Partial<ProspectWrite>): Promise<Prospect> {
|
||||
return api.patch<Prospect>(`/prospects/${id}`, payload as Record<string, unknown>, {
|
||||
toastSuccessKey: 'prospects.updated',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/prospects/${id}`, {}, {
|
||||
toastSuccessKey: 'prospects.deleted',
|
||||
})
|
||||
}
|
||||
|
||||
async function convert(id: number): Promise<Prospect> {
|
||||
return api.post<Prospect>(`/prospects/${id}/convert`, {}, {
|
||||
toastSuccessKey: 'prospects.converted',
|
||||
})
|
||||
}
|
||||
|
||||
return { getAll, getById, create, update, remove, convert }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ReportDocument } from './dto/report-document'
|
||||
import type { HydraCollection } from '~/utils/api'
|
||||
import { extractHydraMembers } from '~/utils/api'
|
||||
import { $fetch } from 'ofetch'
|
||||
|
||||
export function useReportDocumentService() {
|
||||
const api = useApi()
|
||||
const config = useRuntimeConfig()
|
||||
const baseURL = config.public.apiBase || '/api'
|
||||
|
||||
async function getByReport(reportId: number): Promise<ReportDocument[]> {
|
||||
const data = await api.get<HydraCollection<ReportDocument>>('/report_documents', {
|
||||
commercialReport: `/api/commercial_reports/${reportId}`,
|
||||
})
|
||||
return extractHydraMembers(data)
|
||||
}
|
||||
|
||||
async function upload(reportId: number, file: File): Promise<ReportDocument> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('commercialReport', `/api/commercial_reports/${reportId}`)
|
||||
|
||||
return $fetch<ReportDocument>(`${baseURL}/report_documents`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
credentials: 'include',
|
||||
})
|
||||
}
|
||||
|
||||
async function remove(id: number): Promise<void> {
|
||||
await api.delete(`/report_documents/${id}`, {}, { toastSuccessKey: 'directory.documents.deleted' })
|
||||
}
|
||||
|
||||
function getDownloadUrl(id: number): string {
|
||||
return `${baseURL}/report_documents/${id}/download`
|
||||
}
|
||||
|
||||
return { getByReport, upload, remove, getDownloadUrl }
|
||||
}
|
||||
Reference in New Issue
Block a user