feat: page client /admin/clients/{id} avec onglets et gestion contacts
Route /admin/clients/{id} (ClientsController::show) :
- 10 onglets : Information globale, Contacts, Factures, Avis de Paiement,
Devis, Impayes, Echeancier, EsyFlex, Sites Internet, Services
- Onglet actif via query param ?tab=
Onglet Information globale :
- Formulaire edition complet : identite (prenom, nom, email, phone, type),
entreprise (raison sociale, SIRET, RCS, TVA, APE, RNA),
adresse (adresse, complement, CP, ville, geoLat/geoLong hidden)
- Section systeme : code comptable, Stripe ID, dates creation/modification
- POST sauvegarde + updatedAt mis a jour
Onglet Contacts :
- Formulaire ajout contact : prenom, nom, email, phone, role, isBillingEmail
- Table des contacts existants avec suppression (data-confirm modal)
- Gestion via handleContactForm() : create/delete avec verification owner
Onglets placeholder :
- Factures, Avis, Devis, Impayes, Echeancier, EsyFlex, Sites, Services
affichent "Cette section sera disponible prochainement"
Customer entity :
- Ajout setUpdatedAt()
Template index :
- Nom du client cliquable (lien vers show)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -261,6 +261,68 @@ class ClientsController extends AbstractController
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/{id}', name: 'show')]
|
||||
public function show(Customer $customer, Request $request, EntityManagerInterface $em): Response
|
||||
{
|
||||
$tab = $request->query->getString('tab', 'info');
|
||||
|
||||
if ('POST' === $request->getMethod() && 'info' === $tab) {
|
||||
$this->populateCustomerData($request, $customer);
|
||||
$customer->setUpdatedAt(new \DateTimeImmutable());
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Informations mises a jour.');
|
||||
|
||||
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId()]);
|
||||
}
|
||||
|
||||
if ('POST' === $request->getMethod() && 'contacts' === $tab) {
|
||||
return $this->handleContactForm($request, $customer, $em);
|
||||
}
|
||||
|
||||
$contacts = $em->getRepository(\App\Entity\CustomerContact::class)->findBy(['customer' => $customer], ['createdAt' => 'DESC']);
|
||||
$domains = $em->getRepository(\App\Entity\Domain::class)->findBy(['customer' => $customer]);
|
||||
|
||||
return $this->render('admin/clients/show.html.twig', [
|
||||
'customer' => $customer,
|
||||
'contacts' => $contacts,
|
||||
'domains' => $domains,
|
||||
'tab' => $tab,
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleContactForm(Request $request, Customer $customer, EntityManagerInterface $em): Response
|
||||
{
|
||||
$action = $request->request->getString('contact_action');
|
||||
|
||||
if ('create' === $action) {
|
||||
$firstName = trim($request->request->getString('contact_firstName'));
|
||||
$lastName = trim($request->request->getString('contact_lastName'));
|
||||
|
||||
if ('' !== $firstName && '' !== $lastName) {
|
||||
$contact = new \App\Entity\CustomerContact($customer, $firstName, $lastName);
|
||||
$contact->setEmail(trim($request->request->getString('contact_email')) ?: null);
|
||||
$contact->setPhone(trim($request->request->getString('contact_phone')) ?: null);
|
||||
$contact->setRole(trim($request->request->getString('contact_role')) ?: null);
|
||||
$contact->setIsBillingEmail($request->request->getBoolean('contact_isBilling'));
|
||||
$em->persist($contact);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Contact '.$firstName.' '.$lastName.' ajoute.');
|
||||
}
|
||||
}
|
||||
|
||||
if ('delete' === $action) {
|
||||
$contactId = $request->request->getInt('contact_id');
|
||||
$contact = $em->getRepository(\App\Entity\CustomerContact::class)->find($contactId);
|
||||
if (null !== $contact && $contact->getCustomer() === $customer) {
|
||||
$em->remove($contact);
|
||||
$em->flush();
|
||||
$this->addFlash('success', 'Contact supprime.');
|
||||
}
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('app_admin_clients_show', ['id' => $customer->getId(), 'tab' => 'contacts']);
|
||||
}
|
||||
|
||||
#[Route('/{id}/toggle', name: 'toggle', methods: ['POST'])]
|
||||
public function toggle(Customer $customer, EntityManagerInterface $em, MeilisearchService $meilisearch, LoggerInterface $logger): Response
|
||||
{
|
||||
|
||||
@@ -419,4 +419,11 @@ class Customer
|
||||
{
|
||||
return $this->updatedAt;
|
||||
}
|
||||
|
||||
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static
|
||||
{
|
||||
$this->updatedAt = $updatedAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
{% set info = customersInfo[customer.id] ?? {} %}
|
||||
<tr class="hover:bg-white/50">
|
||||
<td class="px-4 pt-3 pb-1">
|
||||
<span class="font-bold">{{ customer.fullName }}</span>
|
||||
<a href="{{ path('app_admin_clients_show', {id: customer.id}) }}" class="font-bold hover:text-[#fabf04] transition-colors">{{ customer.fullName }}</a>
|
||||
{% if customer.raisonSociale and customer.firstName %}
|
||||
<span class="text-[10px] text-gray-400 ml-1">{{ customer.firstName }} {{ customer.lastName }}</span>
|
||||
{% endif %}
|
||||
|
||||
265
templates/admin/clients/show.html.twig
Normal file
265
templates/admin/clients/show.html.twig
Normal file
@@ -0,0 +1,265 @@
|
||||
{% extends 'admin/_layout.html.twig' %}
|
||||
|
||||
{% block title %}{{ customer.fullName }} - Client - CRM SITECONSEIL{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div class="page-container">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold heading-page">{{ customer.fullName }}</h1>
|
||||
{% if customer.raisonSociale %}
|
||||
<p class="text-xs text-gray-400 mt-1">{{ customer.raisonSociale }} — {{ customer.codeComptable ?? 'N/A' }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{% if customer.state == 'active' %}
|
||||
<span class="px-3 py-1 bg-green-500/20 text-green-700 font-bold uppercase text-xs rounded-lg">Actif</span>
|
||||
{% elseif customer.state == 'pending_delete' %}
|
||||
<span class="px-3 py-1 bg-red-500/20 text-red-700 font-bold uppercase text-xs rounded-lg animate-pulse">Suppression</span>
|
||||
{% else %}
|
||||
<span class="px-3 py-1 bg-yellow-100 text-yellow-800 font-bold uppercase text-xs rounded-lg">{{ customer.state }}</span>
|
||||
{% endif %}
|
||||
<a href="{{ path('app_admin_clients_index') }}" class="px-4 py-2 glass font-bold uppercase text-xs tracking-widest hover:bg-gray-900 hover:text-white transition-all">Retour</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for type, messages in app.flashes %}
|
||||
{% for message in messages %}
|
||||
<div class="mb-4 p-4 glass font-medium text-sm rounded-xl {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}">
|
||||
{{ message }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
{# Tabs #}
|
||||
{% set tabs = {
|
||||
'info': 'Information globale',
|
||||
'contacts': 'Contacts',
|
||||
'factures': 'Factures',
|
||||
'avis': 'Avis de Paiement',
|
||||
'devis': 'Devis',
|
||||
'impayes': 'Impayes',
|
||||
'echeancier': 'Echeancier',
|
||||
'esyflex': 'EsyFlex',
|
||||
'sites': 'Sites Internet',
|
||||
'services': 'Services'
|
||||
} %}
|
||||
|
||||
<div class="flex flex-wrap gap-1 mb-6">
|
||||
{% for key, label in tabs %}
|
||||
<a href="{{ path('app_admin_clients_show', {id: customer.id, tab: key}) }}"
|
||||
class="px-4 py-2 font-bold uppercase text-[10px] tracking-wider transition-all {{ tab == key ? 'glass-dark text-white' : 'glass text-gray-600 hover:bg-white/80' }}"
|
||||
style="border-radius: 8px 8px 0 0;">
|
||||
{{ label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Tab: Information globale #}
|
||||
{% if tab == 'info' %}
|
||||
<form method="post" action="{{ path('app_admin_clients_show', {id: customer.id, tab: 'info'}) }}" class="flex flex-col gap-6">
|
||||
<section class="glass p-6">
|
||||
<h2 class="text-sm font-bold uppercase tracking-wider mb-4">Identite</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label for="firstName" class="block text-xs font-bold uppercase tracking-wider mb-2">Prenom *</label>
|
||||
<input type="text" id="firstName" name="firstName" value="{{ customer.firstName }}" required class="w-full px-4 py-3 input-glass text-sm font-medium">
|
||||
</div>
|
||||
<div>
|
||||
<label for="lastName" class="block text-xs font-bold uppercase tracking-wider mb-2">Nom *</label>
|
||||
<input type="text" id="lastName" name="lastName" value="{{ customer.lastName }}" required class="w-full px-4 py-3 input-glass text-sm font-medium">
|
||||
</div>
|
||||
<div>
|
||||
<label for="email" class="block text-xs font-bold uppercase tracking-wider mb-2">Email *</label>
|
||||
<input type="email" id="email" name="email" value="{{ customer.email ?? customer.user.email }}" required class="w-full px-4 py-3 input-glass text-sm font-medium">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<div>
|
||||
<label for="phone" class="block text-xs font-bold uppercase tracking-wider mb-2">Telephone</label>
|
||||
<input type="tel" id="phone" name="phone" value="{{ customer.phone }}" class="w-full px-4 py-3 input-glass text-sm font-medium">
|
||||
</div>
|
||||
<div>
|
||||
<label for="typeCompany" class="block text-xs font-bold uppercase tracking-wider mb-2">Type</label>
|
||||
<select id="typeCompany" name="typeCompany" class="w-full px-4 py-3 glass text-sm font-bold">
|
||||
<option value="">— Selectionner —</option>
|
||||
{% for val in ['particulier','association','auto-entrepreneur','sas','sarl','eurl','sa','sci'] %}
|
||||
<option value="{{ val }}" {{ customer.typeCompany == val ? 'selected' }}>{{ val|capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="glass p-6">
|
||||
<h2 class="text-sm font-bold uppercase tracking-wider mb-4">Entreprise</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="raisonSociale" class="block text-xs font-bold uppercase tracking-wider mb-2">Raison sociale</label>
|
||||
<input type="text" id="raisonSociale" name="raisonSociale" value="{{ customer.raisonSociale }}" class="w-full px-4 py-3 input-glass text-sm font-medium">
|
||||
</div>
|
||||
<div>
|
||||
<label for="siret" class="block text-xs font-bold uppercase tracking-wider mb-2">SIRET</label>
|
||||
<input type="text" id="siret" name="siret" value="{{ customer.siret }}" maxlength="14" class="w-full px-4 py-3 input-glass text-sm font-medium">
|
||||
</div>
|
||||
<div>
|
||||
<label for="rcs" class="block text-xs font-bold uppercase tracking-wider mb-2">RCS</label>
|
||||
<input type="text" id="rcs" name="rcs" value="{{ customer.rcs }}" class="w-full px-4 py-3 input-glass text-sm font-medium">
|
||||
</div>
|
||||
<div>
|
||||
<label for="numTva" class="block text-xs font-bold uppercase tracking-wider mb-2">N° TVA</label>
|
||||
<input type="text" id="numTva" name="numTva" value="{{ customer.numTva }}" class="w-full px-4 py-3 input-glass text-sm font-medium">
|
||||
</div>
|
||||
<div>
|
||||
<label for="ape" class="block text-xs font-bold uppercase tracking-wider mb-2">Code APE / NAF</label>
|
||||
<input type="text" id="ape" name="ape" value="{{ customer.ape }}" maxlength="10" class="w-full px-4 py-3 input-glass text-sm font-medium">
|
||||
</div>
|
||||
<div>
|
||||
<label for="rna" class="block text-xs font-bold uppercase tracking-wider mb-2">RNA</label>
|
||||
<input type="text" id="rna" name="rna" value="{{ customer.rna }}" maxlength="20" class="w-full px-4 py-3 input-glass text-sm font-medium">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="glass p-6">
|
||||
<h2 class="text-sm font-bold uppercase tracking-wider mb-4">Adresse</h2>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label for="address" class="block text-xs font-bold uppercase tracking-wider mb-2">Adresse</label>
|
||||
<input type="text" id="address" name="address" value="{{ customer.address }}" class="w-full px-4 py-3 input-glass text-sm font-medium">
|
||||
</div>
|
||||
<div>
|
||||
<label for="address2" class="block text-xs font-bold uppercase tracking-wider mb-2">Complement</label>
|
||||
<input type="text" id="address2" name="address2" value="{{ customer.address2 }}" class="w-full px-4 py-3 input-glass text-sm font-medium">
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="zipCode" class="block text-xs font-bold uppercase tracking-wider mb-2">Code postal</label>
|
||||
<input type="text" id="zipCode" name="zipCode" value="{{ customer.zipCode }}" class="w-full px-4 py-3 input-glass text-sm font-medium">
|
||||
</div>
|
||||
<div>
|
||||
<label for="city" class="block text-xs font-bold uppercase tracking-wider mb-2">Ville</label>
|
||||
<input type="text" id="city" name="city" value="{{ customer.city }}" class="w-full px-4 py-3 input-glass text-sm font-medium">
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden" name="geoLat" value="{{ customer.geoLat }}">
|
||||
<input type="hidden" name="geoLong" value="{{ customer.geoLong }}">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="glass p-6">
|
||||
<h2 class="text-sm font-bold uppercase tracking-wider mb-4">Informations systeme</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-xs">
|
||||
<div>
|
||||
<span class="text-gray-400 font-bold uppercase text-[9px] block">Code comptable</span>
|
||||
<span class="font-mono font-bold">{{ customer.codeComptable ?? '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400 font-bold uppercase text-[9px] block">Stripe ID</span>
|
||||
<span class="font-mono font-bold">{{ customer.stripeCustomerId ?? '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400 font-bold uppercase text-[9px] block">Cree le</span>
|
||||
<span class="font-bold">{{ customer.createdAt|date('d/m/Y H:i') }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400 font-bold uppercase text-[9px] block">Modifie le</span>
|
||||
<span class="font-bold">{{ customer.updatedAt ? customer.updatedAt|date('d/m/Y H:i') : '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div>
|
||||
<button type="submit" class="px-6 py-3 btn-gold text-sm font-bold uppercase tracking-wider text-gray-900">Enregistrer</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# Tab: Contacts #}
|
||||
{% elseif tab == 'contacts' %}
|
||||
<section class="glass p-6 mb-6">
|
||||
<h2 class="text-sm font-bold uppercase tracking-wider mb-4">Ajouter un contact</h2>
|
||||
<form method="post" action="{{ path('app_admin_clients_show', {id: customer.id, tab: 'contacts'}) }}" class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<input type="hidden" name="contact_action" value="create">
|
||||
<div>
|
||||
<label for="contact_firstName" class="block text-xs font-bold uppercase tracking-wider mb-2">Prenom *</label>
|
||||
<input type="text" id="contact_firstName" name="contact_firstName" required class="w-full px-4 py-3 input-glass text-sm font-medium" placeholder="Prenom">
|
||||
</div>
|
||||
<div>
|
||||
<label for="contact_lastName" class="block text-xs font-bold uppercase tracking-wider mb-2">Nom *</label>
|
||||
<input type="text" id="contact_lastName" name="contact_lastName" required class="w-full px-4 py-3 input-glass text-sm font-medium" placeholder="Nom">
|
||||
</div>
|
||||
<div>
|
||||
<label for="contact_email" class="block text-xs font-bold uppercase tracking-wider mb-2">Email</label>
|
||||
<input type="email" id="contact_email" name="contact_email" class="w-full px-4 py-3 input-glass text-sm font-medium" placeholder="email@entreprise.fr">
|
||||
</div>
|
||||
<div>
|
||||
<label for="contact_phone" class="block text-xs font-bold uppercase tracking-wider mb-2">Telephone</label>
|
||||
<input type="tel" id="contact_phone" name="contact_phone" class="w-full px-4 py-3 input-glass text-sm font-medium" placeholder="06 12 34 56 78">
|
||||
</div>
|
||||
<div>
|
||||
<label for="contact_role" class="block text-xs font-bold uppercase tracking-wider mb-2">Role</label>
|
||||
<input type="text" id="contact_role" name="contact_role" class="w-full px-4 py-3 input-glass text-sm font-medium" placeholder="Gerant, Comptable...">
|
||||
</div>
|
||||
<div class="flex items-end gap-3">
|
||||
<label for="contact_isBilling" class="flex items-center gap-2 cursor-pointer pb-3">
|
||||
<input type="checkbox" id="contact_isBilling" name="contact_isBilling" value="1" class="accent-[#fabf04]">
|
||||
<span class="text-xs font-bold">Email facturation</span>
|
||||
</label>
|
||||
<button type="submit" class="px-5 py-3 btn-gold text-xs font-bold uppercase tracking-wider text-gray-900 mb-0.5">Ajouter</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if contacts|length > 0 %}
|
||||
<div class="glass overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="glass-dark text-white">
|
||||
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Nom</th>
|
||||
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Email</th>
|
||||
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Telephone</th>
|
||||
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Role</th>
|
||||
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Facturation</th>
|
||||
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for contact in contacts %}
|
||||
<tr class="border-b border-white/20 hover:bg-white/50">
|
||||
<td class="px-4 py-3 font-bold">{{ contact.fullName }}</td>
|
||||
<td class="px-4 py-3 text-xs font-mono">{{ contact.email ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-xs">{{ contact.phone ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-xs">{{ contact.role ?? '—' }}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
{% if contact.isBillingEmail %}
|
||||
<span class="text-green-600 font-bold">✓</span>
|
||||
{% else %}
|
||||
<span class="text-gray-300">✗</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<form method="post" action="{{ path('app_admin_clients_show', {id: customer.id, tab: 'contacts'}) }}" data-confirm="Supprimer le contact {{ contact.fullName }} ?">
|
||||
<input type="hidden" name="contact_action" value="delete">
|
||||
<input type="hidden" name="contact_id" value="{{ contact.id }}">
|
||||
<button type="submit" class="px-2 py-1 border-2 border-red-600 bg-white text-red-600 font-bold uppercase text-[10px] tracking-widest hover:bg-red-600 hover:text-white transition-all">Supprimer</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-gray-400">{{ contacts|length }} contact(s)</p>
|
||||
{% else %}
|
||||
<div class="glass p-8 text-center text-gray-400 font-bold">Aucun contact.</div>
|
||||
{% endif %}
|
||||
|
||||
{# Tabs placeholder #}
|
||||
{% else %}
|
||||
<div class="glass p-12 text-center">
|
||||
<p class="text-gray-400 font-bold uppercase text-sm tracking-wider">{{ tabs[tab] ?? tab }}</p>
|
||||
<p class="text-gray-300 text-xs mt-2">Cette section sera disponible prochainement.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -70,8 +70,9 @@ class AdminControllersTest extends TestCase
|
||||
{
|
||||
$repo = $this->createStub(CustomerRepository::class);
|
||||
$repo->method('findBy')->willReturn([]);
|
||||
$em = $this->createStub(\Doctrine\ORM\EntityManagerInterface::class);
|
||||
$controller = $this->createMockController(ClientsController::class);
|
||||
$response = $controller->index($repo);
|
||||
$response = $controller->index($repo, $em);
|
||||
$this->assertInstanceOf(Response::class, $response);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user