feat(facturation): Génère, enregistre et envoie les factures client par email

This commit is contained in:
Serreau Jovann
2026-02-12 15:53:07 +01:00
parent 6f96a3803e
commit a421416c93
10 changed files with 568 additions and 9 deletions

View File

@@ -0,0 +1,33 @@
<?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 Version20260212144324 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE facture ADD facture_file_name VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE facture ADD facture_file_size INT DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE facture DROP facture_file_name');
$this->addSql('ALTER TABLE facture DROP facture_file_size');
}
}

View File

@@ -7,6 +7,7 @@ use App\Entity\ContratsLine;
use App\Entity\ContratsOption;
use App\Entity\ContratsPayments;
use App\Entity\Devis;
use App\Entity\Facture;
use App\Event\Signature\ContratEvent;
use App\Form\Type\ContratsType;
use App\Logger\AppLogger;
@@ -14,6 +15,7 @@ use App\Repository\ContratsRepository;
use App\Repository\DevisRepository;
use App\Service\Mailer\Mailer;
use App\Service\Pdf\ContratPdfService;
use App\Service\Pdf\FacturePdfService;
use App\Service\Pdf\PlPdf;
use App\Service\Signature\Client;
use Doctrine\ORM\EntityManagerInterface;
@@ -70,7 +72,7 @@ class ContratsController extends AbstractController
}
#[Route('/view/{id}', name: 'app_crm_contrats_view', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function view(Contrats $contrat, Request $request): Response
public function view(Contrats $contrat,Mailer $mailer, Request $request): Response
{
// 1. Actions sur la Caution (Libérer / Encaisser)
if ($action = $request->query->get('action')) {
@@ -86,6 +88,45 @@ class ContratsController extends AbstractController
if ($request->query->has('act')) {
$act = $request->query->get('act');
if ($act === 'generateInvoice') {
$facturePdfService = new FacturePdfService($this->kernel,$contrat);
// Utilisation du service pour générer le binaire du PDF
$pdfContent = $facturePdfService->generate();
$filename = 'Facture_Ludikevent_' . $contrat->getNumReservation() . '.pdf';
$attachment = new \Symfony\Component\Mime\Part\DataPart($pdfContent, $filename, 'application/pdf');
$fc = $contrat->getFacture();
if(!$fc instanceof Facture){
$fc = new Facture();
$fc->setContrat($contrat);
$fc->setCreateAt(new \DateTimeImmutable('now'));
$fc->setUpdateAt(new \DateTimeImmutable());
$fc->setNum('F' . $contrat->getNumReservation());
$this->em->persist($fc);
}
$tmpPath = sys_get_temp_dir() . '/facture_' . uniqid() . '.pdf';
file_put_contents($tmpPath,$pdfContent); // generate() retourne le contenu ou Output('S')
$fc->setFactureFile(new UploadedFile($tmpPath,"facture.pdf","application/pdf",0,true));
$fc->setUpdateAt(new \DateTimeImmutable());
$this->em->persist($fc);
$this->em->flush();
$mailer->send(
$contrat->getCustomer()->getEmail(),
$contrat->getCustomer()->getSurname() . ' ' . $contrat->getCustomer()->getName(),
"Votre facture Ludikevent - #" . $contrat->getNumReservation(),
"mails/customer/invoice.twig", // Créez ce template simple
['contrat' => $contrat],
[$attachment] // Passage de la pièce jointe
);
return new Response($pdfContent, 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => HeaderUtils::makeDisposition(
HeaderUtils::DISPOSITION_INLINE,
'Facture_Ludikevent_' . $contrat->getNumReservation() . '.pdf'
)
]);
}
if ($act == "downloadFilePv" && $contrat->getEtatLieux()) {
$etatLieux = $contrat->getEtatLieux();

View File

@@ -4,6 +4,7 @@ namespace App\Controller\Dashboard;
use App\Logger\AppLogger;
use App\Repository\ContratsPaymentsRepository;
use App\Repository\FactureRepository;
use Doctrine\ORM\QueryBuilder;
use Knp\Component\Pager\PaginatorInterface;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
@@ -22,6 +23,7 @@ class FactureController extends AbstractController
{
public function __construct(
private readonly ContratsPaymentsRepository $contratsPaymentsRepo,
private readonly FactureRepository $factureRepository,
private readonly AppLogger $appLogger,
private readonly UploaderHelper $uploaderHelper
) {
@@ -43,9 +45,19 @@ class FactureController extends AbstractController
->setParameter('end', $endDate)
->orderBy('p.paymentAt', 'DESC');
$queryBuilderFacture = $this->factureRepository->createQueryBuilder('p')
->leftJoin('p.contrat', 'c')
->leftJoin('c.customer', 'u')
->where('p.createAt BETWEEN :start AND :end')
->setParameter('start', $startDate)
->setParameter('end', $endDate)
->orderBy('p.createAt', 'DESC');
if (!empty($searchTerm)) {
$queryBuilder->andWhere('u.name LIKE :q OR u.surname LIKE :q OR c.numReservation LIKE :q OR p.type LIKE :q')
->setParameter('q', '%' . $searchTerm . '%');
$queryBuilderFacture->andWhere('u.name LIKE :q OR u.surname LIKE :q OR c.numReservation LIKE :q OR p.type LIKE :q')
->setParameter('q', '%' . $searchTerm . '%');
}
// 3. Export Excel (Action Rapide)
@@ -58,6 +70,7 @@ class FactureController extends AbstractController
return $this->render('dashboard/contrats/facture.twig', [
'pagination' => $paginator->paginate($queryBuilder, $request->query->getInt('page', 1), 15),
'pagination2' => $paginator->paginate($queryBuilderFacture, $request->query->getInt('page', 1), 15),
'startDate' => $startDate->format('Y-m-d'),
'endDate' => $endDate->format('Y-m-d'),
'searchTerm' => $searchTerm,

View File

@@ -4,8 +4,12 @@ namespace App\Entity;
use App\Repository\FactureRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Attribute\Uploadable;
use Vich\UploaderBundle\Mapping\Attribute\UploadableField;
#[ORM\Entity(repositoryClass: FactureRepository::class)]
#[Uploadable]
class Facture
{
#[ORM\Id]
@@ -25,6 +29,12 @@ class Facture
#[ORM\Column]
private ?\DateTimeImmutable $updateAt = null;
#[UploadableField(mapping: 'contrat_docuseal', fileNameProperty: 'factureFileName', size: 'factureFileSize')]
private ?File $factureFile = null;
#[ORM\Column(nullable: true)]
private ?string $factureFileName = null;
#[ORM\Column(nullable: true)]
private ?int $factureFileSize = null;
public function getId(): ?int
{
return $this->id;
@@ -77,4 +87,52 @@ class Facture
return $this;
}
/**
* @return string|null
*/
public function getFactureFileName(): ?string
{
return $this->factureFileName;
}
/**
* @return File|null
*/
public function getFactureFile(): ?File
{
return $this->factureFile;
}
/**
* @return int|null
*/
public function getFactureFileSize(): ?int
{
return $this->factureFileSize;
}
/**
* @param string|null $factureFileName
*/
public function setFactureFileName(?string $factureFileName): void
{
$this->factureFileName = $factureFileName;
}
/**
* @param File|null $factureFile
*/
public function setFactureFile(?File $factureFile): void
{
$this->factureFile = $factureFile;
}
/**
* @param int|null $factureFileSize
*/
public function setFactureFileSize(?int $factureFileSize): void
{
$this->factureFileSize = $factureFileSize;
}
}

View File

@@ -0,0 +1,298 @@
<?php
namespace App\Service\Pdf;
use App\Entity\Contrats;
use Fpdf\Fpdf;
use Symfony\Component\HttpKernel\KernelInterface;
class FacturePdfService extends Fpdf
{
private Contrats $contrat;
private string $logo;
private bool $isExtraPage = false;
public function __construct(KernelInterface $kernel, Contrats $contrat, $orientation = 'P', $unit = 'mm', $size = 'A4')
{
parent::__construct($orientation, $unit, $size);
$this->contrat = $contrat;
$this->logo = $kernel->getProjectDir()."/public/provider/images/favicon.png";
$this->AliasNbPages();
$this->SetAutoPageBreak(true, 30);
}
protected function clean(?string $text): string
{
if (!$text) return '';
$text = iconv('UTF-8', 'windows-1252//TRANSLIT//IGNORE', $text);
return str_replace('€', chr(128), $text);
}
protected function euro(): string
{
return ' ' . chr(128);
}
public function Header()
{
if ($this->page > 0 && !$this->isExtraPage) {
$this->SetY(10);
if (file_exists($this->logo)) {
$this->Image($this->logo, 10, 10, 12);
$this->SetX(25);
}
$this->SetFont('Arial', 'B', 14);
$this->Cell(0, 7, $this->clean('Lilian SEGARD - Ludikevent'), 0, 1, 'L');
$this->SetX(25);
$this->SetFont('Arial', '', 8);
$this->SetTextColor(80, 80, 80);
$this->Cell(0, 4, $this->clean('SIRET : 930 488 408 00012 | RCS : 930 488 408'), 0, 1, 'L');
$this->SetX(25);
$this->Cell(0, 4, $this->clean('6 Rue du Château 02800 Danizy France'), 0, 1, 'L');
$this->SetX(25);
$this->Cell(0, 4, $this->clean('contact@ludikevent.fr | 06 14 17 24 47'), 0, 1, 'L');
$this->SetY(40);
$this->SetFont('Arial', 'B', 18);
$this->SetTextColor(37, 99, 235);
// On utilise le numéro de réservation comme base de numéro de facture
$this->Cell(0, 10, $this->clean('FACTURE N° F' . $this->contrat->getNumReservation()), 0, 1, 'L');
$this->SetFont('Arial', '', 10);
$this->SetTextColor(0, 0, 0);
$this->Cell(0, 5, $this->clean('Date de facturation : ' . date('d/m/Y')), 0, 1, 'L');
$this->Cell(0, 5, $this->clean('Référence commande : #' . $this->contrat->getNumReservation()), 0, 1, 'L');
$this->Ln(5);
$this->SetDrawColor(37, 99, 235);
$this->SetLineWidth(0.5);
$this->Line(10, $this->GetY(), 200, $this->GetY());
}
}
public function generate(): string
{
$this->AddPage();
$this->renderCustomerBlock();
$this->renderInvoiceTable();
$this->renderPaymentsHistory();
$this->renderFooterMentions();
return $this->Output('S');
}
private function renderCustomerBlock(): void
{
$this->SetY(65);
$this->SetFont('Arial', 'B', 10);
$this->SetFillColor(245, 245, 245);
$this->Cell(95, 7, $this->clean(" FACTURÉ À"), 0, 0, 'L', true);
$this->Cell(5, 7, "", 0, 0);
$this->Cell(95, 7, $this->clean(" DÉTAILS ÉVÉNEMENT"), 0, 1, 'L', true);
$this->Ln(2);
$startY = $this->GetY();
$this->SetFont('Arial', '', 10);
// Client
$this->SetX(10);
$customer = $this->contrat->getCustomer();
$this->MultiCell(95, 5, $this->clean(
$customer->getSurname() . ' ' . $customer->getName() . "\n" .
$this->contrat->getAddressEvent() . "\n" .
$this->contrat->getZipCodeEvent() . " " . $this->contrat->getTownEvent()
), 0, 'L');
// Event
$this->SetXY(110, $startY);
$this->MultiCell(95, 5, $this->clean(
"Date début : " . $this->contrat->getDateAt()->format('d/m/Y') . "\n" .
"Date fin : " . $this->contrat->getEndAt()->format('d/m/Y') . "\n" .
"Lieu : " . $this->contrat->getTownEvent()
), 0, 'L');
$this->Ln(10);
}
private function renderInvoiceTable(): void
{
$tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true";
$tvaRate = $tvaEnabled ? 0.20 : 0;
$this->SetFont('Arial', 'B', 9);
$this->SetFillColor(37, 99, 235);
$this->SetTextColor(255, 255, 255);
$this->Cell(110, 8, $this->clean("Désignation"), 1, 0, 'L', true);
$this->Cell(40, 8, $this->clean("Quantité / Durée"), 1, 0, 'C', true);
$this->Cell(40, 8, $this->clean("Total HT"), 1, 1, 'C', true);
$this->SetTextColor(0, 0, 0);
$this->SetFont('Arial', '', 9);
$totalHt = 0;
$devis = $this->contrat->getDevis();
$formule = $devis?->getFormule();
$interval = $this->contrat->getDateAt()->diff($this->contrat->getEndAt());
$nbJours = $interval->days + 1;
// Logique Formule ou Lignes standards
if ($formule) {
$fPrice = $formule->getPrice1j() ?? 0;
if ($nbJours >= 2 && $formule->getPrice2j()) $fPrice = $formule->getPrice2j();
if ($nbJours >= 5 && $formule->getPrice5j()) $fPrice = $formule->getPrice5j();
$this->Cell(110, 7, $this->clean("Location Formule : " . $formule->getName()), 1, 0, 'L');
$this->Cell(40, 7, $nbJours . " jour(s)", 1, 0, 'C');
$this->Cell(40, 7, number_format($fPrice, 2, ',', ' ') . $this->euro(), 1, 1, 'R');
$totalHt += $fPrice;
}
foreach ($this->contrat->getContratsLines() as $line) {
if (!$formule) {
$price = $line->getPrice1DayHt() + ($line->getPriceSupDayHt() * ($nbJours - 1));
$this->Cell(110, 7, $this->clean($line->getName()), 1, 0, 'L');
$this->Cell(40, 7, $nbJours . " jour(s)", 1, 0, 'C');
$this->Cell(40, 7, number_format($price, 2, ',', ' ') . $this->euro(), 1, 1, 'R');
$totalHt += $price;
} else {
// Si formule, les lignes sont listées à 0 pour info
$this->SetTextColor(100, 100, 100);
$this->Cell(110, 6, $this->clean(" - " . $line->getName()), 1, 0, 'L');
$this->Cell(40, 6, "Inclus", 1, 0, 'C');
$this->Cell(40, 6, "0,00" . $this->euro(), 1, 1, 'R');
$this->SetTextColor(0, 0, 0);
}
}
foreach ($this->contrat->getContratsOptions() as $opt) {
$priceOpt = ($formule && stripos($opt->getName(), 'livraison') === false) ? 0 : $opt->getPrice();
$this->Cell(110, 7, $this->clean("Option : " . $opt->getName()), 1, 0, 'L');
$this->Cell(40, 7, "Forfait", 1, 0, 'C');
$this->Cell(40, 7, number_format($priceOpt, 2, ',', ' ') . $this->euro(), 1, 1, 'R');
$totalHt += $priceOpt;
}
// Remises
$promotion = $devis?->getOrderSession()?->getPromotion();
if ($promotion) {
$discount = $totalHt * (($promotion['percentage'] ?? 0) / 100);
$this->SetTextColor(37, 99, 235);
$this->Cell(150, 7, $this->clean("Remise : " . $promotion['name'] . " (-" . $promotion['percentage'] . "%)"), 1, 0, 'L');
$this->Cell(40, 7, "-" . number_format($discount, 2, ',', ' ') . $this->euro(), 1, 1, 'R');
$totalHt -= $discount;
$this->SetTextColor(0, 0, 0);
}
// Totaux
$this->Ln(5);
$this->SetX(120);
$this->SetFont('Arial', 'B', 10);
$this->Cell(40, 8, $this->clean("TOTAL HT"), 0, 0, 'L');
$this->Cell(30, 8, number_format($totalHt, 2, ',', ' ') . $this->euro(), 0, 1, 'R');
if ($tvaEnabled) {
$tvaVal = $totalHt * $tvaRate;
$this->SetX(120);
$this->SetFont('Arial', '', 10);
$this->Cell(40, 8, $this->clean("TVA (20%)"), 0, 0, 'L');
$this->Cell(30, 8, number_format($tvaVal, 2, ',', ' ') . $this->euro(), 0, 1, 'R');
$totalTtc = $totalHt + $tvaVal;
} else {
$totalTtc = $totalHt;
}
$this->SetX(120);
$this->SetFont('Arial', 'B', 12);
$this->SetFillColor(37, 99, 235); $this->SetTextColor(255, 255, 255);
$this->Cell(40, 10, $this->clean(" TOTAL TTC"), 0, 0, 'L', true);
$this->Cell(30, 10, number_format($totalTtc, 2, ',', ' ') . $this->euro(), 0, 1, 'R', true);
$this->SetTextColor(0, 0, 0);
}
private function renderPaymentsHistory(): void
{
$this->Ln(10);
$this->SetFont('Arial', 'B', 10);
$this->Cell(0, 7, $this->clean("RÈGLEMENTS REÇUS"), 'B', 1, 'L');
$this->Ln(2);
$this->SetFont('Arial', '', 9);
$totalPaid = 0;
foreach ($this->contrat->getContratsPayments() as $payment) {
if ($payment->getType() !== 'caution' && str_contains(strtolower($payment->getState()), 'complete')) {
$this->Cell(50, 6, $payment->getPaymentAt()->format('d/m/Y'), 0, 0, 'L');
$this->Cell(100, 6, $this->clean("Paiement " . $payment->getType()), 0, 0, 'L');
$this->Cell(40, 6, number_format($payment->getAmount(), 2, ',', ' ') . $this->euro(), 0, 1, 'R');
$totalPaid += $payment->getAmount();
}
}
$this->Ln(2);
$this->SetFont('Arial', 'B', 11);
$solde = $this->getTotalTtc() - $totalPaid;
$this->Cell(150, 8, $this->clean("SOLDE À PAYER"), 0, 0, 'R');
if($solde <=0.05){
$this->SetTextColor(16,185,129);
} else {
$this->SetTextColor(200,38,38);
}
$this->Cell(40, 8, number_format(max(0, $solde), 2, ',', ' ') . $this->euro(), 0, 1, 'R');
$this->SetTextColor(0, 0, 0);
}
private function getTotalTtc(): float
{
$totalHt = 0;
$nbJours = $this->contrat->getDateAt()->diff($this->contrat->getEndAt())->days + 1;
$devis = $this->contrat->getDevis();
$formule = $devis?->getFormule();
if ($formule) {
$fPrice = $formule->getPrice1j() ?? 0;
if ($nbJours >= 2 && $formule->getPrice2j()) $fPrice = $formule->getPrice2j();
if ($nbJours >= 5 && $formule->getPrice5j()) $fPrice = $formule->getPrice5j();
$totalHt += $fPrice;
}
foreach ($this->contrat->getContratsLines() as $line) {
if (!$formule) $totalHt += $line->getPrice1DayHt() + ($line->getPriceSupDayHt() * ($nbJours - 1));
}
foreach ($this->contrat->getContratsOptions() as $opt) {
$totalHt += ($formule && stripos($opt->getName(), 'livraison') === false) ? 0 : $opt->getPrice();
}
if ($devis && $devis->getOrderSession() && $devis->getOrderSession()->getPromotion()) {
$totalHt -= $totalHt * (($devis->getOrderSession()->getPromotion()['percentage'] ?? 0) / 100);
}
$tvaEnabled = isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true";
return $tvaEnabled ? $totalHt * 1.20 : $totalHt;
}
private function renderFooterMentions(): void
{
$this->SetY(-40);
$this->SetFont('Arial', 'I', 8);
$this->SetTextColor(100, 100, 100);
if (!(isset($_ENV['TVA_ENABLED']) && $_ENV['TVA_ENABLED'] === "true")) {
$this->Cell(0, 4, $this->clean("TVA non applicable, art. 293 B du CGI"), 0, 1, 'C');
}
$this->Cell(0, 4, $this->clean("En votre aimable règlement. Cordialement,"), 0, 1, 'C');
$this->SetFont('Arial', 'B', 8);
$this->Cell(0, 4, $this->clean("Ludikevent - Lilian SEGARD"), 0, 1, 'C');
}
public function Footer()
{
$this->SetY(-15);
$this->SetFont('Arial', 'I', 7);
$this->SetTextColor(150, 150, 150);
$this->Cell(0, 10, $this->clean('Facture F' . $this->contrat->getNumReservation() . ' - Page ' . $this->PageNo() . '/{nb}'), 0, 0, 'C');
}
}

View File

@@ -46,16 +46,16 @@
{% import _self as menu %}
{{ menu.nav_link(path('app_crm'), 'Dashboard', '<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />', 'app_crm') }}
{# {{ menu.nav_link(path('app_crm_reservation'), 'Planing de réservation', '<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />', 'app_crm_reservation') }}#}
{{ menu.nav_link(path('app_crm_reservation'), 'Planing de réservation', '<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" />', 'app_crm_reservation') }}
{{ menu.nav_link(path('app_template_point_controle_index'), 'Modèles de contrôle', '<path stroke-linecap="round" stroke-linejoin="round" d="M11.35 3.836c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />', 'app_template_point_controle_index') }}
{{ menu.nav_link(path('app_crm_product'), 'Produits', '<path stroke-linecap="round" stroke-linejoin="round" d="M20.25 7.5l-.625 10.632a2.25 2.25 0 01-2.247 2.118H6.622a2.25 2.25 0 01-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />', 'app_crm_product') }}
{{ menu.nav_link(path('app_crm_formules'), 'Formules', '<path stroke-linecap="round" stroke-linejoin="round" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" />', 'app_crm_formules') }}
{# {{ menu.nav_link(path('app_crm_promotion'), 'Promotions', '<path stroke-linecap="round" stroke-linejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />', 'app_crm_promotion') }}#}
{# {{ menu.nav_link(path('app_crm_facture'), 'Facture', '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />', 'app_crm_facture') }}#}
{{ menu.nav_link(path('app_crm_promotion'), 'Promotions', '<path stroke-linecap="round" stroke-linejoin="round" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />', 'app_crm_promotion') }}
{{ menu.nav_link(path('app_crm_facture'), 'Facture', '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />', 'app_crm_facture') }}
{{ menu.nav_link(path('app_crm_customer'), 'Clients', '<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />', 'app_crm_customer') }}
{# {{ menu.nav_link(path('app_crm_devis'), 'Devis', '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3" />', 'app_crm_devis') }}#}
{# {{ menu.nav_link(path('app_crm_contrats'), 'Contrat de location', '<path stroke-linecap="round" stroke-linejoin="round" d="M11.35 3.836c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />', 'app_crm_contrats') }}#}
{# {{ menu.nav_link(path('app_crm_prestataire'), 'Prestataires', '<path stroke-linecap="round" stroke-linejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>', 'app_crm_prestataire') }}#}
{{ menu.nav_link(path('app_crm_devis'), 'Devis', '<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3" />', 'app_crm_devis') }}
{{ menu.nav_link(path('app_crm_contrats'), 'Contrat de location', '<path stroke-linecap="round" stroke-linejoin="round" d="M11.35 3.836c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />', 'app_crm_contrats') }}
{{ menu.nav_link(path('app_crm_prestataire'), 'Prestataires', '<path stroke-linecap="round" stroke-linejoin="round" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>', 'app_crm_prestataire') }}
{% set pendingCount = getPendingOrderSessionCount() %}
<a data-turbo="false" href="{{ path('app_crm_flow') }}" class="flex items-center justify-between px-4 py-3 rounded-xl transition-all duration-200 group {{ app.current_route == 'app_crm_flow' ? 'bg-blue-600 text-white shadow-lg shadow-blue-500/30' : 'hover:bg-slate-800 text-slate-400' }}">

View File

@@ -111,12 +111,62 @@
<tr class="border-b border-white/5">
<th class="pb-6 text-[10px] font-black text-slate-300 uppercase tracking-widest">Date</th>
<th class="pb-6 text-[10px] font-black text-slate-300 uppercase tracking-widest">Client</th>
<th class="pb-6 text-[10px] font-black text-slate-300 uppercase tracking-widest text-right">Montant TTC</th>
<th class="pb-6 text-[10px] font-black text-slate-300 uppercase tracking-widest text-center">Statut</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5 text-slate-400 italic text-xs">
<tr><td colspan="4" class="py-10 text-center uppercase tracking-widest opacity-30">Section factures en développement</td></tr>
{% for confirmedPaiement in pagination2 %}
<tr class="group bg-white/[0.02] hover:bg-white/[0.05] transition-all duration-300">
<td class="py-5 pl-6 rounded-l-2xl border-y border-l border-white/5">
<div class="flex flex-col">
<span class="text-xs font-bold text-white">{{ confirmedPaiement.createAt|date('d/m/Y') }}</span>
<span class="text-[9px] text-slate-300 font-medium">Encaissé</span>
</div>
</td>
<td class="py-5 border-y border-white/5">
<div class="flex flex-col">
<span class="text-xs font-bold text-slate-200 uppercase tracking-tight">
{{ confirmedPaiement.contrat.customer.name }} {{ confirmedPaiement.contrat.customer.surname }}
</span>
<span class="text-[10px text-slate-300 lowercase">{{ confirmedPaiement.contrat.customer.email }}</span>
</div>
</td>
<td class="py-5 border-y border-white/5">
<span class="px-3 py-1.5 bg-slate-900/50 rounded-lg border border-white/5 text-[10px] font-mono font-bold text-blue-400">
{{ confirmedPaiement.contrat.numReservation }}
</span>
</td>
<td class="py-5 pr-6 rounded-r-2xl border-y border-r border-white/5 text-right">
{% if confirmedPaiement.factureFileName !="" %}
<a href="{{ vich_uploader_asset(confirmedPaiement, 'factureFile') }}"
download
class="inline-flex p-2.5 bg-emerald-500/10 hover:bg-emerald-500 text-emerald-500 hover:text-white rounded-xl transition-all duration-300 border border-emerald-500/20 group/btn"
title="Télécharger le justificatif">
<svg class="w-4 h-4 transform group-hover/btn:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
</a>
{% else %}
<span class="text-[9px] font-black text-slate-600 uppercase tracking-tighter italic">Aucun fichier</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="6" class="py-20 text-center border-2 border-dashed border-white/5 rounded-[2rem]">
<div class="flex flex-col items-center">
<svg class="w-10 h-10 text-slate-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"/>
</svg>
<p class="text-slate-500 text-[10px] font-black uppercase tracking-[0.3em]">Aucune donnée trouvée pour cette recherche</p>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>

View File

@@ -95,6 +95,7 @@
'edl_validated': { 'color': 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30', 'label': '✅ EDL Validé' },
'return_edl_progress': { 'color': 'bg-orange-500/10 text-orange-400 border-orange-500/20', 'label': '🔍 EDL Retour...' },
'edl_return_done': { 'color': 'bg-purple-500/10 text-purple-400 border-purple-500/20', 'label': '📦 Retour Effectué' },
'edl_return_finised': { 'color': 'bg-purple-500/10 text-purple-400 border-purple-500/20', 'label': '📦 Retour Effectué' },
'edl_return_refused': { 'color': 'bg-red-600 text-white border-red-500', 'label': '❌ Signature Refusée' }
} %}

View File

@@ -4,6 +4,16 @@
{% block actions %}
<div class="flex items-center gap-3">
{# --- BOUTON FACTURATION (Nouveau) --- #}
{% if contrat.etatLieux and contrat.etatLieux.status in ['edl_return_done', 'edl_return_refused', 'edl_return_finised'] %}
<a target="_blank" href="{{ path('app_crm_contrats_view', {id: contrat.id, act: 'generateInvoice'}) }}"
class="flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-700 border border-blue-500 rounded-xl text-white text-[10px] font-black uppercase italic transition-all shadow-lg shadow-blue-500/20 group">
<svg class="w-4 h-4 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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>
Générer la Facture
</a>
{% endif %}
{% if contrat.signed %}
<a href="{{ vich_uploader_asset(contrat,'devisSignFile') }}" download
class="flex items-center gap-2 px-6 py-2.5 bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/20 backdrop-blur-md rounded-xl text-emerald-400 text-[10px] font-black uppercase italic transition-all group">

View File

@@ -0,0 +1,55 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-section padding-bottom="0px">
<mj-column>
<mj-text font-size="20px" font-weight="black" color="#2563eb" text-transform="uppercase" letter-spacing="1px">
Votre Facture
</mj-text>
<mj-text font-size="14px" color="#475569" padding-top="0px">
Référence : #{{ datas.contrat.numReservation }}
</mj-text>
</mj-column>
</mj-section>
<mj-section>
<mj-column>
<mj-text>
Bonjour <strong>{{ datas.contrat.customer.surname }} {{ datas.contrat.customer.name }}</strong>,
</mj-text>
<mj-text line-height="1.6">
Nous vous remercions une nouvelle fois pour votre confiance. Votre prestation étant terminée, vous trouverez en pièce jointe de cet e-mail votre <strong>facture officielle</strong> au format PDF.
</mj-text>
<mj-divider border-width="1px" border-color="#e2e8f0" padding="20px 0px" />
<mj-text font-weight="bold" font-size="14px" color="#1e293b">
Résumé de votre réservation :
</mj-text>
<mj-text padding-bottom="5px">
• <strong>Événement :</strong> {{ datas.contrat.townEvent }}<br>
• <strong>Date :</strong> {{ datas.contrat.dateAt|date('d/m/Y') }}<br>
• <strong>Statut :</strong> Prestation terminée et réglée
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#f8fafc" border-radius="12px">
<mj-column>
<mj-text align="center" font-style="italic" color="#64748b" font-size="13px">
"Nous espérons que nos structures et nos services ont contribué à la réussite de votre événement !"
</mj-text>
</mj-column>
</mj-section>
<mj-section padding-top="0px">
<mj-column>
<mj-text align="center" color="#94a3b8" font-size="12px">
Si vous avez des questions concernant cette facture, n'hésitez pas à nous contacter à contact@ludikevent.fr.
</mj-text>
<mj-text align="center" font-weight="bold" color="#1e293b" padding-top="20px">
Lilian - Ludikevent
</mj-text>
</mj-column>
</mj-section>
{% endblock %}