feat: entite Contrat + CRUD admin + formulaire creation

Entite Contrat:
- email, raisonSociale, type (migration_siteconseil), state (draft/send/signed/cancelled)
- submissionId, submitterCompanyId, submitterCustomerId (DocuSeal)
- 3 PDFs Vich (unsigned, signed, audit)
- customer (ManyToOne nullable, lie apres signature)
- Reference CTR_XXXXX, getTypeLabel()

Controller admin /admin/contrats:
- index: liste des contrats avec statut
- create: email + raison sociale + type de contrat
- show: detail avec infos client, contrat, PDFs, actions
- cancel: annulation

Templates:
- index: tableau + modal creation (email, raison sociale, select type)
- show: 2 blocs (client + contrat), boutons PDF/signe/audit/annuler

Vich mappings: contrat_pdf, contrat_signed_pdf, contrat_audit_pdf

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-09 08:09:51 +02:00
parent f10dabad81
commit 9b8e49c550
5 changed files with 552 additions and 8 deletions

View File

@@ -46,6 +46,18 @@ vich_uploader:
uri_prefix: /uploads/eflex/audit
upload_destination: '%kernel.project_dir%/public/uploads/eflex/audit'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
contrat_pdf:
uri_prefix: /uploads/contrats
upload_destination: '%kernel.project_dir%/public/uploads/contrats'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
contrat_signed_pdf:
uri_prefix: /uploads/contrats/signed
upload_destination: '%kernel.project_dir%/public/uploads/contrats/signed'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
contrat_audit_pdf:
uri_prefix: /uploads/contrats/audit
upload_destination: '%kernel.project_dir%/public/uploads/contrats/audit'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
attestation_custom_pdf:
uri_prefix: /uploads/attestations
upload_destination: '%kernel.project_dir%/public/uploads/attestations'

View File

@@ -2,7 +2,10 @@
namespace App\Controller\Admin;
use App\Entity\Contrat;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -11,9 +14,75 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_EMPLOYE')]
class ContratController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em,
) {
}
#[Route('', name: 'index')]
public function index(): Response
{
return $this->render('admin/contrats/index.html.twig');
$contrats = $this->em->getRepository(Contrat::class)->findBy([], ['createdAt' => 'DESC']);
return $this->render('admin/contrats/index.html.twig', [
'contrats' => $contrats,
]);
}
#[Route('/create', name: 'create', methods: ['POST'])]
public function create(Request $request): Response
{
$email = trim($request->request->getString('email'));
$raisonSociale = trim($request->request->getString('raisonSociale'));
$type = $request->request->getString('type');
if ('' === $email || '' === $raisonSociale || '' === $type) {
$this->addFlash('error', 'Tous les champs sont requis.');
return $this->redirectToRoute('app_admin_contrats_index');
}
if (!isset(Contrat::TYPE_LABELS[$type])) {
$this->addFlash('error', 'Type de contrat invalide.');
return $this->redirectToRoute('app_admin_contrats_index');
}
$contrat = new Contrat($email, $raisonSociale, $type);
$this->em->persist($contrat);
$this->em->flush();
$this->addFlash('success', 'Contrat '.$contrat->getReference().' cree.');
return $this->redirectToRoute('app_admin_contrats_show', ['id' => $contrat->getId()]);
}
#[Route('/{id}', name: 'show', requirements: ['id' => '\d+'])]
public function show(int $id): Response
{
$contrat = $this->em->getRepository(Contrat::class)->find($id);
if (null === $contrat) {
throw $this->createNotFoundException('Contrat introuvable');
}
return $this->render('admin/contrats/show.html.twig', [
'contrat' => $contrat,
]);
}
#[Route('/{id}/cancel', name: 'cancel', requirements: ['id' => '\d+'], methods: ['POST'])]
public function cancel(int $id): Response
{
$contrat = $this->em->getRepository(Contrat::class)->find($id);
if (null === $contrat) {
throw $this->createNotFoundException('Contrat introuvable');
}
$contrat->setState(Contrat::STATE_CANCELLED);
$this->em->flush();
$this->addFlash('success', 'Contrat annule.');
return $this->redirectToRoute('app_admin_contrats_index');
}
}

312
src/Entity/Contrat.php Normal file
View File

@@ -0,0 +1,312 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Attribute as Vich;
#[ORM\Entity]
#[Vich\Uploadable]
class Contrat
{
public const STATE_DRAFT = 'draft';
public const STATE_SEND = 'send';
public const STATE_SIGNED = 'signed';
public const STATE_CANCELLED = 'cancelled';
public const TYPE_MIGRATION_SITECONSEIL = 'migration_siteconseil';
public const TYPE_LABELS = [
self::TYPE_MIGRATION_SITECONSEIL => 'Contrat Migration SARL SITECONSEIL',
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private string $email;
#[ORM\Column(length: 255)]
private string $raisonSociale;
#[ORM\Column(length: 50)]
private string $type;
#[ORM\Column(length: 20, options: ['default' => 'draft'])]
private string $state = self::STATE_DRAFT;
#[ORM\Column(nullable: true)]
private ?string $submissionId = null;
#[ORM\Column(nullable: true)]
private ?int $submitterCompanyId = null;
#[ORM\Column(nullable: true)]
private ?int $submitterCustomerId = null;
// ── PDF Unsigned ──
#[ORM\Column(length: 255, nullable: true)]
private ?string $pdfUnsigned = null;
#[Vich\UploadableField(mapping: 'contrat_pdf', fileNameProperty: 'pdfUnsigned')]
private ?File $pdfUnsignedFile = null;
// ── PDF Signed ──
#[ORM\Column(length: 255, nullable: true)]
private ?string $pdfSigned = null;
#[Vich\UploadableField(mapping: 'contrat_signed_pdf', fileNameProperty: 'pdfSigned')]
private ?File $pdfSignedFile = null;
// ── PDF Audit ──
#[ORM\Column(length: 255, nullable: true)]
private ?string $pdfAudit = null;
#[Vich\UploadableField(mapping: 'contrat_audit_pdf', fileNameProperty: 'pdfAudit')]
private ?File $pdfAuditFile = null;
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?Customer $customer = null;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $signedAt = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
public function __construct(string $email, string $raisonSociale, string $type)
{
$this->email = $email;
$this->raisonSociale = $raisonSociale;
$this->type = $type;
$this->createdAt = new \DateTimeImmutable();
}
public function getId(): ?int
{
return $this->id;
}
public function getReference(): string
{
return 'CTR_'.str_pad((string) ($this->id ?? 0), 5, '0', \STR_PAD_LEFT);
}
public function getEmail(): string
{
return $this->email;
}
public function setEmail(string $email): static
{
$this->email = $email;
return $this;
}
public function getRaisonSociale(): string
{
return $this->raisonSociale;
}
public function setRaisonSociale(string $raisonSociale): static
{
$this->raisonSociale = $raisonSociale;
return $this;
}
public function getType(): string
{
return $this->type;
}
public function getTypeLabel(): string
{
return self::TYPE_LABELS[$this->type] ?? $this->type;
}
public function setType(string $type): static
{
$this->type = $type;
return $this;
}
public function getState(): string
{
return $this->state;
}
public function setState(string $state): static
{
$this->state = $state;
return $this;
}
public function getSubmissionId(): ?string
{
return $this->submissionId;
}
public function setSubmissionId(?string $submissionId): static
{
$this->submissionId = $submissionId;
return $this;
}
public function getSubmitterCompanyId(): ?int
{
return $this->submitterCompanyId;
}
public function setSubmitterCompanyId(?int $submitterCompanyId): static
{
$this->submitterCompanyId = $submitterCompanyId;
return $this;
}
public function getSubmitterCustomerId(): ?int
{
return $this->submitterCustomerId;
}
public function setSubmitterCustomerId(?int $submitterCustomerId): static
{
$this->submitterCustomerId = $submitterCustomerId;
return $this;
}
public function getPdfUnsigned(): ?string
{
return $this->pdfUnsigned;
}
public function setPdfUnsigned(?string $pdfUnsigned): static
{
$this->pdfUnsigned = $pdfUnsigned;
return $this;
}
public function getPdfUnsignedFile(): ?File
{
return $this->pdfUnsignedFile;
}
public function setPdfUnsignedFile(?File $file): static
{
$this->pdfUnsignedFile = $file;
if (null !== $file) {
$this->updatedAt = new \DateTimeImmutable();
}
return $this;
}
public function getPdfSigned(): ?string
{
return $this->pdfSigned;
}
public function setPdfSigned(?string $pdfSigned): static
{
$this->pdfSigned = $pdfSigned;
return $this;
}
public function getPdfSignedFile(): ?File
{
return $this->pdfSignedFile;
}
public function setPdfSignedFile(?File $file): static
{
$this->pdfSignedFile = $file;
if (null !== $file) {
$this->updatedAt = new \DateTimeImmutable();
}
return $this;
}
public function getPdfAudit(): ?string
{
return $this->pdfAudit;
}
public function setPdfAudit(?string $pdfAudit): static
{
$this->pdfAudit = $pdfAudit;
return $this;
}
public function getPdfAuditFile(): ?File
{
return $this->pdfAuditFile;
}
public function setPdfAuditFile(?File $file): static
{
$this->pdfAuditFile = $file;
if (null !== $file) {
$this->updatedAt = new \DateTimeImmutable();
}
return $this;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function setCustomer(?Customer $customer): static
{
$this->customer = $customer;
return $this;
}
public function getCreatedAt(): \DateTimeImmutable
{
return $this->createdAt;
}
public function getSignedAt(): ?\DateTimeImmutable
{
return $this->signedAt;
}
public function setSignedAt(?\DateTimeImmutable $signedAt): static
{
$this->signedAt = $signedAt;
return $this;
}
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static
{
$this->updatedAt = $updatedAt;
return $this;
}
}

View File

@@ -6,6 +6,7 @@
<div class="page-container">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold heading-page">Contrats</h1>
<button type="button" data-modal-open="modal-contrat" class="btn-gold px-4 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900">Creer un contrat</button>
</div>
{% for type, messages in app.flashes %}
@@ -14,13 +15,79 @@
{% endfor %}
{% endfor %}
<div class="glass p-12 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 mx-auto mb-4 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-gray-400 font-bold uppercase text-sm tracking-wider mb-2">Contrats</p>
<p class="text-gray-300 text-xs">Cette section sera disponible prochainement.</p>
<p class="text-gray-300 text-xs mt-1">Creez un contrat, faites-le signer, puis l'espace client sera cree automatiquement.</p>
{% if contrats|length > 0 %}
<div class="glass overflow-x-auto 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">Reference</th>
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Client</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">Type</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Statut</th>
<th class="px-4 py-3 text-left font-bold uppercase text-xs tracking-widest">Date</th>
<th class="px-4 py-3 text-center font-bold uppercase text-xs tracking-widest">Actions</th>
</tr>
</thead>
<tbody>
{% for c in contrats %}
<tr class="border-b border-white/20 hover:bg-white/50">
<td class="px-4 py-3 font-mono font-bold text-[10px]">{{ c.reference }}</td>
<td class="px-4 py-3 font-bold text-xs">{{ c.raisonSociale }}</td>
<td class="px-4 py-3 text-xs text-gray-500">{{ c.email }}</td>
<td class="px-4 py-3 text-xs">{{ c.typeLabel }}</td>
<td class="px-4 py-3 text-center">
{% if c.state == 'signed' %}
<span class="px-2 py-0.5 bg-green-500/20 text-green-700 font-bold uppercase text-[10px]">Signe</span>
{% elseif c.state == 'send' %}
<span class="px-2 py-0.5 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px]">Envoye</span>
{% elseif c.state == 'cancelled' %}
<span class="px-2 py-0.5 bg-red-500/20 text-red-700 font-bold uppercase text-[10px]">Annule</span>
{% else %}
<span class="px-2 py-0.5 bg-yellow-100 text-yellow-800 font-bold uppercase text-[10px]">Brouillon</span>
{% endif %}
</td>
<td class="px-4 py-3 text-xs text-gray-500">{{ c.createdAt|date('d/m/Y') }}</td>
<td class="px-4 py-3 text-center">
<a href="{{ path('app_admin_contrats_show', {id: c.id}) }}" class="px-3 py-1 bg-gray-900 text-white hover:bg-[#fabf04] hover:text-gray-900 font-bold uppercase text-[10px] transition-all">Voir</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="glass p-8 text-center text-gray-400 font-bold">Aucun contrat.</div>
{% endif %}
{# Modal creation #}
<div id="modal-contrat" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="glass-heavy p-6 w-full max-w-lg">
<h2 class="text-lg font-bold uppercase mb-4">Nouveau contrat</h2>
<form method="post" action="{{ path('app_admin_contrats_create') }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<div class="md:col-span-2">
<label for="ctr-raisonSociale" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Raison sociale du client *</label>
<input type="text" id="ctr-raisonSociale" name="raisonSociale" required class="input-glass w-full px-3 py-2 text-xs font-bold" placeholder="Ex: SARL Mon Entreprise">
</div>
<div class="md:col-span-2">
<label for="ctr-email" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Email du client *</label>
<input type="email" id="ctr-email" name="email" required class="input-glass w-full px-3 py-2 text-xs font-bold" placeholder="client@exemple.fr">
</div>
<div class="md:col-span-2">
<label for="ctr-type" class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-1">Type de contrat *</label>
<select id="ctr-type" name="type" required class="input-glass w-full px-3 py-2 text-xs font-bold">
<option value="">— Selectionner —</option>
<option value="migration_siteconseil">Contrat Migration SARL SITECONSEIL</option>
</select>
</div>
</div>
<div class="flex justify-end gap-2">
<button type="button" data-modal-close="modal-contrat" class="px-4 py-2 glass font-bold uppercase text-[10px] tracking-widest">Annuler</button>
<button type="submit" class="btn-gold px-4 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900">Creer</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,84 @@
{% extends 'admin/_layout.html.twig' %}
{% block title %}Contrat {{ contrat.reference }} - Association E-Cosplay{% 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">{{ contrat.reference }}</h1>
<p class="text-xs text-gray-400 mt-1">{{ contrat.typeLabel }} - {{ contrat.raisonSociale }}</p>
</div>
<div class="flex items-center gap-3">
{% if contrat.state == 'signed' %}
<span class="px-3 py-1 bg-green-500/20 text-green-700 font-bold uppercase text-xs">Signe</span>
{% elseif contrat.state == 'send' %}
<span class="px-3 py-1 bg-blue-500/20 text-blue-700 font-bold uppercase text-xs">Envoye</span>
{% elseif contrat.state == 'cancelled' %}
<span class="px-3 py-1 bg-red-500/20 text-red-700 font-bold uppercase text-xs">Annule</span>
{% else %}
<span class="px-3 py-1 bg-yellow-100 text-yellow-800 font-bold uppercase text-xs">Brouillon</span>
{% endif %}
<a href="{{ path('app_admin_contrats_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 {{ type == 'success' ? 'border-green-300 text-green-800' : 'border-red-300 text-red-800' }}">{{ message }}</div>
{% endfor %}
{% endfor %}
{# Informations #}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="glass p-5">
<h2 class="text-sm font-bold uppercase tracking-wider mb-3">Client</h2>
<div class="space-y-2 text-sm">
<p><span class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Raison sociale :</span> <span class="font-bold">{{ contrat.raisonSociale }}</span></p>
<p><span class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Email :</span> <span class="font-bold">{{ contrat.email }}</span></p>
{% if contrat.customer %}
<p><span class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Compte client :</span> <a href="{{ path('app_admin_clients_show', {id: contrat.customer.id}) }}" class="font-bold" style="color: #fabf04;">{{ contrat.customer.fullName }}</a></p>
{% endif %}
</div>
</div>
<div class="glass p-5">
<h2 class="text-sm font-bold uppercase tracking-wider mb-3">Contrat</h2>
<div class="space-y-2 text-sm">
<p><span class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Reference :</span> <span class="font-mono font-bold">{{ contrat.reference }}</span></p>
<p><span class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Type :</span> <span class="font-bold">{{ contrat.typeLabel }}</span></p>
<p><span class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Cree le :</span> <span class="font-bold">{{ contrat.createdAt|date('d/m/Y H:i') }}</span></p>
{% if contrat.signedAt %}
<p><span class="text-[9px] font-bold uppercase tracking-wider text-gray-400">Signe le :</span> <span class="font-bold text-green-600">{{ contrat.signedAt|date('d/m/Y H:i') }}</span></p>
{% endif %}
</div>
</div>
</div>
{# Actions #}
<div class="flex flex-wrap gap-2 mb-6">
{% if contrat.pdfUnsigned %}
<a href="{{ vich_uploader_asset(contrat, 'pdfUnsignedFile') }}" target="_blank"
class="px-4 py-2 bg-gray-900 text-white font-bold uppercase text-[10px] tracking-wider hover:bg-[#fabf04] hover:text-gray-900 transition-all">
Voir PDF
</a>
{% endif %}
{% if contrat.pdfSigned %}
<a href="{{ vich_uploader_asset(contrat, 'pdfSignedFile') }}" target="_blank"
class="px-4 py-2 bg-green-500/20 text-green-700 font-bold uppercase text-[10px] tracking-wider hover:bg-green-500 hover:text-white transition-all">
Contrat signe
</a>
{% endif %}
{% if contrat.pdfAudit %}
<a href="{{ vich_uploader_asset(contrat, 'pdfAuditFile') }}" target="_blank"
class="px-4 py-2 bg-blue-500/20 text-blue-700 font-bold uppercase text-[10px] tracking-wider hover:bg-blue-500 hover:text-white transition-all">
Audit signature
</a>
{% endif %}
{% if contrat.state in ['draft', 'send'] %}
<form method="post" action="{{ path('app_admin_contrats_cancel', {id: contrat.id}) }}" data-confirm="Annuler ce contrat ?">
<button type="submit" class="px-4 py-2 bg-red-500/20 text-red-700 hover:bg-red-500 hover:text-white font-bold uppercase text-[10px] tracking-wider transition-all">Annuler</button>
</form>
{% endif %}
</div>
</div>
{% endblock %}