Compare commits

..

11 Commits

Author SHA1 Message Date
Matthieu 435c7fcfc2 fix(directory) : ville absente du select corrigée (option courante conservée) + matching suggestion BAN par libellé
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 38s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m23s
2026-06-24 18:05:16 +02:00
Matthieu 5764d8f472 feat(directory) : type prestataire, validateurs front, autocomplete adresse BAN
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m20s
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m26s
- Prestataire : entité/repo + ressource API Platform (RBAC directory.providers.*),
  ownership prestataire sur contacts/adresses/comptes-rendus (CHECK XOR à 3),
  DTO/service/drawer/fiche détail + onglet dédié dans le répertoire.
- Prospect : société uniquement (suppression du champ name, company requis) ;
  migration de backfill, conversion prospect→client et MCP adaptés.
- Champ site web sur client/prospect/prestataire (entités, DTO, onglet Information, MCP).
- Validateurs front email / téléphone FR (0549200910) / URL sur Information et Contacts,
  enregistrement bloqué tant qu'un champ est invalide.
- Autocomplete adresse branché sur la Base Adresse Nationale (api-adresse.data.gouv.fr)
  avec mode dégradé en saisie libre.
- Administration : retrait de l'onglet Clients.
2026-06-24 17:55:09 +02:00
gitea-actions 052ef55c79 chore: bump version to v0.4.36
Auto Tag Develop / tag (push) Successful in 8s
Build & Push Docker Image / build (push) Successful in 23s
2026-06-24 08:57:34 +00:00
matthieu 302d2c7221 Merge pull request 'fix(absence) : déduire les jours pris du report CP au changement de période' (#22) from fix/absence-cp-carryover into develop
Auto Tag Develop / tag (push) Successful in 9s
Reviewed-on: #22
2026-06-24 08:57:24 +00:00
Matthieu cf3d11a8a3 fix(absence) : déduire les jours pris du report CP au changement de période
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 1m19s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m32s
Au passage d'une période de référence, le report de l'"en cours
d'acquisition" (N) vers l'"acquis" (N-1) ne déduisait pas les jours
déjà pris : un salarié récupérait les CP qu'il avait consommés.

Le report ne porte désormais que les jours non pris. Les congés sont
imputés au plus ancien bucket d'abord (l'acquis N-2, qui expire de toute
façon au changement de période), donc seuls les jours pris au-delà
réduisent le report.

Ajoute AccrueLeaveCommandTest couvrant le report avec jour pris,
l'imputation oldest-first et le report intégral sans jour pris.
2026-06-24 10:52:05 +02:00
gitea-actions b467dbc584 chore: bump version to v0.4.35
Auto Tag Develop / tag (push) Successful in 9s
Build & Push Docker Image / build (push) Successful in 1m48s
2026-06-24 08:13:40 +00:00
matthieu 17a0566f77 Merge pull request 'Directory : onglet Informations éditable + refonte de l'onglet Rapport' (#21) from feat/directory-info-tab into develop
Auto Tag Develop / tag (push) Successful in 8s
Reviewed-on: #21
2026-06-24 08:13:32 +00:00
matthieu 68c3e6fbac Merge branch 'develop' into feat/directory-info-tab
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 41s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 57s
2026-06-24 08:10:10 +00:00
Matthieu 0f14f26fd3 refactor(directory) : gate report actions via RBAC permissions + guard report deletion
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 39s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m0s
- replace hardcoded ROLE_ADMIN check with usePermissions().can('directory.{clients,prospects}.manage')
- rename misleading isAdmin prop to canManage in CommercialReportTab and ReportDocumentList
- add busy guard on delete confirmation modal to prevent duplicate DELETE on double-click
2026-06-24 10:06:25 +02:00
Matthieu 80b2fa5ce6 feat(directory) : revamp commercial report tab and polish info tab
Pull Request — Quality gate / Frontend (build) (pull_request) Successful in 39s
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m31s
- 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
2026-06-24 09:34:58 +02:00
Matthieu 3fe108d38a feat(directory) : add editable Information tab on client/prospect detail
Add an Information tab (first, active by default) to the client and prospect
detail pages so base fields can be edited directly from the record. Client:
name/email/phone. Prospect: name/company/status/email/phone/source/notes.
Fields are edited in memory and persisted only on explicit save (PATCH),
matching the Contact/Address tabs pattern.
2026-06-24 09:07:13 +02:00
53 changed files with 1973 additions and 246 deletions
+1 -1
View File
@@ -1,2 +1,2 @@
parameters: parameters:
app.version: '0.4.34' app.version: '0.4.36'
@@ -0,0 +1,61 @@
<template>
<Teleport v-if="modelValue" to="body">
<Transition name="modal" appear>
<div class="fixed inset-0 z-[70] flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click.stop="cancel" />
<div class="relative z-10 w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-bold text-neutral-900">{{ $t('directory.reports.confirmDeleteTitle') }}</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ $t('directory.reports.confirmDeleteMessage') }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
variant="tertiary"
:label="$t('common.cancel')"
button-class="w-auto px-4"
:disabled="busy"
@click="cancel"
/>
<MalioButton
variant="danger"
:label="$t('common.delete')"
button-class="w-auto px-4"
:disabled="busy"
@click="$emit('confirm')"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const props = defineProps<{
modelValue: boolean
// Suppression en cours : on désactive les actions pour éviter un double envoi.
busy?: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'confirm'): void
}>()
function cancel() {
if (props.busy) return
emit('update:modelValue', false)
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>
+49 -2
View File
@@ -917,6 +917,7 @@
"company": "Société", "company": "Société",
"email": "Email", "email": "Email",
"phone": "Téléphone", "phone": "Téléphone",
"website": "Site web",
"street": "Rue", "street": "Rue",
"city": "Ville", "city": "Ville",
"postalCode": "Code postal", "postalCode": "Code postal",
@@ -932,18 +933,51 @@
"lost": "Perdu" "lost": "Perdu"
}, },
"validation": { "validation": {
"nameRequired": "Le nom est requis" "nameRequired": "Le nom est requis",
"companyRequired": "La société est requise"
}
},
"prestataires": {
"created": "Prestataire créé avec succès.",
"updated": "Prestataire mis à jour avec succès.",
"deleted": "Prestataire supprimé avec succès.",
"addPrestataire": "Ajouter un prestataire",
"editPrestataire": "Modifier un prestataire",
"deleteConfirmTitle": "Supprimer le prestataire",
"deleteConfirmMessage": "Êtes-vous sûr de vouloir supprimer le prestataire « {name} » ? Cette action est irréversible.",
"fields": {
"name": "Nom / Société",
"email": "Email",
"phone": "Téléphone",
"website": "Site web"
} }
}, },
"directory": { "directory": {
"title": "Répertoire", "title": "Répertoire",
"tabs": { "tabs": {
"info": "Informations",
"clients": "Clients", "clients": "Clients",
"prospects": "Prospects", "prospects": "Prospects",
"prestataires": "Prestataires",
"contact": "Contact", "contact": "Contact",
"address": "Adresse", "address": "Adresse",
"report": "Rapport" "report": "Rapport"
}, },
"info": {
"fields": {
"name": "Nom",
"email": "Email",
"phone": "Téléphone",
"website": "Site web"
}
},
"validation": {
"nameRequired": "Le nom est requis.",
"subjectRequired": "L'objet est requis.",
"emailInvalid": "Adresse email invalide.",
"phoneInvalid": "Numéro de téléphone invalide (ex. 0549200910).",
"urlInvalid": "URL invalide (ex. https://exemple.fr)."
},
"clients": { "clients": {
"add": "Ajouter un client", "add": "Ajouter un client",
"empty": "Aucun client trouvé." "empty": "Aucun client trouvé."
@@ -953,6 +987,10 @@
"empty": "Aucun prospect trouvé.", "empty": "Aucun prospect trouvé.",
"allStatuses": "Tous les statuts" "allStatuses": "Tous les statuts"
}, },
"prestataires": {
"add": "Ajouter un prestataire",
"empty": "Aucun prestataire trouvé."
},
"contacts": { "contacts": {
"add": "Ajouter un contact", "add": "Ajouter un contact",
"item": "Contact {n}", "item": "Contact {n}",
@@ -972,6 +1010,8 @@
"item": "Adresse {n}", "item": "Adresse {n}",
"saved": "Adresse enregistrée.", "saved": "Adresse enregistrée.",
"deleted": "Adresse supprimée.", "deleted": "Adresse supprimée.",
"streetNotFound": "Aucune adresse trouvée — saisie libre possible.",
"autocompleteUnavailable": "Recherche d'adresse indisponible : saisissez l'adresse manuellement.",
"fields": { "fields": {
"label": "Libellé", "label": "Libellé",
"street": "Rue", "street": "Rue",
@@ -982,9 +1022,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, prestataire?: 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(/&nbsp;/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,158 +1,235 @@
<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="canManage"
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="canManage"
<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="canManage" 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) || canManage"
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"
:can-manage="canManage"
@delete="(docId) => removeDocument(docId)"
/>
<ReportDocumentUpload
v-if="canManage"
: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"
:busy="deleting"
@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'
const props = defineProps<{ const props = defineProps<{
owner: { client?: string, prospect?: string } owner: { client?: string, prospect?: string, prestataire?: string }
isAdmin: boolean canManage: 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', const deleting = ref(false)
...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]
}
function startOfDay(d: Date): number {
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
}
function absoluteDate(iso: string): string {
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 || deleting.value) return
deleting.value = true
try {
await reportService.remove(pendingDelete.value.id)
confirmOpen.value = false
pendingDelete.value = null
await reload()
} finally {
deleting.value = false
} }
} }
const draft = ref<CommercialReportWrite>(emptyDraft())
const typeOptions: { label: string, value: ReportType }[] = [ async function removeDocument(id: number): Promise<void> {
{ label: t('directory.reports.types.call'), value: 'call' }, await documentService.remove(id)
{ label: t('directory.reports.types.meeting'), value: 'meeting' }, await reload()
{ 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> { 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)
@@ -19,13 +19,33 @@
:readonly="readonly" :readonly="readonly"
@update:model-value="update('label', $event)" @update:model-value="update('label', $event)"
/> />
<MalioInputText
class="col-span-2" <!-- Rue : saisie assistée (BAN) en édition, champ texte en lecture seule.
:label="$t('directory.addresses.fields.street')" allow-create conserve le texte saisi si la BAN ne propose rien
:model-value="modelValue.street ?? ''" (erreur/timeout). Choisir une suggestion remplit rue + CP + ville. -->
:readonly="readonly" <div class="col-span-2">
@update:model-value="update('street', $event)" <MalioInputAutocomplete
/> v-if="!readonly"
:model-value="modelValue.street ?? ''"
:options="addressOptions"
:loading="addressLoading"
:min-search-length="3"
:allow-create="true"
:label="$t('directory.addresses.fields.street')"
:no-results-text="$t('directory.addresses.streetNotFound')"
@update:model-value="(v) => update('street', v === null ? '' : String(v))"
@search="onAddressSearch"
@select="onAddressSelect"
/>
<MalioInputText
v-else
:label="$t('directory.addresses.fields.street')"
:model-value="modelValue.street ?? ''"
:readonly="readonly"
@update:model-value="update('street', $event)"
/>
</div>
<MalioInputText <MalioInputText
class="col-span-2" class="col-span-2"
:label="$t('directory.addresses.fields.streetComplement')" :label="$t('directory.addresses.fields.streetComplement')"
@@ -33,13 +53,27 @@
:readonly="readonly" :readonly="readonly"
@update:model-value="update('streetComplement', $event)" @update:model-value="update('streetComplement', $event)"
/> />
<MalioInputText <MalioInputText
:label="$t('directory.addresses.fields.postalCode')" :label="$t('directory.addresses.fields.postalCode')"
:model-value="modelValue.postalCode ?? ''" :model-value="modelValue.postalCode ?? ''"
:readonly="readonly" :readonly="readonly"
@update:model-value="update('postalCode', $event)" @update:model-value="onPostalCodeInput"
/>
<!-- Ville : select alimenté par le code postal (BAN). En mode dégradé
(BAN indispo) ou lecture seule, on bascule en saisie libre. -->
<MalioSelect
v-if="!readonly && !degraded"
:model-value="modelValue.city ?? ''"
:options="cityOptions"
:label="$t('directory.addresses.fields.city')"
empty-option-label=""
group-class="w-full"
@update:model-value="(v) => update('city', v === null ? '' : String(v))"
/> />
<MalioInputText <MalioInputText
v-else
:label="$t('directory.addresses.fields.city')" :label="$t('directory.addresses.fields.city')"
:model-value="modelValue.city ?? ''" :model-value="modelValue.city ?? ''"
:readonly="readonly" :readonly="readonly"
@@ -50,6 +84,10 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Address } from '~/modules/directory/services/dto/address' import type { Address } from '~/modules/directory/services/dto/address'
import {
useAddressAutocomplete,
type AddressSuggestion,
} from '~/modules/directory/composables/useAddressAutocomplete'
const props = defineProps<{ const props = defineProps<{
modelValue: Address modelValue: Address
@@ -63,7 +101,98 @@ const emit = defineEmits<{
'remove': [] 'remove': []
}>() }>()
const { t } = useI18n()
const toast = useToast()
const autocomplete = useAddressAutocomplete()
type Option = { label: string, value: string | number }
const addressOptions = ref<Option[]>([])
// Villes renvoyées par la BAN pour le code postal courant.
const fetchedCityOptions = ref<Option[]>([])
const addressLoading = ref(false)
// Le select Ville n'affiche que les valeurs présentes dans ses options : on
// garantit donc que la ville déjà enregistrée (chargement d'une fiche) ou
// pré-remplie par l'autocomplétion d'adresse figure toujours dans la liste,
// même avant toute recherche par code postal — sinon elle s'afficherait vide.
const cityOptions = computed<Option[]>(() => {
const current = (props.modelValue.city ?? '').trim()
const options = [...fetchedCityOptions.value]
if (current && !options.some(o => o.value === current)) {
options.unshift({ value: current, label: current })
}
return options
})
// Mode dégradé : BAN indisponible → la ville passe en saisie libre.
const degraded = ref(false)
let lastAddressSuggestions: AddressSuggestion[] = []
let notified = false
function update(field: keyof Address, value: string): void { function update(field: keyof Address, value: string): void {
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value }) emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
} }
// Avertit une seule fois que l'autocomplétion est indisponible (saisie libre).
function notifyUnavailable(): void {
if (notified) return
notified = true
toast.info({ title: '', message: t('directory.addresses.autocompleteUnavailable') })
}
/** Recherche d'adresse assistée (event de MalioInputAutocomplete). */
async function onAddressSearch(query: string): Promise<void> {
if (query.trim().length < 3) {
addressOptions.value = []
return
}
addressLoading.value = true
try {
const postalCode = (props.modelValue.postalCode ?? '').replace(/\D/g, '') || undefined
const suggestions = await autocomplete.searchAddress(query, postalCode)
lastAddressSuggestions = suggestions
addressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
}
catch {
addressOptions.value = []
notifyUnavailable()
}
finally {
addressLoading.value = false
}
}
/** Sélection d'une suggestion → remplit rue + ville + code postal. */
function onAddressSelect(option: Option | null): void {
if (option === null) return
// Matching par `label` (adresse complète, unique côté BAN) plutôt que par
// rue : deux communes peuvent partager le même libellé de voie.
const suggestion = lastAddressSuggestions.find(s => s.label === option.label)
if (!suggestion) {
update('street', String(option.value))
return
}
emit('update:modelValue', {
...props.modelValue,
street: suggestion.street,
city: suggestion.city || props.modelValue.city,
postalCode: suggestion.postalCode || props.modelValue.postalCode,
})
}
/** Saisie du code postal → met à jour le champ + interroge la BAN pour la ville. */
async function onPostalCodeInput(value: string): Promise<void> {
update('postalCode', value)
const digits = (value ?? '').replace(/\D/g, '')
if (digits.length < 5) return
try {
const suggestions = await autocomplete.searchCity(digits)
fetchedCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
degraded.value = false
}
catch {
degraded.value = true
notifyUnavailable()
}
}
</script> </script>
@@ -35,18 +35,21 @@
:label="$t('directory.contacts.fields.email')" :label="$t('directory.contacts.fields.email')"
:model-value="modelValue.email ?? ''" :model-value="modelValue.email ?? ''"
:readonly="readonly" :readonly="readonly"
:error="emailError"
@update:model-value="update('email', $event)" @update:model-value="update('email', $event)"
/> />
<MalioInputText <MalioInputText
:label="$t('directory.contacts.fields.phonePrimary')" :label="$t('directory.contacts.fields.phonePrimary')"
:model-value="modelValue.phonePrimary ?? ''" :model-value="modelValue.phonePrimary ?? ''"
:readonly="readonly" :readonly="readonly"
:error="phonePrimaryError"
@update:model-value="update('phonePrimary', $event)" @update:model-value="update('phonePrimary', $event)"
/> />
<MalioInputText <MalioInputText
:label="$t('directory.contacts.fields.phoneSecondary')" :label="$t('directory.contacts.fields.phoneSecondary')"
:model-value="modelValue.phoneSecondary ?? ''" :model-value="modelValue.phoneSecondary ?? ''"
:readonly="readonly" :readonly="readonly"
:error="phoneSecondaryError"
@update:model-value="update('phoneSecondary', $event)" @update:model-value="update('phoneSecondary', $event)"
/> />
</div> </div>
@@ -54,6 +57,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Contact } from '~/modules/directory/services/dto/contact' import type { Contact } from '~/modules/directory/services/dto/contact'
import { isValidEmail, isValidFrPhone } from '~/modules/directory/utils/validation'
const props = defineProps<{ const props = defineProps<{
modelValue: Contact modelValue: Contact
@@ -67,6 +71,18 @@ const emit = defineEmits<{
'remove': [] 'remove': []
}>() }>()
const { t } = useI18n()
const emailError = computed(() =>
isValidEmail(props.modelValue.email) ? '' : t('directory.validation.emailInvalid'),
)
const phonePrimaryError = computed(() =>
isValidFrPhone(props.modelValue.phonePrimary) ? '' : t('directory.validation.phoneInvalid'),
)
const phoneSecondaryError = computed(() =>
isValidFrPhone(props.modelValue.phoneSecondary) ? '' : t('directory.validation.phoneInvalid'),
)
function update(field: keyof Contact, value: string): void { function update(field: keyof Contact, value: string): void {
emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value }) emit('update:modelValue', { ...props.modelValue, [field]: value === '' ? null : value })
} }
@@ -0,0 +1,88 @@
<template>
<MalioDrawer v-model="isOpen">
<template #header>
<h2 class="text-xl font-bold">{{ isEditing ? $t('prestataires.editPrestataire') : $t('prestataires.addPrestataire') }}</h2>
</template>
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText
v-model="form.name"
:label="$t('prestataires.fields.name')"
input-class="w-full"
:error="touched.name && !form.name.trim() ? $t('directory.validation.nameRequired') : ''"
@blur="touched.name = true"
/>
<div class="mt-6 flex justify-end">
<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 { Prestataire, PrestataireWrite } from '~/modules/directory/services/dto/prestataire'
import { usePrestataireService } from '~/modules/directory/services/prestataires'
const props = defineProps<{
modelValue: boolean
prestataire: Prestataire | 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.prestataire)
const isSubmitting = ref(false)
const form = reactive({
name: '',
})
const touched = reactive({
name: false,
})
watch(() => props.modelValue, (open) => {
if (open) {
form.name = props.prestataire?.name ?? ''
touched.name = false
}
})
const { create, update } = usePrestataireService()
async function handleSubmit() {
touched.name = true
if (!form.name.trim()) return
isSubmitting.value = true
try {
const payload: PrestataireWrite = {
name: form.name.trim(),
}
if (isEditing.value && props.prestataire) {
await update(props.prestataire.id, payload)
} else {
await create(payload)
}
emit('saved')
isOpen.value = false
} finally {
isSubmitting.value = false
}
}
</script>
@@ -5,11 +5,11 @@
</template> </template>
<form @submit.prevent="handleSubmit" class="flex flex-col gap-2"> <form @submit.prevent="handleSubmit" class="flex flex-col gap-2">
<MalioInputText <MalioInputText
v-model="form.name" v-model="form.company"
label="Nom société" :label="$t('prospects.fields.company')"
input-class="w-full" input-class="w-full"
:error="touched.name && !form.name.trim() ? $t('prospects.validation.nameRequired') : ''" :error="touched.company && !form.company.trim() ? $t('prospects.validation.companyRequired') : ''"
@blur="touched.name = true" @blur="touched.company = true"
/> />
<div class="mt-6 flex items-center justify-between gap-2"> <div class="mt-6 flex items-center justify-between gap-2">
@@ -62,30 +62,30 @@ const isConverted = computed(() => !!props.prospect?.convertedClient)
const isSubmitting = ref(false) const isSubmitting = ref(false)
const form = reactive({ const form = reactive({
name: '', company: '',
}) })
const touched = reactive({ const touched = reactive({
name: false, company: false,
}) })
watch(() => props.modelValue, (open) => { watch(() => props.modelValue, (open) => {
if (open) { if (open) {
form.name = props.prospect?.name ?? '' form.company = props.prospect?.company ?? ''
touched.name = false touched.company = false
} }
}) })
const { create, update, convert } = useProspectService() const { create, update, convert } = useProspectService()
async function handleSubmit() { async function handleSubmit() {
touched.name = true touched.company = true
if (!form.name.trim()) return if (!form.company.trim()) return
isSubmitting.value = true isSubmitting.value = true
try { try {
const payload: ProspectWrite = { const payload: ProspectWrite = {
name: form.name.trim(), company: form.company.trim(),
} }
if (isEditing.value && props.prospect) { if (isEditing.value && props.prospect) {
@@ -15,7 +15,7 @@
{{ doc.originalName }} {{ doc.originalName }}
</a> </a>
<MalioButtonIcon <MalioButtonIcon
v-if="isAdmin" v-if="canManage"
icon="mdi:trash-can-outline" icon="mdi:trash-can-outline"
button-class="!text-red-600" button-class="!text-red-600"
:aria-label="$t('common.delete')" :aria-label="$t('common.delete')"
@@ -32,7 +32,7 @@
import type { ReportDocument } from '~/modules/directory/services/dto/report-document' import type { ReportDocument } from '~/modules/directory/services/dto/report-document'
import { useReportDocumentService } from '~/modules/directory/services/report-documents' import { useReportDocumentService } from '~/modules/directory/services/report-documents'
defineProps<{ documents: ReportDocument[], isAdmin: boolean }>() defineProps<{ documents: ReportDocument[], canManage: boolean }>()
defineEmits<{ delete: [id: number] }>() defineEmits<{ delete: [id: number] }>()
const { getDownloadUrl } = useReportDocumentService() const { getDownloadUrl } = useReportDocumentService()
@@ -0,0 +1,113 @@
import { httpExternal } from '~/utils/httpExternal'
// Autocomplétion d'adresse branchée sur la Base Adresse Nationale (BAN),
// `api-adresse.data.gouv.fr` — service public français, gratuit, CORS ouvert.
//
// Appel HTTP DIRECT depuis le front (pas de proxy back) : la BAN est un domaine
// externe, sans cookie de session ni enveloppe Hydra → on passe par
// `httpExternal` et NON `useApi()`.
//
// Contrat :
// searchCity(postalCode) -> liste { city, postalCode }
// searchAddress(query, cp?) -> liste { label, street, postalCode, city }
// En cas d'erreur/timeout, la méthode THROW une
// AddressAutocompleteUnavailableError. Le composant consommateur catch,
// avertit l'utilisateur et bascule en saisie libre.
/** URL de l'endpoint de recherche BAN. */
const BAN_SEARCH_URL = 'https://api-adresse.data.gouv.fr/search/'
/** Une suggestion de ville renvoyée à partir d'un code postal. */
export interface CitySuggestion {
city: string
postalCode: string
}
/** Une suggestion d'adresse complète (saisie assistée du champ « Rue »). */
export interface AddressSuggestion {
label: string
street: string
postalCode: string
city: string
}
export interface AddressAutocomplete {
searchCity(postalCode: string): Promise<CitySuggestion[]>
searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]>
}
/** Erreur signalant que le service d'autocomplétion BAN n'est pas disponible. */
export class AddressAutocompleteUnavailableError extends Error {
constructor() {
super('Address autocomplete (BAN) is not available.')
this.name = 'AddressAutocompleteUnavailableError'
}
}
/** Propriétés d'une « feature » GeoJSON renvoyée par la BAN (champs utilisés). */
interface BanFeatureProperties {
label?: string
name?: string
street?: string
postcode?: string
city?: string
}
/** Réponse GeoJSON FeatureCollection de la BAN. */
interface BanResponse {
features?: { properties?: BanFeatureProperties }[]
}
export function useAddressAutocomplete(): AddressAutocomplete {
return {
async searchCity(postalCode: string): Promise<CitySuggestion[]> {
let res: BanResponse
try {
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, {
query: { q: postalCode, type: 'municipality' },
})
}
catch {
throw new AddressAutocompleteUnavailableError()
}
return (res.features ?? []).map((feature) => {
const props = feature.properties ?? {}
return {
city: props.city ?? props.name ?? '',
postalCode: props.postcode ?? '',
}
})
},
async searchAddress(query: string, postalCode?: string): Promise<AddressSuggestion[]> {
// Pas de `type=housenumber` ici : sans filtre, la BAN classe rues +
// numéros par pertinence (comportement d'autocomplétion attendu).
// On n'ajoute `postcode` que s'il est fourni (sinon recherche large).
const banQuery: Record<string, string> = { q: query }
if (postalCode) {
banQuery.postcode = postalCode
}
let res: BanResponse
try {
res = await httpExternal<BanResponse>(BAN_SEARCH_URL, { query: banQuery })
}
catch {
throw new AddressAutocompleteUnavailableError()
}
return (res.features ?? []).map((feature) => {
const props = feature.properties ?? {}
return {
label: props.label ?? '',
// `name` porte la ligne d'adresse complète (numéro + voie) ;
// `street` ne contient que la voie. On privilégie `name`.
street: props.name ?? props.street ?? '',
postalCode: props.postcode ?? '',
city: props.city ?? '',
}
})
},
}
}
@@ -3,7 +3,7 @@ import type { Address } from '~/modules/directory/services/dto/address'
import { useContactService } from '~/modules/directory/services/contacts' import { useContactService } from '~/modules/directory/services/contacts'
import { useAddressService } from '~/modules/directory/services/addresses' import { useAddressService } from '~/modules/directory/services/addresses'
type Owner = { client?: string, prospect?: string } type Owner = { client?: string, prospect?: string, prestataire?: string }
/** /**
* Logique partagée des fiches détail Client/Prospect : blocs répétables Contact * Logique partagée des fiches détail Client/Prospect : blocs répétables Contact
@@ -8,6 +8,44 @@
<p v-if="loading">{{ $t('common.loading') }}</p> <p v-if="loading">{{ $t('common.loading') }}</p>
<template v-else-if="client"> <template v-else-if="client">
<MalioTabList v-model="activeTab" :tabs="tabs"> <MalioTabList v-model="activeTab" :tabs="tabs">
<template #info>
<div class="flex flex-col gap-4 pt-6">
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
<MalioInputText
v-model="info.name"
class="col-span-2"
:label="$t('directory.info.fields.name')"
:error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''"
@blur="infoTouched.name = true"
/>
<MalioInputText
v-model="info.email"
:label="$t('directory.info.fields.email')"
:error="emailError"
/>
<MalioInputText
v-model="info.phone"
:label="$t('directory.info.fields.phone')"
:error="phoneError"
/>
<MalioInputText
v-model="info.website"
class="col-span-2"
:label="$t('directory.info.fields.website')"
:error="websiteError"
/>
</div>
<div class="flex justify-center pt-2">
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingInfo || !infoValid"
@click="saveInfo"
/>
</div>
</div>
</template>
<template #contact> <template #contact>
<div class="flex flex-col gap-4 pt-6"> <div class="flex flex-col gap-4 pt-6">
<DirectoryContactBlock <DirectoryContactBlock
@@ -69,7 +107,7 @@
</template> </template>
<template #report> <template #report>
<CommercialReportTab :owner="owner" :is-admin="true" /> <CommercialReportTab :owner="owner" :can-manage="canManage" />
</template> </template>
</MalioTabList> </MalioTabList>
</template> </template>
@@ -79,6 +117,7 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Client } from '~/modules/directory/services/dto/client' import type { Client } from '~/modules/directory/services/dto/client'
import { useClientService } from '~/modules/directory/services/clients' import { useClientService } from '~/modules/directory/services/clients'
import { isValidEmail, isValidFrPhone, isValidUrl } from '~/modules/directory/utils/validation'
definePageMeta({ middleware: ['admin'] }) definePageMeta({ middleware: ['admin'] })
@@ -107,22 +146,57 @@ const {
load, load,
} = useDirectoryDetail(owner) } = useDirectoryDetail(owner)
const { can } = usePermissions()
const canManage = computed(() => can('directory.clients.manage'))
const client = ref<Client | null>(null) const client = ref<Client | null>(null)
const loading = ref(true) const loading = ref(true)
const activeTab = ref('contact') const activeTab = ref('info')
const tabs = [ const tabs = [
{ key: 'info', label: t('directory.tabs.info'), icon: 'mdi:information-outline' },
{ key: 'contact', label: t('directory.tabs.contact'), icon: 'mdi:account-outline' }, { key: 'contact', label: t('directory.tabs.contact'), icon: 'mdi:account-outline' },
{ key: 'address', label: t('directory.tabs.address'), icon: 'mdi:map-marker-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' }, { key: 'report', label: t('directory.tabs.report'), icon: 'mdi:file-document-outline' },
] ]
// Champs de base de la fiche, édités en mémoire et persistés au clic sur
// « Enregistrer » (PATCH), comme les onglets Contact/Adresse.
const info = reactive({ name: '', email: '', phone: '', website: '' })
const infoTouched = reactive({ name: false })
const savingInfo = ref(false)
const emailError = computed(() => (isValidEmail(info.email) ? '' : t('directory.validation.emailInvalid')))
const phoneError = computed(() => (isValidFrPhone(info.phone) ? '' : t('directory.validation.phoneInvalid')))
const websiteError = computed(() => (isValidUrl(info.website) ? '' : t('directory.validation.urlInvalid')))
const infoValid = computed(() => !emailError.value && !phoneError.value && !websiteError.value)
async function saveInfo(): Promise<void> {
infoTouched.name = true
if (!info.name.trim() || !infoValid.value || savingInfo.value) return
savingInfo.value = true
try {
client.value = await clientService.update(id, {
name: info.name.trim(),
email: info.email.trim() || null,
phone: info.phone.trim() || null,
website: info.website.trim() || null,
})
} finally {
savingInfo.value = false
}
}
function goBack(): void { function goBack(): void {
router.push('/directory') router.push('/directory')
} }
onMounted(async () => { onMounted(async () => {
client.value = await clientService.getById(id) client.value = await clientService.getById(id)
info.name = client.value.name ?? ''
info.email = client.value.email ?? ''
info.phone = client.value.phone ?? ''
info.website = client.value.website ?? ''
await load() await load()
loading.value = false loading.value = false
}) })
@@ -107,6 +107,46 @@
</MalioDataTable> </MalioDataTable>
</div> </div>
</template> </template>
<!-- Prestataires -->
<template #prestataires>
<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.prestataires.add')"
@click="openCreatePrestataire"
/>
</div>
<MalioDataTable
:columns="prestataireColumns"
:items="prestataires"
:total-items="prestataires.length"
:empty-message="$t('directory.prestataires.empty')"
@row-click="openEditPrestataire"
>
<template #cell-email="{ item }">
{{ (item as Prestataire).email ?? '—' }}
</template>
<template #cell-phone="{ item }">
{{ (item as Prestataire).phone ?? '—' }}
</template>
<template #cell-actions="{ item }">
<div class="flex justify-end" @click.stop>
<MalioButtonIcon
icon="mdi:trash-can-outline"
:aria-label="$t('common.delete')"
button-class="!bg-red-100 !text-red-700"
:icon-size="18"
@click="askDeletePrestataire(item as Prestataire)"
/>
</div>
</template>
</MalioDataTable>
</div>
</template>
</MalioTabList> </MalioTabList>
<ClientDrawer <ClientDrawer
@@ -119,6 +159,11 @@
:prospect="selectedProspect" :prospect="selectedProspect"
@saved="onProspectSaved" @saved="onProspectSaved"
/> />
<PrestataireDrawer
v-model="prestataireDrawerOpen"
:prestataire="selectedPrestataire"
@saved="loadPrestataires"
/>
<ConfirmDeleteModal <ConfirmDeleteModal
v-model="deleteModalOpen" v-model="deleteModalOpen"
@@ -134,6 +179,8 @@ import type { Client } from '~/modules/directory/services/dto/client'
import { useClientService } from '~/modules/directory/services/clients' import { useClientService } from '~/modules/directory/services/clients'
import type { Prospect, ProspectStatus } from '~/modules/directory/services/dto/prospect' import type { Prospect, ProspectStatus } from '~/modules/directory/services/dto/prospect'
import { useProspectService } from '~/modules/directory/services/prospects' import { useProspectService } from '~/modules/directory/services/prospects'
import type { Prestataire } from '~/modules/directory/services/dto/prestataire'
import { usePrestataireService } from '~/modules/directory/services/prestataires'
definePageMeta({ middleware: ['admin'] }) definePageMeta({ middleware: ['admin'] })
@@ -144,11 +191,13 @@ useHead({ title: t('directory.title') })
const clientService = useClientService() const clientService = useClientService()
const prospectService = useProspectService() const prospectService = useProspectService()
const prestataireService = usePrestataireService()
const activeTab = ref('clients') const activeTab = ref('clients')
const tabs = [ const tabs = [
{ key: 'clients', label: t('directory.tabs.clients'), icon: 'mdi:account-tie-outline' }, { key: 'clients', label: t('directory.tabs.clients'), icon: 'mdi:account-tie-outline' },
{ key: 'prospects', label: t('directory.tabs.prospects'), icon: 'mdi:account-search-outline' }, { key: 'prospects', label: t('directory.tabs.prospects'), icon: 'mdi:account-search-outline' },
{ key: 'prestataires', label: t('directory.tabs.prestataires'), icon: 'mdi:account-hard-hat-outline' },
] ]
// --- Clients --- // --- Clients ---
@@ -157,7 +206,7 @@ const clientDrawerOpen = ref(false)
const selectedClient = ref<Client | null>(null) const selectedClient = ref<Client | null>(null)
const clientColumns = [ const clientColumns = [
{ key: 'name', label: t('prospects.fields.name') }, { key: 'name', label: t('prospects.fields.company') },
{ key: 'email', label: t('prospects.fields.email') }, { key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') }, { key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: '' }, { key: 'actions', label: '' },
@@ -191,7 +240,6 @@ const statusOptions = [
] ]
const prospectColumns = [ const prospectColumns = [
{ key: 'name', label: t('prospects.fields.name') },
{ key: 'company', label: t('prospects.fields.company') }, { key: 'company', label: t('prospects.fields.company') },
{ key: 'status', label: t('prospects.fields.status') }, { key: 'status', label: t('prospects.fields.status') },
{ key: 'email', label: t('prospects.fields.email') }, { key: 'email', label: t('prospects.fields.email') },
@@ -247,26 +295,62 @@ async function onProspectSaved() {
await Promise.all([loadProspects(), loadClients()]) await Promise.all([loadProspects(), loadClients()])
} }
// --- Suppression (clients & prospects) --- // --- Prestataires ---
const prestataires = ref<Prestataire[]>([])
const prestataireDrawerOpen = ref(false)
const selectedPrestataire = ref<Prestataire | null>(null)
const prestataireColumns = [
{ key: 'name', label: t('prospects.fields.company') },
{ key: 'email', label: t('prospects.fields.email') },
{ key: 'phone', label: t('prospects.fields.phone') },
{ key: 'actions', label: '' },
]
async function loadPrestataires() {
prestataires.value = await prestataireService.getAll()
}
function openCreatePrestataire() {
selectedPrestataire.value = null
prestataireDrawerOpen.value = true
}
function openEditPrestataire(item: Record<string, unknown>) {
navigateTo(`/directory/prestataires/${(item as Prestataire).id}`)
}
// --- Suppression (clients, prospects & prestataires) ---
type DeleteTarget = type DeleteTarget =
| { type: 'client'; item: Client } | { type: 'client'; item: Client }
| { type: 'prospect'; item: Prospect } | { type: 'prospect'; item: Prospect }
| { type: 'prestataire'; item: Prestataire }
const deleteModalOpen = ref(false) const deleteModalOpen = ref(false)
const deleteTarget = ref<DeleteTarget | null>(null) const deleteTarget = ref<DeleteTarget | null>(null)
const deleteModalTitle = computed(() => const deleteModalTitle = computed(() => {
deleteTarget.value?.type === 'prospect' switch (deleteTarget.value?.type) {
? t('prospects.deleteConfirmTitle') case 'prospect':
: t('clients.deleteConfirmTitle'), return t('prospects.deleteConfirmTitle')
) case 'prestataire':
return t('prestataires.deleteConfirmTitle')
default:
return t('clients.deleteConfirmTitle')
}
})
const deleteModalMessage = computed(() => { const deleteModalMessage = computed(() => {
if (!deleteTarget.value) return '' const target = deleteTarget.value
const name = deleteTarget.value.item.name if (!target) return ''
return deleteTarget.value.type === 'prospect' switch (target.type) {
? t('prospects.deleteConfirmMessage', { name }) case 'prospect':
: t('clients.deleteConfirmMessage', { name }) return t('prospects.deleteConfirmMessage', { name: target.item.company })
case 'prestataire':
return t('prestataires.deleteConfirmMessage', { name: target.item.name })
default:
return t('clients.deleteConfirmMessage', { name: target.item.name })
}
}) })
function askDeleteClient(item: Client) { function askDeleteClient(item: Client) {
@@ -279,6 +363,11 @@ function askDeleteProspect(item: Prospect) {
deleteModalOpen.value = true deleteModalOpen.value = true
} }
function askDeletePrestataire(item: Prestataire) {
deleteTarget.value = { type: 'prestataire', item }
deleteModalOpen.value = true
}
async function confirmDelete() { async function confirmDelete() {
const target = deleteTarget.value const target = deleteTarget.value
if (!target) return if (!target) return
@@ -286,9 +375,12 @@ async function confirmDelete() {
if (target.type === 'client') { if (target.type === 'client') {
await clientService.remove(target.item.id) await clientService.remove(target.item.id)
await loadClients() await loadClients()
} else { } else if (target.type === 'prospect') {
await prospectService.remove(target.item.id) await prospectService.remove(target.item.id)
await loadProspects() await loadProspects()
} else {
await prestataireService.remove(target.item.id)
await loadPrestataires()
} }
deleteModalOpen.value = false deleteModalOpen.value = false
@@ -298,7 +390,7 @@ async function confirmDelete() {
watch(statusFilter, loadProspects) watch(statusFilter, loadProspects)
onMounted(async () => { onMounted(async () => {
await Promise.all([loadClients(), loadProspects()]) await Promise.all([loadClients(), loadProspects(), loadPrestataires()])
}) })
</script> </script>
@@ -0,0 +1,201 @@
<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">{{ prestataire?.name ?? '…' }}</h1>
</div>
<p v-if="loading">{{ $t('common.loading') }}</p>
<template v-else-if="prestataire">
<MalioTabList v-model="activeTab" :tabs="tabs">
<template #info>
<div class="flex flex-col gap-4 pt-6">
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
<MalioInputText
v-model="info.name"
class="col-span-2"
:label="$t('directory.info.fields.name')"
:error="infoTouched.name && !info.name.trim() ? $t('directory.validation.nameRequired') : ''"
@blur="infoTouched.name = true"
/>
<MalioInputText
v-model="info.email"
:label="$t('directory.info.fields.email')"
:error="emailError"
/>
<MalioInputText
v-model="info.phone"
:label="$t('directory.info.fields.phone')"
:error="phoneError"
/>
<MalioInputText
v-model="info.website"
class="col-span-2"
:label="$t('directory.info.fields.website')"
:error="websiteError"
/>
</div>
<div class="flex justify-center pt-2">
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingInfo || !infoValid"
@click="saveInfo"
/>
</div>
</div>
</template>
<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)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.contacts.add')"
@click="addContact"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingContacts"
@click="saveContacts"
/>
</div>
</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)"
/>
<div class="flex justify-center gap-3 pt-2">
<MalioButton
variant="tertiary"
icon-name="mdi:plus"
icon-position="left"
button-class="w-auto px-4"
:label="$t('directory.addresses.add')"
@click="addAddress"
/>
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingAddresses"
@click="saveAddresses"
/>
</div>
</div>
</template>
<template #report>
<CommercialReportTab :owner="owner" :can-manage="canManage" />
</template>
</MalioTabList>
</template>
</div>
</template>
<script setup lang="ts">
import type { Prestataire } from '~/modules/directory/services/dto/prestataire'
import { usePrestataireService } from '~/modules/directory/services/prestataires'
import { isValidEmail, isValidFrPhone, isValidUrl } from '~/modules/directory/utils/validation'
definePageMeta({ middleware: ['admin'] })
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const id = Number(route.params.id)
const ownerIri = `/api/prestataires/${id}`
const owner = { prestataire: ownerIri }
const prestataireService = usePrestataireService()
const {
contacts,
addresses,
savingContacts,
savingAddresses,
onContactInput,
addContact,
removeContact,
saveContacts,
onAddressInput,
addAddress,
removeAddress,
saveAddresses,
load,
} = useDirectoryDetail(owner)
const { can } = usePermissions()
const canManage = computed(() => can('directory.providers.manage'))
const prestataire = ref<Prestataire | null>(null)
const loading = ref(true)
const activeTab = ref('info')
const tabs = [
{ key: 'info', label: t('directory.tabs.info'), icon: 'mdi:information-outline' },
{ 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' },
]
const info = reactive({ name: '', email: '', phone: '', website: '' })
const infoTouched = reactive({ name: false })
const savingInfo = ref(false)
const emailError = computed(() => (isValidEmail(info.email) ? '' : t('directory.validation.emailInvalid')))
const phoneError = computed(() => (isValidFrPhone(info.phone) ? '' : t('directory.validation.phoneInvalid')))
const websiteError = computed(() => (isValidUrl(info.website) ? '' : t('directory.validation.urlInvalid')))
const infoValid = computed(() => !emailError.value && !phoneError.value && !websiteError.value)
async function saveInfo(): Promise<void> {
infoTouched.name = true
if (!info.name.trim() || !infoValid.value || savingInfo.value) return
savingInfo.value = true
try {
prestataire.value = await prestataireService.update(id, {
name: info.name.trim(),
email: info.email.trim() || null,
phone: info.phone.trim() || null,
website: info.website.trim() || null,
})
} finally {
savingInfo.value = false
}
}
function goBack(): void {
router.push('/directory')
}
onMounted(async () => {
prestataire.value = await prestataireService.getById(id)
info.name = prestataire.value.name ?? ''
info.email = prestataire.value.email ?? ''
info.phone = prestataire.value.phone ?? ''
info.website = prestataire.value.website ?? ''
await load()
loading.value = false
})
</script>
@@ -2,12 +2,65 @@
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="flex items-center gap-3 pt-4"> <div class="flex items-center gap-3 pt-4">
<MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" /> <MalioButtonIcon icon="mdi:arrow-left" :aria-label="$t('common.back')" @click="goBack" />
<h1 class="text-2xl font-bold text-neutral-900">{{ prospect?.name ?? '…' }}</h1> <h1 class="text-2xl font-bold text-neutral-900">{{ prospect?.company ?? '…' }}</h1>
</div> </div>
<p v-if="loading">{{ $t('common.loading') }}</p> <p v-if="loading">{{ $t('common.loading') }}</p>
<template v-else-if="prospect"> <template v-else-if="prospect">
<MalioTabList v-model="activeTab" :tabs="tabs"> <MalioTabList v-model="activeTab" :tabs="tabs">
<template #info>
<div class="flex flex-col gap-4 pt-6">
<div class="relative grid grid-cols-2 gap-x-[44px] gap-y-4 rounded-lg bg-white px-7 py-5 shadow-[0_4px_4px_0_rgba(0,0,0,0.10)]">
<MalioInputText
v-model="info.company"
class="col-span-2"
:label="$t('prospects.fields.company')"
:error="infoTouched.company && !info.company.trim() ? $t('prospects.validation.companyRequired') : ''"
@blur="infoTouched.company = true"
/>
<MalioSelect
v-model="info.status"
:label="$t('prospects.fields.status')"
:options="statusOptions"
group-class="w-full"
/>
<MalioInputText
v-model="info.website"
:label="$t('prospects.fields.website')"
:error="websiteError"
/>
<MalioInputText
v-model="info.email"
:label="$t('prospects.fields.email')"
:error="emailError"
/>
<MalioInputText
v-model="info.phone"
:label="$t('prospects.fields.phone')"
:error="phoneError"
/>
<MalioInputText
v-model="info.source"
class="col-span-2"
:label="$t('prospects.fields.source')"
/>
<MalioInputTextArea
v-model="info.notes"
class="col-span-2"
:label="$t('prospects.fields.notes')"
/>
</div>
<div class="flex justify-center pt-2">
<MalioButton
button-class="w-auto px-6"
:label="$t('common.save')"
:disabled="savingInfo || !infoValid"
@click="saveInfo"
/>
</div>
</div>
</template>
<template #contact> <template #contact>
<div class="flex flex-col gap-4 pt-6"> <div class="flex flex-col gap-4 pt-6">
<DirectoryContactBlock <DirectoryContactBlock
@@ -69,7 +122,7 @@
</template> </template>
<template #report> <template #report>
<CommercialReportTab :owner="owner" :is-admin="true" /> <CommercialReportTab :owner="owner" :can-manage="canManage" />
</template> </template>
</MalioTabList> </MalioTabList>
</template> </template>
@@ -77,8 +130,9 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Prospect } from '~/modules/directory/services/dto/prospect' import type { Prospect, ProspectStatus } from '~/modules/directory/services/dto/prospect'
import { useProspectService } from '~/modules/directory/services/prospects' import { useProspectService } from '~/modules/directory/services/prospects'
import { isValidEmail, isValidFrPhone, isValidUrl } from '~/modules/directory/utils/validation'
definePageMeta({ middleware: ['admin'] }) definePageMeta({ middleware: ['admin'] })
@@ -107,22 +161,79 @@ const {
load, load,
} = useDirectoryDetail(owner) } = useDirectoryDetail(owner)
const { can } = usePermissions()
const canManage = computed(() => can('directory.prospects.manage'))
const prospect = ref<Prospect | null>(null) const prospect = ref<Prospect | null>(null)
const loading = ref(true) const loading = ref(true)
const activeTab = ref('contact') const activeTab = ref('info')
const tabs = [ const tabs = [
{ key: 'info', label: t('directory.tabs.info'), icon: 'mdi:information-outline' },
{ key: 'contact', label: t('directory.tabs.contact'), icon: 'mdi:account-outline' }, { key: 'contact', label: t('directory.tabs.contact'), icon: 'mdi:account-outline' },
{ key: 'address', label: t('directory.tabs.address'), icon: 'mdi:map-marker-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' }, { key: 'report', label: t('directory.tabs.report'), icon: 'mdi:file-document-outline' },
] ]
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' },
]
// Champs de base de la fiche, édités en mémoire et persistés au clic sur
// « Enregistrer » (PATCH), comme les onglets Contact/Adresse.
const info = reactive<{
company: string
email: string
phone: string
website: string
status: ProspectStatus
source: string
notes: string
}>({ company: '', email: '', phone: '', website: '', status: 'new', source: '', notes: '' })
const infoTouched = reactive({ company: false })
const savingInfo = ref(false)
const emailError = computed(() => (isValidEmail(info.email) ? '' : t('directory.validation.emailInvalid')))
const phoneError = computed(() => (isValidFrPhone(info.phone) ? '' : t('directory.validation.phoneInvalid')))
const websiteError = computed(() => (isValidUrl(info.website) ? '' : t('directory.validation.urlInvalid')))
const infoValid = computed(() => !emailError.value && !phoneError.value && !websiteError.value)
async function saveInfo(): Promise<void> {
infoTouched.company = true
if (!info.company.trim() || !infoValid.value || savingInfo.value) return
savingInfo.value = true
try {
prospect.value = await prospectService.update(id, {
company: info.company.trim(),
email: info.email.trim() || null,
phone: info.phone.trim() || null,
website: info.website.trim() || null,
status: info.status,
source: info.source.trim() || null,
notes: info.notes.trim() || null,
})
} finally {
savingInfo.value = false
}
}
function goBack(): void { function goBack(): void {
router.push('/directory') router.push('/directory')
} }
onMounted(async () => { onMounted(async () => {
prospect.value = await prospectService.getById(id) prospect.value = await prospectService.getById(id)
info.company = prospect.value.company ?? ''
info.email = prospect.value.email ?? ''
info.phone = prospect.value.phone ?? ''
info.website = prospect.value.website ?? ''
info.status = prospect.value.status ?? 'new'
info.source = prospect.value.source ?? ''
info.notes = prospect.value.notes ?? ''
await load() await load()
loading.value = false loading.value = false
}) })
@@ -2,7 +2,7 @@ import type { Address, AddressWrite } from './dto/address'
import type { HydraCollection } from '~/utils/api' import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api' import { extractHydraMembers } from '~/utils/api'
type Owner = { client?: string, prospect?: string } type Owner = { client?: string, prospect?: string, prestataire?: string }
export function useAddressService() { export function useAddressService() {
const api = useApi() const api = useApi()
@@ -2,7 +2,7 @@ import type { CommercialReport, CommercialReportWrite } from './dto/commercial-r
import type { HydraCollection } from '~/utils/api' import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api' import { extractHydraMembers } from '~/utils/api'
type Owner = { client?: string, prospect?: string } type Owner = { client?: string, prospect?: string, prestataire?: string }
export function useCommercialReportService() { export function useCommercialReportService() {
const api = useApi() const api = useApi()
@@ -2,7 +2,7 @@ import type { Contact, ContactWrite } from './dto/contact'
import type { HydraCollection } from '~/utils/api' import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api' import { extractHydraMembers } from '~/utils/api'
type Owner = { client?: string, prospect?: string } type Owner = { client?: string, prospect?: string, prestataire?: string }
export function useContactService() { export function useContactService() {
const api = useApi() const api = useApi()
@@ -9,6 +9,7 @@ export type Address = {
country: string country: string
client?: string | null client?: string | null
prospect?: string | null prospect?: string | null
prestataire?: string | null
} }
export type AddressWrite = { export type AddressWrite = {
@@ -20,4 +21,5 @@ export type AddressWrite = {
country: string country: string
client?: string | null client?: string | null
prospect?: string | null prospect?: string | null
prestataire?: string | null
} }
@@ -4,10 +4,12 @@ export type Client = {
name: string name: string
email: string | null email: string | null
phone: string | null phone: string | null
website: string | null
} }
export type ClientWrite = { export type ClientWrite = {
name: string name: string
email?: string | null email?: string | null
phone?: string | null phone?: string | null
website?: string | null
} }
@@ -12,6 +12,7 @@ export type CommercialReport = {
author?: { id: number, username: string } | null author?: { id: number, username: string } | null
client?: string | null client?: string | null
prospect?: string | null prospect?: string | null
prestataire?: string | null
documents?: ReportDocument[] documents?: ReportDocument[]
createdAt?: string createdAt?: string
updatedAt?: string updatedAt?: string
@@ -24,4 +25,5 @@ export type CommercialReportWrite = {
type: ReportType type: ReportType
client?: string | null client?: string | null
prospect?: string | null prospect?: string | null
prestataire?: string | null
} }
@@ -9,6 +9,7 @@ export type Contact = {
phoneSecondary: string | null phoneSecondary: string | null
client?: string | null client?: string | null
prospect?: string | null prospect?: string | null
prestataire?: string | null
} }
export type ContactWrite = { export type ContactWrite = {
@@ -20,4 +21,5 @@ export type ContactWrite = {
phoneSecondary: string | null phoneSecondary: string | null
client?: string | null client?: string | null
prospect?: string | null prospect?: string | null
prestataire?: string | null
} }
@@ -0,0 +1,15 @@
export type Prestataire = {
id: number
'@id'?: string
name: string
email: string | null
phone: string | null
website: string | null
}
export type PrestataireWrite = {
name: string
email?: string | null
phone?: string | null
website?: string | null
}
@@ -5,10 +5,10 @@ export type ProspectStatus = 'new' | 'contacted' | 'qualified' | 'won' | 'lost'
export type Prospect = { export type Prospect = {
id: number id: number
'@id'?: string '@id'?: string
name: string company: string
company: string | null
email: string | null email: string | null
phone: string | null phone: string | null
website: string | null
status: ProspectStatus status: ProspectStatus
source: string | null source: string | null
notes: string | null notes: string | null
@@ -18,10 +18,10 @@ export type Prospect = {
} }
export type ProspectWrite = { export type ProspectWrite = {
name: string company: string
company?: string | null
email?: string | null email?: string | null
phone?: string | null phone?: string | null
website?: string | null
status?: ProspectStatus status?: ProspectStatus
source?: string | null source?: string | null
notes?: string | null notes?: string | null
@@ -0,0 +1,36 @@
import type { Prestataire, PrestataireWrite } from './dto/prestataire'
import type { HydraCollection } from '~/utils/api'
import { extractHydraMembers } from '~/utils/api'
export function usePrestataireService() {
const api = useApi()
async function getAll(): Promise<Prestataire[]> {
const data = await api.get<HydraCollection<Prestataire>>('/prestataires')
return extractHydraMembers(data)
}
async function getById(id: number): Promise<Prestataire> {
return api.get<Prestataire>(`/prestataires/${id}`)
}
async function create(payload: PrestataireWrite): Promise<Prestataire> {
return api.post<Prestataire>('/prestataires', payload as Record<string, unknown>, {
toastSuccessKey: 'prestataires.created',
})
}
async function update(id: number, payload: Partial<PrestataireWrite>): Promise<Prestataire> {
return api.patch<Prestataire>(`/prestataires/${id}`, payload as Record<string, unknown>, {
toastSuccessKey: 'prestataires.updated',
})
}
async function remove(id: number): Promise<void> {
await api.delete(`/prestataires/${id}`, {}, {
toastSuccessKey: 'prestataires.deleted',
})
}
return { getAll, getById, create, update, remove }
}
@@ -0,0 +1,40 @@
// Validateurs partagés du répertoire (annuaire). Chaque validateur considère
// une valeur VIDE comme valide : les champs email/téléphone/site web sont
// facultatifs — la validation ne porte que sur le format quand c'est renseigné.
/** Email basique (présence d'un @ entouré de caractères, un point dans le domaine). */
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
/**
* Téléphone français : 10 chiffres commençant par 0 (ex. `0549200910`) — format
* saisi par l'utilisateur, sans séparateurs — ou notation internationale
* `+33XXXXXXXXX` (9 chiffres après l'indicatif). Les espaces, points et tirets
* sont tolérés à la frappe (retirés avant contrôle).
*/
const FR_PHONE_NATIONAL_RE = /^0\d{9}$/
const FR_PHONE_INTL_RE = /^\+33\d{9}$/
const URL_RE = /^https?:\/\/[^\s.]+\.[^\s]+$/
/** Retire les séparateurs usuels d'un numéro (espaces, points, tirets, parenthèses). */
export function stripPhoneSeparators(value: string): string {
return value.replace(/[\s.\-()]/g, '')
}
export function isValidEmail(value: string | null | undefined): boolean {
const v = (value ?? '').trim()
if (v === '') return true
return EMAIL_RE.test(v)
}
export function isValidFrPhone(value: string | null | undefined): boolean {
const v = stripPhoneSeparators((value ?? '').trim())
if (v === '') return true
return FR_PHONE_NATIONAL_RE.test(v) || FR_PHONE_INTL_RE.test(v)
}
export function isValidUrl(value: string | null | undefined): boolean {
const v = (value ?? '').trim()
if (v === '') return true
return URL_RE.test(v)
}
+1 -3
View File
@@ -21,7 +21,6 @@
</div> </div>
<div> <div>
<AdminClientTab v-if="activeTab === 'clients'" />
<AdminWorkflowTab v-if="activeTab === 'workflows'" /> <AdminWorkflowTab v-if="activeTab === 'workflows'" />
<AdminEffortTab v-if="activeTab === 'efforts'" /> <AdminEffortTab v-if="activeTab === 'efforts'" />
<AdminPriorityTab v-if="activeTab === 'priorities'" /> <AdminPriorityTab v-if="activeTab === 'priorities'" />
@@ -50,7 +49,6 @@ const canViewRoles = computed(() => can('core.roles.view'))
const canViewAudit = computed(() => can('core.audit_log.view')) const canViewAudit = computed(() => can('core.audit_log.view'))
const tabs = [ const tabs = [
{ key: 'clients', label: 'Clients' },
{ key: 'workflows', label: 'Workflows' }, { key: 'workflows', label: 'Workflows' },
{ key: 'efforts', label: 'Efforts' }, { key: 'efforts', label: 'Efforts' },
{ key: 'priorities', label: 'Priorités' }, { key: 'priorities', label: 'Priorités' },
@@ -72,5 +70,5 @@ const visibleTabs = computed(() =>
tabs.filter((tab) => !('permission' in tab) || can(tab.permission)), tabs.filter((tab) => !('permission' in tab) || can(tab.permission)),
) )
const activeTab = ref<TabKey>('clients') const activeTab = ref<TabKey>('workflows')
</script> </script>
+26
View File
@@ -0,0 +1,26 @@
import { $fetch } from 'ofetch'
/**
* Appel HTTP vers un service EXTERNE (hors API Lesstime) : pas de cookie de
* session, pas d'enveloppe Hydra, timeout court. Utilisé par l'autocomplétion
* d'adresse branchée sur la Base Adresse Nationale (api-adresse.data.gouv.fr).
* Ne jamais passer par `useApi()` pour ces domaines tiers.
*/
export interface HttpExternalOptions {
/** Paramètres de query string (encodés par ofetch). */
query?: Record<string, string | number | undefined>
/** Timeout en millisecondes avant abandon (défaut 5000). */
timeoutMs?: number
}
export async function httpExternal<T>(
url: string,
opts: HttpExternalOptions = {},
): Promise<T> {
return $fetch<T>(url, {
query: opts.query,
credentials: 'omit',
retry: 0,
timeout: opts.timeoutMs ?? 5000,
})
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260624153709 extends AbstractMigration
{
public function getDescription(): string
{
return 'Directory: prestataire entity + website on client/prospect/prestataire + prestataire ownership on contacts/addresses/reports + prospect company-only';
}
public function up(Schema $schema): void
{
$this->addSql('CREATE TABLE prestataire (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, name VARCHAR(255) NOT NULL, email VARCHAR(255) DEFAULT NULL, phone VARCHAR(50) DEFAULT NULL, website VARCHAR(255) DEFAULT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, created_by INT DEFAULT NULL, updated_by INT DEFAULT NULL, PRIMARY KEY (id))');
$this->addSql('CREATE INDEX IDX_60A26480DE12AB56 ON prestataire (created_by)');
$this->addSql('CREATE INDEX IDX_60A2648016FE72E1 ON prestataire (updated_by)');
$this->addSql('ALTER TABLE prestataire ADD CONSTRAINT FK_60A26480DE12AB56 FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE prestataire ADD CONSTRAINT FK_60A2648016FE72E1 FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL NOT DEFERRABLE');
$this->addSql('ALTER TABLE client ADD website VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE commercial_report ADD prestataire_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE commercial_report ADD CONSTRAINT FK_886919D8BE3DB2B7 FOREIGN KEY (prestataire_id) REFERENCES prestataire (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_886919D8BE3DB2B7 ON commercial_report (prestataire_id)');
$this->addSql('ALTER TABLE directory_address ADD prestataire_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE directory_address ADD CONSTRAINT FK_6E5D9707BE3DB2B7 FOREIGN KEY (prestataire_id) REFERENCES prestataire (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_6E5D9707BE3DB2B7 ON directory_address (prestataire_id)');
$this->addSql('ALTER TABLE directory_contact ADD prestataire_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE directory_contact ADD CONSTRAINT FK_2F711EBEBE3DB2B7 FOREIGN KEY (prestataire_id) REFERENCES prestataire (id) ON DELETE CASCADE NOT DEFERRABLE');
$this->addSql('CREATE INDEX IDX_2F711EBEBE3DB2B7 ON directory_contact (prestataire_id)');
// Prospect désormais société-only : on conserve la donnée existante en
// recopiant le nom dans la société quand celle-ci est vide, avant de
// rendre la colonne obligatoire et de supprimer la colonne name.
$this->addSql('ALTER TABLE prospect ADD website VARCHAR(255) DEFAULT NULL');
$this->addSql("UPDATE prospect SET company = name WHERE company IS NULL OR company = ''");
$this->addSql('ALTER TABLE prospect ALTER company SET NOT NULL');
$this->addSql('ALTER TABLE prospect DROP name');
// Ownership CHECK constraints: chaque ligne appartient à un client,
// un prospect OU un prestataire.
$this->addSql('ALTER TABLE directory_contact DROP CONSTRAINT chk_contact_owner');
$this->addSql('ALTER TABLE directory_contact ADD CONSTRAINT chk_contact_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL OR prestataire_id IS NOT NULL)');
$this->addSql('ALTER TABLE directory_address DROP CONSTRAINT chk_address_owner');
$this->addSql('ALTER TABLE directory_address ADD CONSTRAINT chk_address_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL OR prestataire_id IS NOT NULL)');
$this->addSql('ALTER TABLE commercial_report DROP CONSTRAINT chk_report_owner');
$this->addSql('ALTER TABLE commercial_report ADD CONSTRAINT chk_report_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL OR prestataire_id IS NOT NULL)');
}
public function down(Schema $schema): void
{
// Rétablit les contraintes d'ownership client/prospect (sans prestataire).
$this->addSql('ALTER TABLE directory_contact DROP CONSTRAINT chk_contact_owner');
$this->addSql('ALTER TABLE directory_address DROP CONSTRAINT chk_address_owner');
$this->addSql('ALTER TABLE commercial_report DROP CONSTRAINT chk_report_owner');
$this->addSql('ALTER TABLE commercial_report DROP CONSTRAINT FK_886919D8BE3DB2B7');
$this->addSql('DROP INDEX IDX_886919D8BE3DB2B7');
$this->addSql('ALTER TABLE commercial_report DROP prestataire_id');
$this->addSql('ALTER TABLE directory_address DROP CONSTRAINT FK_6E5D9707BE3DB2B7');
$this->addSql('DROP INDEX IDX_6E5D9707BE3DB2B7');
$this->addSql('ALTER TABLE directory_address DROP prestataire_id');
$this->addSql('ALTER TABLE directory_contact DROP CONSTRAINT FK_2F711EBEBE3DB2B7');
$this->addSql('DROP INDEX IDX_2F711EBEBE3DB2B7');
$this->addSql('ALTER TABLE directory_contact DROP prestataire_id');
$this->addSql('ALTER TABLE directory_contact ADD CONSTRAINT chk_contact_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL)');
$this->addSql('ALTER TABLE directory_address ADD CONSTRAINT chk_address_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL)');
$this->addSql('ALTER TABLE commercial_report ADD CONSTRAINT chk_report_owner CHECK (client_id IS NOT NULL OR prospect_id IS NOT NULL)');
$this->addSql('ALTER TABLE prestataire DROP CONSTRAINT FK_60A26480DE12AB56');
$this->addSql('ALTER TABLE prestataire DROP CONSTRAINT FK_60A2648016FE72E1');
$this->addSql('DROP TABLE prestataire');
$this->addSql('ALTER TABLE client DROP website');
// Restaure la colonne name (recopiée depuis company) puis l'oblige.
$this->addSql('ALTER TABLE prospect ADD name VARCHAR(255) DEFAULT NULL');
$this->addSql('UPDATE prospect SET name = company');
$this->addSql('ALTER TABLE prospect ALTER name SET NOT NULL');
$this->addSql('ALTER TABLE prospect DROP website');
$this->addSql('ALTER TABLE prospect ALTER company DROP NOT NULL');
}
}
-3
View File
@@ -127,7 +127,6 @@ class AppFixtures extends Fixture
// Prospects // Prospects
$prospectLead = new Prospect(); $prospectLead = new Prospect();
$prospectLead->setName('Marie Dupont');
$prospectLead->setCompany('Atelier Dupont'); $prospectLead->setCompany('Atelier Dupont');
$prospectLead->setEmail('marie@atelier-dupont.fr'); $prospectLead->setEmail('marie@atelier-dupont.fr');
$prospectLead->setPhone('06 11 22 33 44'); $prospectLead->setPhone('06 11 22 33 44');
@@ -145,7 +144,6 @@ class AppFixtures extends Fixture
$manager->persist($addressLead); $manager->persist($addressLead);
$prospectQualified = new Prospect(); $prospectQualified = new Prospect();
$prospectQualified->setName('Jean Martin');
$prospectQualified->setCompany('Martin & Fils'); $prospectQualified->setCompany('Martin & Fils');
$prospectQualified->setEmail('contact@martin-fils.fr'); $prospectQualified->setEmail('contact@martin-fils.fr');
$prospectQualified->setPhone('07 55 66 77 88'); $prospectQualified->setPhone('07 55 66 77 88');
@@ -163,7 +161,6 @@ class AppFixtures extends Fixture
$manager->persist($addressQualified); $manager->persist($addressQualified);
$prospectWon = new Prospect(); $prospectWon = new Prospect();
$prospectWon->setName('Sophie Bernard');
$prospectWon->setCompany('ACME Corp'); $prospectWon->setCompany('ACME Corp');
$prospectWon->setEmail('contact@acme.com'); $prospectWon->setEmail('contact@acme.com');
$prospectWon->setPhone('01 23 45 67 89'); $prospectWon->setPhone('01 23 45 67 89');
@@ -111,9 +111,18 @@ class AccrueLeaveCommand extends Command
$previousBalance = null !== $previousPeriod $previousBalance = null !== $previousPeriod
? $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $previousPeriod) ? $this->balanceRepository->findOneForPeriod($user, AbsenceType::PaidLeave, $previousPeriod)
: null; : null;
$balance->setAcquired(
null !== $previousBalance ? $previousBalance->getAcquiring() : $profile->getInitialLeaveBalance(), if (null !== $previousBalance) {
); // Only the days *not yet taken* carry over. Leave is charged
// oldest-first: it first consumes the previous "acquired"
// (N-2) bucket — which expires at roll-over anyway — so only
// days taken beyond that bucket eat into the carry-over.
$carryOver = $previousBalance->getAcquiring()
- max(0.0, $previousBalance->getTaken() - $previousBalance->getAcquired());
$balance->setAcquired(max(0.0, $carryOver));
} else {
$balance->setAcquired($profile->getInitialLeaveBalance());
}
} }
if ($monthKey === $balance->getLastAccruedMonth()) { if ($monthKey === $balance->getLastAccruedMonth()) {
+2
View File
@@ -38,6 +38,8 @@ final class DirectoryModule implements ModuleInterface
['code' => 'directory.clients.manage', 'label' => 'Gérer les clients'], ['code' => 'directory.clients.manage', 'label' => 'Gérer les clients'],
['code' => 'directory.prospects.view', 'label' => 'Voir les prospects'], ['code' => 'directory.prospects.view', 'label' => 'Voir les prospects'],
['code' => 'directory.prospects.manage', 'label' => 'Gérer les prospects'], ['code' => 'directory.prospects.manage', 'label' => 'Gérer les prospects'],
['code' => 'directory.providers.view', 'label' => 'Voir les prestataires'],
['code' => 'directory.providers.manage', 'label' => 'Gérer les prestataires'],
]; ];
} }
} }
+23 -6
View File
@@ -23,17 +23,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable] #[Auditable]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view') or is_granted('directory.providers.view')"),
new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view') or is_granted('directory.providers.view')"),
new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
], ],
normalizationContext: ['groups' => ['address:read']], normalizationContext: ['groups' => ['address:read']],
denormalizationContext: ['groups' => ['address:write']], denormalizationContext: ['groups' => ['address:write']],
order: ['id' => 'ASC'], order: ['id' => 'ASC'],
)] )]
#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact'])] #[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact', 'prestataire' => 'exact'])]
#[ORM\Entity(repositoryClass: DoctrineAddressRepository::class)] #[ORM\Entity(repositoryClass: DoctrineAddressRepository::class)]
#[ORM\Table(name: 'directory_address')] #[ORM\Table(name: 'directory_address')]
class Address implements TimestampableInterface, BlamableInterface class Address implements TimestampableInterface, BlamableInterface
@@ -80,6 +80,11 @@ class Address implements TimestampableInterface, BlamableInterface
#[Groups(['address:read', 'address:write'])] #[Groups(['address:read', 'address:write'])]
private ?Prospect $prospect = null; private ?Prospect $prospect = null;
#[ORM\ManyToOne(targetEntity: Prestataire::class)]
#[ORM\JoinColumn(name: 'prestataire_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['address:read', 'address:write'])]
private ?Prestataire $prestataire = null;
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -180,4 +185,16 @@ class Address implements TimestampableInterface, BlamableInterface
return $this; return $this;
} }
public function getPrestataire(): ?Prestataire
{
return $this->prestataire;
}
public function setPrestataire(?Prestataire $prestataire): static
{
$this->prestataire = $prestataire;
return $this;
}
} }
@@ -58,6 +58,10 @@ class Client implements ClientInterface, TimestampableInterface, BlamableInterfa
#[Groups(['client:read', 'client:write'])] #[Groups(['client:read', 'client:write'])]
private ?string $phone = null; private ?string $phone = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['client:read', 'client:write'])]
private ?string $website = null;
/** @var Collection<int, ProjectInterface> */ /** @var Collection<int, ProjectInterface> */
#[ORM\OneToMany(targetEntity: ProjectInterface::class, mappedBy: 'client')] #[ORM\OneToMany(targetEntity: ProjectInterface::class, mappedBy: 'client')]
private Collection $projects; private Collection $projects;
@@ -108,6 +112,18 @@ class Client implements ClientInterface, TimestampableInterface, BlamableInterfa
return $this; return $this;
} }
public function getWebsite(): ?string
{
return $this->website;
}
public function setWebsite(?string $website): static
{
$this->website = $website;
return $this;
}
/** @return Collection<int, ProjectInterface> */ /** @return Collection<int, ProjectInterface> */
public function getProjects(): Collection public function getProjects(): Collection
{ {
@@ -26,17 +26,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view') or is_granted('directory.providers.view')"),
new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view') or is_granted('directory.providers.view')"),
new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
], ],
normalizationContext: ['groups' => ['commercial_report:read']], normalizationContext: ['groups' => ['commercial_report:read']],
denormalizationContext: ['groups' => ['commercial_report:write']], denormalizationContext: ['groups' => ['commercial_report:write']],
order: ['occurredAt' => 'DESC'], order: ['occurredAt' => 'DESC'],
)] )]
#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact'])] #[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact', 'prestataire' => 'exact'])]
#[ORM\Entity(repositoryClass: DoctrineCommercialReportRepository::class)] #[ORM\Entity(repositoryClass: DoctrineCommercialReportRepository::class)]
#[ORM\Table(name: 'commercial_report')] #[ORM\Table(name: 'commercial_report')]
class CommercialReport implements TimestampableInterface class CommercialReport implements TimestampableInterface
@@ -80,6 +80,11 @@ class CommercialReport implements TimestampableInterface
#[Groups(['commercial_report:read', 'commercial_report:write'])] #[Groups(['commercial_report:read', 'commercial_report:write'])]
private ?Prospect $prospect = null; private ?Prospect $prospect = null;
#[ORM\ManyToOne(targetEntity: Prestataire::class)]
#[ORM\JoinColumn(name: 'prestataire_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['commercial_report:read', 'commercial_report:write'])]
private ?Prestataire $prestataire = null;
/** @var Collection<int, ReportDocument> */ /** @var Collection<int, ReportDocument> */
#[ORM\OneToMany(targetEntity: ReportDocument::class, mappedBy: 'commercialReport', cascade: ['remove'])] #[ORM\OneToMany(targetEntity: ReportDocument::class, mappedBy: 'commercialReport', cascade: ['remove'])]
#[Groups(['commercial_report:read'])] #[Groups(['commercial_report:read'])]
@@ -179,6 +184,18 @@ class CommercialReport implements TimestampableInterface
return $this; return $this;
} }
public function getPrestataire(): ?Prestataire
{
return $this->prestataire;
}
public function setPrestataire(?Prestataire $prestataire): static
{
$this->prestataire = $prestataire;
return $this;
}
/** @return Collection<int, ReportDocument> */ /** @return Collection<int, ReportDocument> */
public function getDocuments(): Collection public function getDocuments(): Collection
{ {
+23 -6
View File
@@ -23,17 +23,17 @@ use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable] #[Auditable]
#[ApiResource( #[ApiResource(
operations: [ operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), new GetCollection(paginationEnabled: false, security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view') or is_granted('directory.providers.view')"),
new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view')"), new Get(security: "is_granted('directory.clients.view') or is_granted('directory.prospects.view') or is_granted('directory.providers.view')"),
new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Post(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Patch(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage')"), new Delete(security: "is_granted('directory.clients.manage') or is_granted('directory.prospects.manage') or is_granted('directory.providers.manage')"),
], ],
normalizationContext: ['groups' => ['contact:read']], normalizationContext: ['groups' => ['contact:read']],
denormalizationContext: ['groups' => ['contact:write']], denormalizationContext: ['groups' => ['contact:write']],
order: ['lastName' => 'ASC'], order: ['lastName' => 'ASC'],
)] )]
#[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact'])] #[ApiFilter(SearchFilter::class, properties: ['client' => 'exact', 'prospect' => 'exact', 'prestataire' => 'exact'])]
#[ORM\Entity(repositoryClass: DoctrineContactRepository::class)] #[ORM\Entity(repositoryClass: DoctrineContactRepository::class)]
#[ORM\Table(name: 'directory_contact')] #[ORM\Table(name: 'directory_contact')]
class Contact implements TimestampableInterface, BlamableInterface class Contact implements TimestampableInterface, BlamableInterface
@@ -80,6 +80,11 @@ class Contact implements TimestampableInterface, BlamableInterface
#[Groups(['contact:read', 'contact:write'])] #[Groups(['contact:read', 'contact:write'])]
private ?Prospect $prospect = null; private ?Prospect $prospect = null;
#[ORM\ManyToOne(targetEntity: Prestataire::class)]
#[ORM\JoinColumn(name: 'prestataire_id', referencedColumnName: 'id', nullable: true, onDelete: 'CASCADE')]
#[Groups(['contact:read', 'contact:write'])]
private ?Prestataire $prestataire = null;
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;
@@ -180,4 +185,16 @@ class Contact implements TimestampableInterface, BlamableInterface
return $this; return $this;
} }
public function getPrestataire(): ?Prestataire
{
return $this->prestataire;
}
public function setPrestataire(?Prestataire $prestataire): static
{
$this->prestataire = $prestataire;
return $this;
}
} }
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Directory\Infrastructure\Doctrine\DoctrinePrestataireRepository;
use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface;
use App\Shared\Domain\Trait\TimestampableBlamableTrait;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Serializer\Attribute\Groups;
#[Auditable]
#[ApiResource(
operations: [
new GetCollection(paginationEnabled: false, security: "is_granted('directory.providers.view')"),
new Get(security: "is_granted('directory.providers.view')"),
new Post(security: "is_granted('directory.providers.manage')"),
new Patch(security: "is_granted('directory.providers.manage')"),
new Delete(security: "is_granted('directory.providers.manage')"),
],
normalizationContext: ['groups' => ['prestataire:read']],
denormalizationContext: ['groups' => ['prestataire:write']],
order: ['name' => 'ASC'],
)]
#[ORM\Entity(repositoryClass: DoctrinePrestataireRepository::class)]
#[ORM\Table(name: 'prestataire')]
class Prestataire implements TimestampableInterface, BlamableInterface
{
use TimestampableBlamableTrait;
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['prestataire:read'])]
private ?int $id = null;
#[ORM\Column(length: 255)]
#[Groups(['prestataire:read', 'prestataire:write'])]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['prestataire:read', 'prestataire:write'])]
private ?string $email = null;
#[ORM\Column(length: 50, nullable: true)]
#[Groups(['prestataire:read', 'prestataire:write'])]
private ?string $phone = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['prestataire:read', 'prestataire:write'])]
private ?string $website = null;
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getEmail(): ?string
{
return $this->email;
}
public function setEmail(?string $email): static
{
$this->email = $email;
return $this;
}
public function getPhone(): ?string
{
return $this->phone;
}
public function setPhone(?string $phone): static
{
$this->phone = $phone;
return $this;
}
public function getWebsite(): ?string
{
return $this->website;
}
public function setWebsite(?string $website): static
{
$this->website = $website;
return $this;
}
}
+18 -18
View File
@@ -40,7 +40,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
], ],
normalizationContext: ['groups' => ['prospect:read']], normalizationContext: ['groups' => ['prospect:read']],
denormalizationContext: ['groups' => ['prospect:write']], denormalizationContext: ['groups' => ['prospect:write']],
order: ['name' => 'ASC'], order: ['company' => 'ASC'],
)] )]
#[ApiFilter(SearchFilter::class, properties: ['status' => 'exact'])] #[ApiFilter(SearchFilter::class, properties: ['status' => 'exact'])]
#[ORM\Entity(repositoryClass: DoctrineProspectRepository::class)] #[ORM\Entity(repositoryClass: DoctrineProspectRepository::class)]
@@ -57,10 +57,6 @@ class Prospect implements TimestampableInterface, BlamableInterface
#[ORM\Column(length: 255)] #[ORM\Column(length: 255)]
#[Groups(['prospect:read', 'prospect:write'])] #[Groups(['prospect:read', 'prospect:write'])]
private ?string $name = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['prospect:read', 'prospect:write'])]
private ?string $company = null; private ?string $company = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
@@ -71,6 +67,10 @@ class Prospect implements TimestampableInterface, BlamableInterface
#[Groups(['prospect:read', 'prospect:write'])] #[Groups(['prospect:read', 'prospect:write'])]
private ?string $phone = null; private ?string $phone = null;
#[ORM\Column(length: 255, nullable: true)]
#[Groups(['prospect:read', 'prospect:write'])]
private ?string $website = null;
#[ORM\Column(type: Types::STRING, length: 32, enumType: ProspectStatus::class)] #[ORM\Column(type: Types::STRING, length: 32, enumType: ProspectStatus::class)]
#[Groups(['prospect:read', 'prospect:write'])] #[Groups(['prospect:read', 'prospect:write'])]
private ProspectStatus $status = ProspectStatus::New; private ProspectStatus $status = ProspectStatus::New;
@@ -93,24 +93,12 @@ class Prospect implements TimestampableInterface, BlamableInterface
return $this->id; return $this->id;
} }
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getCompany(): ?string public function getCompany(): ?string
{ {
return $this->company; return $this->company;
} }
public function setCompany(?string $company): static public function setCompany(string $company): static
{ {
$this->company = $company; $this->company = $company;
@@ -141,6 +129,18 @@ class Prospect implements TimestampableInterface, BlamableInterface
return $this; return $this;
} }
public function getWebsite(): ?string
{
return $this->website;
}
public function setWebsite(?string $website): static
{
$this->website = $website;
return $this;
}
public function getStatus(): ProspectStatus public function getStatus(): ProspectStatus
{ {
return $this->status; return $this->status;
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Domain\Repository;
use App\Module\Directory\Domain\Entity\Prestataire;
interface PrestataireRepositoryInterface
{
public function findById(int $id): ?Prestataire;
/**
* @param array<string, mixed> $criteria
* @param null|array<string, string> $orderBy
*
* @return Prestataire[]
*/
public function findBy(array $criteria, ?array $orderBy = null, ?int $limit = null, ?int $offset = null): array;
}
@@ -46,9 +46,10 @@ final readonly class ConvertProspectProcessor implements ProcessorInterface
} }
$client = new Client(); $client = new Client();
$client->setName($prospect->getCompany() ?: (string) $prospect->getName()); $client->setName((string) $prospect->getCompany());
$client->setEmail($prospect->getEmail()); $client->setEmail($prospect->getEmail());
$client->setPhone($prospect->getPhone()); $client->setPhone($prospect->getPhone());
$client->setWebsite($prospect->getWebsite());
$this->entityManager->persist($client); $this->entityManager->persist($client);
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Module\Directory\Infrastructure\Doctrine;
use App\Module\Directory\Domain\Entity\Prestataire;
use App\Module\Directory\Domain\Repository\PrestataireRepositoryInterface;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Prestataire>
*/
final class DoctrinePrestataireRepository extends ServiceEntityRepository implements PrestataireRepositoryInterface
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Prestataire::class);
}
public function findById(int $id): ?Prestataire
{
return $this->find($id);
}
}
@@ -42,9 +42,10 @@ class ConvertProspectTool
if (null === $prospect->getConvertedClient()) { if (null === $prospect->getConvertedClient()) {
$client = new Client(); $client = new Client();
$client->setName($prospect->getCompany() ?: (string) $prospect->getName()); $client->setName((string) $prospect->getCompany());
$client->setEmail($prospect->getEmail()); $client->setEmail($prospect->getEmail());
$client->setPhone($prospect->getPhone()); $client->setPhone($prospect->getPhone());
$client->setWebsite($prospect->getWebsite());
$this->entityManager->persist($client); $this->entityManager->persist($client);
@@ -23,6 +23,7 @@ class CreateClientTool
string $name, string $name,
?string $email = null, ?string $email = null,
?string $phone = null, ?string $phone = null,
?string $website = null,
): string { ): string {
if (!$this->security->isGranted('ROLE_ADMIN')) { if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
@@ -32,6 +33,7 @@ class CreateClientTool
$client->setName($name); $client->setName($name);
$client->setEmail($email); $client->setEmail($email);
$client->setPhone($phone); $client->setPhone($phone);
$client->setWebsite($website);
$this->entityManager->persist($client); $this->entityManager->persist($client);
$this->entityManager->flush(); $this->entityManager->flush();
@@ -15,7 +15,7 @@ use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use function sprintf; use function sprintf;
#[McpTool(name: 'create-prospect', description: 'Create a prospect (admin). Only name is required. Status defaults to "new".')] #[McpTool(name: 'create-prospect', description: 'Create a prospect (admin). Only company is required. Status defaults to "new".')]
class CreateProspectTool class CreateProspectTool
{ {
public function __construct( public function __construct(
@@ -24,10 +24,10 @@ class CreateProspectTool
) {} ) {}
public function __invoke( public function __invoke(
string $name, string $company,
?string $company = null,
?string $email = null, ?string $email = null,
?string $phone = null, ?string $phone = null,
?string $website = null,
?string $status = null, ?string $status = null,
?string $source = null, ?string $source = null,
?string $notes = null, ?string $notes = null,
@@ -37,10 +37,10 @@ class CreateProspectTool
} }
$prospect = new Prospect(); $prospect = new Prospect();
$prospect->setName($name);
$prospect->setCompany($company); $prospect->setCompany($company);
$prospect->setEmail($email); $prospect->setEmail($email);
$prospect->setPhone($phone); $prospect->setPhone($phone);
$prospect->setWebsite($website);
$prospect->setSource($source); $prospect->setSource($source);
$prospect->setNotes($notes); $prospect->setNotes($notes);
@@ -33,10 +33,10 @@ class DeleteProspectTool
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id)); throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id));
} }
$name = $prospect->getName(); $company = $prospect->getCompany();
$this->entityManager->remove($prospect); $this->entityManager->remove($prospect);
$this->entityManager->flush(); $this->entityManager->flush();
return json_encode(['success' => true, 'message' => sprintf('Prospect "%s" deleted.', $name)]); return json_encode(['success' => true, 'message' => sprintf('Prospect "%s" deleted.', $company)]);
} }
} }
@@ -37,7 +37,7 @@ class ListProspectsTool
$criteria['status'] = $statusEnum; $criteria['status'] = $statusEnum;
} }
$prospects = $this->prospectRepository->findBy($criteria, ['name' => 'ASC']); $prospects = $this->prospectRepository->findBy($criteria, ['company' => 'ASC']);
return json_encode(array_map(static fn ($prospect) => Serializer::prospect($prospect), $prospects)); return json_encode(array_map(static fn ($prospect) => Serializer::prospect($prospect), $prospects));
} }
@@ -28,6 +28,7 @@ class UpdateClientTool
?string $name = null, ?string $name = null,
?string $email = null, ?string $email = null,
?string $phone = null, ?string $phone = null,
?string $website = null,
): string { ): string {
if (!$this->security->isGranted('ROLE_ADMIN')) { if (!$this->security->isGranted('ROLE_ADMIN')) {
throw new AccessDeniedException('Access denied: ROLE_ADMIN required.'); throw new AccessDeniedException('Access denied: ROLE_ADMIN required.');
@@ -47,6 +48,9 @@ class UpdateClientTool
if (null !== $phone) { if (null !== $phone) {
$client->setPhone($phone); $client->setPhone($phone);
} }
if (null !== $website) {
$client->setWebsite($website);
}
$this->entityManager->flush(); $this->entityManager->flush();
@@ -26,10 +26,10 @@ class UpdateProspectTool
public function __invoke( public function __invoke(
int $id, int $id,
?string $name = null,
?string $company = null, ?string $company = null,
?string $email = null, ?string $email = null,
?string $phone = null, ?string $phone = null,
?string $website = null,
?string $status = null, ?string $status = null,
?string $source = null, ?string $source = null,
?string $notes = null, ?string $notes = null,
@@ -43,9 +43,6 @@ class UpdateProspectTool
throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id)); throw new InvalidArgumentException(sprintf('Prospect with ID %d not found.', $id));
} }
if (null !== $name) {
$prospect->setName($name);
}
if (null !== $company) { if (null !== $company) {
$prospect->setCompany($company); $prospect->setCompany($company);
} }
@@ -55,6 +52,9 @@ class UpdateProspectTool
if (null !== $phone) { if (null !== $phone) {
$prospect->setPhone($phone); $prospect->setPhone($phone);
} }
if (null !== $website) {
$prospect->setWebsite($website);
}
if (null !== $status) { if (null !== $status) {
$statusEnum = ProspectStatus::tryFrom($status); $statusEnum = ProspectStatus::tryFrom($status);
if (null === $statusEnum) { if (null === $statusEnum) {
+6 -5
View File
@@ -366,10 +366,11 @@ final class Serializer
public static function client(Client $c): array public static function client(Client $c): array
{ {
return [ return [
'id' => $c->getId(), 'id' => $c->getId(),
'name' => $c->getName(), 'name' => $c->getName(),
'email' => $c->getEmail(), 'email' => $c->getEmail(),
'phone' => $c->getPhone(), 'phone' => $c->getPhone(),
'website' => $c->getWebsite(),
]; ];
} }
@@ -382,10 +383,10 @@ final class Serializer
return [ return [
'id' => $p->getId(), 'id' => $p->getId(),
'name' => $p->getName(),
'company' => $p->getCompany(), 'company' => $p->getCompany(),
'email' => $p->getEmail(), 'email' => $p->getEmail(),
'phone' => $p->getPhone(), 'phone' => $p->getPhone(),
'website' => $p->getWebsite(),
'status' => $p->getStatus()->value, 'status' => $p->getStatus()->value,
'statusLabel' => $p->getStatus()->label(), 'statusLabel' => $p->getStatus()->label(),
'source' => $p->getSource(), 'source' => $p->getSource(),
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
namespace App\Tests\Functional\Command;
use App\Module\Absence\Domain\Entity\AbsenceBalance;
use App\Module\Absence\Domain\Enum\AbsenceType;
use App\Module\Absence\Infrastructure\Command\AccrueLeaveCommand;
use App\Module\Absence\Infrastructure\Doctrine\DoctrineAbsenceBalanceRepository;
use App\Module\Core\Domain\Entity\User;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
/**
* Covers the period roll-over: when a new reference period opens, the previous
* period's "en cours d'acquisition" (N) becomes the new "acquired" (N-1), but
* only for the days that were not already taken.
*
* @internal
*/
class AccrueLeaveCommandTest extends KernelTestCase
{
private EntityManagerInterface $em;
private DoctrineAbsenceBalanceRepository $balances;
protected function setUp(): void
{
self::bootKernel();
$this->em = self::getContainer()->get(EntityManagerInterface::class);
$this->balances = self::getContainer()->get(DoctrineAbsenceBalanceRepository::class);
}
/**
* Tristan's real case: 9.75 accruing, 1 day taken, nothing previously
* acquired the day taken eats into the carry-over, so 8.75 rolls over
* (not 9.75).
*/
public function testCarryOverDeductsTakenDays(): void
{
$user = $this->createEmployee();
$this->seedPreviousBalance($user, acquired: 0.0, acquiring: 9.75, taken: 1.0);
$this->runForJune2026();
$rolled = $this->balances->findOneForPeriod($user, AbsenceType::PaidLeave, '2026-2027');
self::assertNotNull($rolled);
self::assertEqualsWithDelta(8.75, $rolled->getAcquired(), 0.0001);
}
/**
* Leave is charged oldest-first: the 3 days taken come out of the expiring
* N-2 "acquired" bucket (5), so the full 10 accruing days carry over intact.
*/
public function testCarryOverChargesOldestBucketFirst(): void
{
$user = $this->createEmployee();
$this->seedPreviousBalance($user, acquired: 5.0, acquiring: 10.0, taken: 3.0);
$this->runForJune2026();
$rolled = $this->balances->findOneForPeriod($user, AbsenceType::PaidLeave, '2026-2027');
self::assertNotNull($rolled);
self::assertEqualsWithDelta(10.0, $rolled->getAcquired(), 0.0001);
}
/** No day taken → the whole accruing bucket carries over. */
public function testFullCarryOverWhenNothingTaken(): void
{
$user = $this->createEmployee();
$this->seedPreviousBalance($user, acquired: 0.0, acquiring: 10.0, taken: 0.0);
$this->runForJune2026();
$rolled = $this->balances->findOneForPeriod($user, AbsenceType::PaidLeave, '2026-2027');
self::assertNotNull($rolled);
self::assertEqualsWithDelta(10.0, $rolled->getAcquired(), 0.0001);
}
private function createEmployee(): User
{
$user = new User();
$user->setUsername('accrue-test-'.uniqid());
$user->setPassword('x');
$user->setRoles(['ROLE_USER']);
$user->setIsEmployee(true);
$user->setHireDate(new DateTimeImmutable('2024-01-01'));
$user->setReferencePeriodStart('06-01');
$user->setAnnualLeaveDays(25.0);
$user->setWorkTimeRatio(1.0);
$user->setInitialLeaveBalance(0.0);
$this->em->persist($user);
$this->em->flush();
return $user;
}
private function seedPreviousBalance(User $user, float $acquired, float $acquiring, float $taken): void
{
$balance = new AbsenceBalance();
$balance->setUser($user);
$balance->setType(AbsenceType::PaidLeave);
$balance->setPeriod('2025-2026');
$balance->setAcquired($acquired);
$balance->setAcquiring($acquiring);
$balance->setTaken($taken);
$this->em->persist($balance);
$this->em->flush();
}
private function runForJune2026(): void
{
$command = self::getContainer()->get(AccrueLeaveCommand::class);
$tester = new CommandTester($command);
$tester->execute(['--month' => '2026-06']);
self::assertSame(0, $tester->getStatusCode());
}
}
@@ -28,7 +28,6 @@ class ProspectConversionTest extends KernelTestCase
public function testConvertCreatesClientAndFlagsProspectWon(): void public function testConvertCreatesClientAndFlagsProspectWon(): void
{ {
$prospect = new Prospect(); $prospect = new Prospect();
$prospect->setName('Lead Test');
$prospect->setCompany('Lead Company '.uniqid()); $prospect->setCompany('Lead Company '.uniqid());
$prospect->setEmail('lead@example.com'); $prospect->setEmail('lead@example.com');
$prospect->setPhone('06 00 00 00 00'); $prospect->setPhone('06 00 00 00 00');
@@ -47,7 +46,7 @@ class ProspectConversionTest extends KernelTestCase
public function testConvertIsIdempotent(): void public function testConvertIsIdempotent(): void
{ {
$prospect = new Prospect(); $prospect = new Prospect();
$prospect->setName('Idempotent Lead'); $prospect->setCompany('Idempotent Lead');
$prospect->setStatus(ProspectStatus::New); $prospect->setStatus(ProspectStatus::New);
$this->em->persist($prospect); $this->em->persist($prospect);
$this->em->flush(); $this->em->flush();
@@ -28,7 +28,6 @@ final class ConvertProspectProcessorTest extends KernelTestCase
$em = self::getContainer()->get(EntityManagerInterface::class); $em = self::getContainer()->get(EntityManagerInterface::class);
$prospect = new Prospect(); $prospect = new Prospect();
$prospect->setName('Atelier Test');
$prospect->setCompany('Atelier Test SARL'); $prospect->setCompany('Atelier Test SARL');
$em->persist($prospect); $em->persist($prospect);