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>
|
||||
Reference in New Issue
Block a user