feat(Product.php): Ajoute la relation avec ProductReserve.

 feat(DevisSubscriber.php): Crée un subscriber pour l'envoi de devis.

 feat(Devis.php): Ajoute la relation avec ProductReserve.

 feat: Crée le template de mail pour la notification de signature.

 feat(DevisSend.php): Crée l'événement DevisSend.

 feat(Customer.php): Ajoute la relation avec ProductReserve.

🐛 fix(SignatureController.php): Corrige la gestion de la signature complétée.

 feat(DevisController.php): Ajoute la relance de signature et pagination.

 feat: Crée le template de mail pour l'envoi du devis à signer.

 feat: Crée le template de mail pour la confirmation de signature.

 feat(Client.php): Gère la création et le suivi de la signature DocuSeal.

 feat(DevisPdfService.php): Intègre les champs Docuseal.

 feat(list.twig): Affiche la liste des devis avec actions et statuts.

 feat: Crée la page de succès de signature.

 feat(StripeExtension.php): Ajoute le filtre totalQuoto pour calculer le total HT.
```
This commit is contained in:
Serreau Jovann
2026-01-19 19:40:27 +01:00
parent 0afc9e3396
commit cd45a37d73
19 changed files with 879 additions and 55 deletions

View File

@@ -0,0 +1,43 @@
<?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 Version20260119183356 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('CREATE TABLE product_reserve (id SERIAL NOT NULL, product_id INT DEFAULT NULL, customer_id INT DEFAULT NULL, devis_id INT DEFAULT NULL, start_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, end_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_CE39F1924584665A ON product_reserve (product_id)');
$this->addSql('CREATE INDEX IDX_CE39F1929395C3F3 ON product_reserve (customer_id)');
$this->addSql('CREATE UNIQUE INDEX UNIQ_CE39F19241DEFADA ON product_reserve (devis_id)');
$this->addSql('COMMENT ON COLUMN product_reserve.start_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN product_reserve.end_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE product_reserve ADD CONSTRAINT FK_CE39F1924584665A FOREIGN KEY (product_id) REFERENCES product (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE product_reserve ADD CONSTRAINT FK_CE39F1929395C3F3 FOREIGN KEY (customer_id) REFERENCES customer (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE product_reserve ADD CONSTRAINT FK_CE39F19241DEFADA FOREIGN KEY (devis_id) REFERENCES devis (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE product_reserve DROP CONSTRAINT FK_CE39F1924584665A');
$this->addSql('ALTER TABLE product_reserve DROP CONSTRAINT FK_CE39F1929395C3F3');
$this->addSql('ALTER TABLE product_reserve DROP CONSTRAINT FK_CE39F19241DEFADA');
$this->addSql('DROP TABLE product_reserve');
}
}

View File

@@ -0,0 +1,31 @@
<?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 Version20260119183526 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
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Controller\Dashboard;
use App\Entity\CustomerAddress;
use App\Entity\Devis;
use App\Entity\DevisLine;
use App\Event\Signature\DevisSend;
use App\Form\NewDevisType;
use App\Logger\AppLogger;
use App\Repository\AccountRepository;
@@ -18,6 +19,7 @@ use Doctrine\ORM\EntityManagerInterface;
use Knp\Bundle\PaginatorBundle\KnpPaginatorBundle;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
@@ -30,16 +32,52 @@ class DevisController extends AbstractController
/**
* Liste des administrateurs
*/
#[Route(path: '/crm/devis', name: 'app_crm_devis', options: ['sitemap' => false], methods: ['GET'])]
public function devis(Client $client,EntityManagerInterface $entityManager,KernelInterface $kernel,DevisRepository $devisRepository,AppLogger $appLogger,PaginatorInterface $paginator,Request $request): Response
{
#[Route(path: '/crm/devis', name: 'app_crm_devis', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function devis(
EventDispatcherInterface $eventDispatcher,
EntityManagerInterface $entityManager,
DevisRepository $devisRepository,
AppLogger $appLogger,
PaginatorInterface $paginator,
Request $request,
): Response {
// Gestion du renvoi de la signature
if ($request->query->has('resend')) {
$quoteId = $request->query->get('resend');
$quote = $devisRepository->find($quoteId);
if ($quote instanceof Devis) {
// Déclenchement de l'événement de renvoi
$event = new DevisSend($quote);
$eventDispatcher->dispatch($event);
// Journalisation et notification
$appLogger->record('RESEND', 'Relance signature pour le devis ' . $quote->getNum());
$this->addFlash("success", "Le lien de signature pour le devis " . $quote->getNum() . " a été renvoyé au client.");
return $this->redirectToRoute('app_crm_devis');
}
$this->addFlash("error", "Devis introuvable.");
}
$appLogger->record('VIEW', 'Consultation de la liste des devis');
return $this->render('dashboard/devis/list.twig',[
'quotes' => $paginator->paginate($devisRepository->findBy([],['createA'=>'asc']),$request->get('page', 1),20),
// Pagination (Tri décroissant sur la date de création pour voir les plus récents en premier)
$pagination = $paginator->paginate(
$devisRepository->findBy([], ['createA' => 'DESC']),
$request->query->getInt('page', 1),
20
);
return $this->render('dashboard/devis/list.twig', [
'quotes' => $pagination,
]);
}
#[Route(path: '/crm/devis/add', name: 'app_crm_devis_add', options: ['sitemap' => false], methods: ['GET','POST'])]
public function devisAdd(Client $client,KernelInterface $kernel,CustomerAddressRepository $customerAddress,ProductRepository $productRepository,EntityManagerInterface $entityManager,CustomerRepository $customerRepository,DevisRepository $devisRepository, AppLogger $appLogger,Request $request): Response
public function devisAdd(Client $client,EventDispatcherInterface $eventDispatcher,KernelInterface $kernel,CustomerAddressRepository $customerAddress,ProductRepository $productRepository,EntityManagerInterface $entityManager,CustomerRepository $customerRepository,DevisRepository $devisRepository, AppLogger $appLogger,Request $request): Response
{
$devisNumber ="DEVIS-".sprintf('%05d',$devisRepository->count()+1);
$appLogger->record('VIEW', 'Consultation de la création d\'un devis');
@@ -94,6 +132,9 @@ class DevisController extends AbstractController
$devis->setUpdateAt(new \DateTimeImmutable());
$entityManager->flush();
$client->createSubmissionDevis($devis);
$event = new DevisSend($devis);
$eventDispatcher->dispatch($event);
return $this->redirectToRoute('app_crm_devis');
}
return $this->render('dashboard/devis/add.twig',[

View File

@@ -4,18 +4,25 @@ namespace App\Controller;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Entity\Devis;
use App\Entity\ProductReserve;
use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType;
use App\Logger\AppLogger;
use App\Repository\DevisRepository;
use App\Service\Mailer\Mailer;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
use App\Service\ResetPassword\Event\ResetPasswordEvent;
use App\Service\Signature\Client;
use Doctrine\ORM\EntityManagerInterface;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
@@ -26,9 +33,104 @@ class SignatureController extends AbstractController
{
#[Route('/signature/complete', name: 'app_sign_complete')]
public function appSignComplete()
{
public function appSignComplete(
Client $client,
DevisRepository $devisRepository,
EntityManagerInterface $entityManager,
Request $request,
Mailer $mailer,
): Response {
if ($request->get('type') === "devis") {
$devis = $devisRepository->find($request->get('id'));
if (!$devis) {
throw $this->createNotFoundException("Devis introuvable.");
}
// On évite de retraiter un devis déjà marqué comme signé
if ($devis->getState() === 'signed') {
return $this->render('sign/sign_success.twig', ['devis' => $devis]);
}
$submiter = $client->getSubmiter($devis->getSignatureId());
$submission = $client->getSubmition($submiter['submission_id']);
if ($submission['status'] === "completed") {
$devis->setState("signed");
$auditUrl = $submission['audit_log_url'];
$signedDocUrl = $submission['documents'][0]['url'];
try {
// 1. Gestion du PDF SIGNÉ
$tmpSigned = sys_get_temp_dir() . '/sign_' . uniqid() . '.pdf';
$signedContent = file_get_contents($signedDocUrl);
file_put_contents($tmpSigned, $signedContent);
// On utilise UploadedFile pour simuler un upload propre pour VichUploader
$devis->setDevisSignFile(new UploadedFile($tmpSigned, "sign-" . $devis->getNum() . ".pdf", "application/pdf", null, true));
// 2. Gestion de l'AUDIT LOG
$tmpAudit = sys_get_temp_dir() . '/audit_' . uniqid() . '.pdf';
$auditContent = file_get_contents($auditUrl);
file_put_contents($tmpAudit, $auditContent);
$devis->setDevisAuditFile(new UploadedFile($tmpAudit, "audit-" . $devis->getNum() . ".pdf", "application/pdf", null, true));
// 3. Préparation des pièces jointes pour le mail (Le PDF signé est le plus important)
$attachments = [
new DataPart($signedContent, "Devis-" . $devis->getNum() . "-Signe.pdf", "application/pdf"),
new DataPart($auditContent, "Certificat-Signature-" . $devis->getNum() . ".pdf", "application/pdf"),
];
// 4. Sauvegarde en base de données
$devis->setUpdateAt(new \DateTimeImmutable());
foreach ($devis->getDevisLines() as $line) {
$product = $line->getProduct();
$productReserve = new ProductReserve();
$productReserve->setProduct($product);
$productReserve->setCustomer($devis->getCustomer());
$productReserve->setStartAt($line->getStartAt());
$productReserve->setEndAt($line->getEndAt());
$productReserve->setDevis($devis);
$entityManager->persist($productReserve);
}
$entityManager->persist($devis);
$entityManager->flush();
// 5. Envoi du mail de confirmation avec le récapitulatif
$mailer->send(
$devis->getCustomer()->getEmail(),
$devis->getCustomer()->getName() . " " . $devis->getCustomer()->getSurname(),
"[Ludikevent] Confirmation de signature - Devis " . $devis->getNum(),
"mails/sign/signed.twig",
[
'devis' => $devis // Correction ici : passage de l'objet, pas d'un string
],
$attachments
);
$mailer->send(
"contact@ludikevent.fr",
"Ludikevent",
"[Intranet Ludikevent] Confirmation signature d'un client pour - Devis " . $devis->getNum(),
"mails/sign/signed_notification.twig",
[
'devis' => $devis // Correction ici : passage de l'objet, pas d'un string
],
$attachments
);
} catch (\Exception $e) {
return new Response("Erreur lors de la récupération ou de l'envoi des documents : " . $e->getMessage(), 500);
}
}
}
return $this->render('sign/sign_success.twig', [
'devis' => $devis ?? null
]);
}
}

View File

@@ -51,10 +51,17 @@ class Customer
#[ORM\OneToMany(targetEntity: Devis::class, mappedBy: 'customer')]
private Collection $devis;
/**
* @var Collection<int, ProductReserve>
*/
#[ORM\OneToMany(targetEntity: ProductReserve::class, mappedBy: 'customer')]
private Collection $productReserves;
public function __construct()
{
$this->customerAddresses = new ArrayCollection();
$this->devis = new ArrayCollection();
$this->productReserves = new ArrayCollection();
}
public function getId(): ?int
@@ -217,4 +224,34 @@ class Customer
return $this;
}
/**
* @return Collection<int, ProductReserve>
*/
public function getProductReserves(): Collection
{
return $this->productReserves;
}
public function addProductReserf(ProductReserve $productReserf): static
{
if (!$this->productReserves->contains($productReserf)) {
$this->productReserves->add($productReserf);
$productReserf->setCustomer($this);
}
return $this;
}
public function removeProductReserf(ProductReserve $productReserf): static
{
if ($this->productReserves->removeElement($productReserf)) {
// set the owning side to null (unless already changed)
if ($productReserf->getCustomer() === $this) {
$productReserf->setCustomer(null);
}
}
return $this;
}
}

View File

@@ -77,6 +77,9 @@ class Devis
#[ORM\ManyToOne(inversedBy: 'devisBill')]
private ?CustomerAddress $billAddress = null;
#[ORM\OneToOne(mappedBy: 'devis', cascade: ['persist', 'remove'])]
private ?ProductReserve $productReserve = null;
public function __construct()
{
$this->devisLines = new ArrayCollection();
@@ -425,4 +428,26 @@ class Devis
return $this;
}
public function getProductReserve(): ?ProductReserve
{
return $this->productReserve;
}
public function setProductReserve(?ProductReserve $productReserve): static
{
// unset the owning side of the relation if necessary
if ($productReserve === null && $this->productReserve !== null) {
$this->productReserve->setDevis(null);
}
// set the owning side of the relation if necessary
if ($productReserve !== null && $productReserve->getDevis() !== $this) {
$productReserve->setDevis($this);
}
$this->productReserve = $productReserve;
return $this;
}
}

View File

@@ -61,9 +61,16 @@ class Product
#[ORM\OneToMany(targetEntity: DevisLine::class, mappedBy: 'product')]
private Collection $devisLines;
/**
* @var Collection<int, ProductReserve>
*/
#[ORM\OneToMany(targetEntity: ProductReserve::class, mappedBy: 'product')]
private Collection $productReserves;
public function __construct()
{
$this->devisLines = new ArrayCollection();
$this->productReserves = new ArrayCollection();
}
public function getId(): ?int
@@ -232,4 +239,34 @@ class Product
return $this;
}
/**
* @return Collection<int, ProductReserve>
*/
public function getProductReserves(): Collection
{
return $this->productReserves;
}
public function addProductReserf(ProductReserve $productReserf): static
{
if (!$this->productReserves->contains($productReserf)) {
$this->productReserves->add($productReserf);
$productReserf->setProduct($this);
}
return $this;
}
public function removeProductReserf(ProductReserve $productReserf): static
{
if ($this->productReserves->removeElement($productReserf)) {
// set the owning side to null (unless already changed)
if ($productReserf->getProduct() === $this) {
$productReserf->setProduct(null);
}
}
return $this;
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Entity;
use App\Repository\ProductReserveRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ProductReserveRepository::class)]
class ProductReserve
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'productReserves')]
private ?Product $product = null;
#[ORM\Column]
private ?\DateTimeImmutable $startAt = null;
#[ORM\Column]
private ?\DateTimeImmutable $endAt = null;
#[ORM\ManyToOne(inversedBy: 'productReserves')]
private ?Customer $customer = null;
#[ORM\OneToOne(inversedBy: 'productReserve', cascade: ['persist', 'remove'])]
private ?Devis $devis = null;
public function getId(): ?int
{
return $this->id;
}
public function getProduct(): ?Product
{
return $this->product;
}
public function setProduct(?Product $product): static
{
$this->product = $product;
return $this;
}
public function getStartAt(): ?\DateTimeImmutable
{
return $this->startAt;
}
public function setStartAt(\DateTimeImmutable $startAt): static
{
$this->startAt = $startAt;
return $this;
}
public function getEndAt(): ?\DateTimeImmutable
{
return $this->endAt;
}
public function setEndAt(\DateTimeImmutable $endAt): static
{
$this->endAt = $endAt;
return $this;
}
public function getCustomer(): ?Customer
{
return $this->customer;
}
public function setCustomer(?Customer $customer): static
{
$this->customer = $customer;
return $this;
}
public function getDevis(): ?Devis
{
return $this->devis;
}
public function setDevis(?Devis $devis): static
{
$this->devis = $devis;
return $this;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Event\Signature;
use App\Entity\Devis;
class DevisSend
{
public function __construct(private readonly Devis $devis)
{
}
/**
* @return Devis
*/
public function getDevis(): Devis
{
return $this->devis;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Event\Signature;
use App\Entity\Account;
use App\Entity\AccountLoginRegister;
use App\Service\Mailer\Mailer;
use App\Service\Signature\Client;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Security\Http\Event\LoginSuccessEvent;
#[AsEventListener(event: DevisSend::class, method: 'onDevisSend')]
class DevisSubscriber
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly Mailer $mailer,
private readonly Client $client,
)
{
}
public function onDevisSend(DevisSend $devisSend): void
{
$customer = $devisSend->getDevis()->getCustomer();
$devis = $devisSend->getDevis();
$signLink = $this->client->getLinkSign($devis->getSignatureId());
$this->mailer->send($customer->getEmail(), $customer->getName()." ".$customer->getSurname(),"[Signature Ludikevent] - Signature de votre devis pour votre location","mails/sign/devis.twig",[
'devis' => $devis,
'signLink' => $signLink,
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\ProductReserve;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ProductReserve>
*/
class ProductReserveRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ProductReserve::class);
}
// /**
// * @return ProductReserve[] Returns an array of ProductReserve objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('p')
// ->andWhere('p.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('p.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?ProductReserve
// {
// return $this->createQueryBuilder('p')
// ->andWhere('p.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -327,15 +327,15 @@ class DevisPdfService extends Fpdf
// AJOUT CONDITIONNEL DOCUSEAL
if ($this->isIntegrateDocusealFields) {
$this->SetXY(15, $currentY + 1);
$this->SetTextColor(255, 255, 255); // Blanc (invisible)
$this->SetFont('Arial', '', 4);
$this->Cell(5, 5, '{{Check;required=true;role=Client;name='.$role.'}}', 0, 0, 'C');
$this->SetTextColor(0, 0, 0); // Blanc (invisible)
$this->SetFont('Arial', '', 10);
$this->Cell(5, 5, '{{type=checkbox;required=true;role=Client;name='.$role.';}}', 0, 0, 'C');
$this->SetTextColor(0, 0, 0);
$this->SetFont('Arial', '', 10);
}
$this->Ln(4);
$this->Ln(10);
}
$this->Ln(15);
@@ -349,17 +349,13 @@ class DevisPdfService extends Fpdf
$this->Cell(85, 45, "", 1, 0);
$this->SetXY(17, $ySign + 12);
$this->SetFont('Arial', 'I', 8);
$this->SetTextColor(100, 100, 100);
$mention = "Signée par Lilian SEGARD - Ludikevent - Le ".date('d/m/Y')." par signature numérique validée";
$this->MultiCell(81, 5, $this->clean($mention), 0, 'C');
// AJOUT CONDITIONNEL SIGNATURE PRESTATAIRE
if ($this->isIntegrateDocusealFields) {
$this->SetXY(15, $ySign + 25);
$this->SetTextColor(255, 255, 255);
$this->SetFont('Arial', '', 4);
$this->Cell(85, 5, '{{Sign;type=signature;role=Ludikevent}}', 0, 0, 'C');
$this->SetXY(22, $ySign + 9);
$this->SetTextColor(0, 0, 0);
$this->SetFont('Arial', '', 10);
$this->Cell(85, 5, '{{Sign;type=signature;role=Ludikevent;height=120;width=236}}', 0, 0, 'C');
}
// --- BLOC CLIENT ---
@@ -372,10 +368,10 @@ class DevisPdfService extends Fpdf
// AJOUT CONDITIONNEL SIGNATURE CLIENT
if ($this->isIntegrateDocusealFields) {
$this->SetXY(110, $ySign + 20);
$this->SetTextColor(255, 255, 255);
$this->SetFont('Arial', '', 4);
$this->Cell(85, 5, '{{Sign;type=signature;role=Client}}', 0, 0, 'C');
$this->SetXY(113, $ySign+9);
$this->SetTextColor(0, 0, 0);
$this->SetFont('Arial', '', 10);
$this->Cell(85, 5, '{{Sign;type=signature;role=Client;height=120;width=236}}', 0, 0, 'C');
}
}
@@ -400,14 +396,6 @@ class DevisPdfService extends Fpdf
// Texte gauche : Identification du devis
$this->Cell(0, 10, $this->clean('Devis Ludik Event - Lilian SEGARD - SIRET 93048840800012'), 0, 0, 'L');
// --- AJOUT DU CHAMP DOCUSEAL DANS LE FOOTER ---
if ($this->isIntegrateDocusealFields) {
$this->SetX(-60); // On se place vers la droite
$this->SetTextColor(255, 255, 255); // Invisible
$this->SetFont('Arial', '', 4);
// Utilisation d'un champ "Initials" qui est souvent utilisé en bas de page
$this->Cell(30, 10, '{{Initials;role=Client;name=paraphe}}', 0, 0, 'R');
}
// Numérotation des pages à droite
$this->SetTextColor(150, 150, 150);

View File

@@ -29,6 +29,7 @@ class Client
// L'URL API est le point d'entrée pour le SDK Docuseal
$apiUrl = rtrim("https://signature.esy-web.dev", '/') . '/api';
$this->docuseal = new \Docuseal\Api($key, $apiUrl);
$this->logo = $kernel->getProjectDir()."/public/provider/images/favicon.png";
}
/**
@@ -64,11 +65,20 @@ class Client
'role' => 'Ludikevent',
'email' => 'contact@ludikevent.fr',
'completed' => true,
'fields' => [
['name'=>'Sign','default_value'=>$this->logoBase64()]
]
],
[
'role' => 'Client',
'email' => $devis->getCustomer()->getEmail(),
'name' => $devis->getCustomer()->getSurname() . ' ' . $devis->getCustomer()->getName(),
'fields' => [
['name'=>'cgv','default_value'=>true],
['name'=>'assurance','default_value'=>true],
['name'=>'securite','default_value'=>true],
['name'=>'arrhes','default_value'=>true],
],
'metadata' => [
'id' => $devis->getId(),
'type' => 'devis'
@@ -77,8 +87,11 @@ class Client
],
]);
// Stockage de l'ID submitter de Docuseal dans ton entité
$devis->setSignatureId($submission['submitters'][1]['id']);
dd($this->getLinkSign($devis->getSignatureId()));
$this->entityManager->flush();
}
@@ -96,7 +109,7 @@ class Client
$submissionData = $this->docuseal->getSubmitter($submitterId);
return rtrim($this->baseUrl, '/') . "/s/" . $submissionData['slug'];
return rtrim("https://signature.esy-web.dev", '/') . "/s/" . $submissionData['slug'];
}
/**
@@ -116,4 +129,33 @@ class Client
return false;
}
}
/**
* Récupère le fichier logo et le convertit en chaîne Base64
* Utile pour l'intégration directe dans certains flux HTML ou API
*/
private function logoBase64(): ?string
{
// Vérifie si le fichier existe pour éviter une erreur
if (!file_exists($this->logo)) {
return null;
}
// Lecture du contenu du fichier
$binaryData = file_get_contents($this->logo);
// Récupération de l'extension pour le type MIME (png, jpg, etc.)
$extension = pathinfo($this->logo, PATHINFO_EXTENSION);
// Encodage en Base64
$base64 = base64_encode($binaryData);
// Retourne le format complet data:image/...
return 'data:image/' . $extension . ';base64,' . $base64;
}
public function getSubmition(mixed $submission_id)
{
return $this->docuseal->getSubmission($submission_id);
}
}

View File

@@ -2,8 +2,10 @@
namespace App\Twig;
use App\Entity\Devis;
use App\Service\Stripe\Client;
use Twig\Extension\AbstractExtension;
use Twig\TwigFilter;
use Twig\TwigFunction;
class StripeExtension extends AbstractExtension
@@ -12,6 +14,35 @@ class StripeExtension extends AbstractExtension
{
}
public function getFilters()
{
return [
new TwigFilter('totalQuoto',[$this,'totalQuoto'])
];
}
/**
* Calcule le total HT du devis en tenant compte des tarifs dégressifs (J1 + Jours Sup)
*/
public function totalQuoto(Devis $devis): float
{
$totalHT = 0;
foreach ($devis->getDevisLines() as $line) {
$price1Day = $line->getPriceHt() ?? 0;
$priceSupHT = $line->getPriceHtSup() ?? 0;
$nbDays = $line->getDay() ?? 1;
// Formule : Le premier jour est au prix plein, les suivants au prix Sup
// J1 + ( (Total Jours - 1) * Prix Sup )
$lineTotalHT = $price1Day + (max(0, $nbDays - 1) * $priceSupHT);
$totalHT += $lineTotalHT;
}
return (float) $totalHT;
}
public function getFunctions()
{
return [

View File

@@ -24,7 +24,7 @@
<th class="px-6 py-5 text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">Client</th>
<th class="px-6 py-5 text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">Date</th>
<th class="px-6 py-5 text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">Statut</th>
<th class="px-6 py-5 text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] text-center">Total TTC</th>
<th class="px-6 py-5 text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] text-center">Total HT</th>
<th class="px-6 py-5 text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] text-right">Actions</th>
</tr>
</thead>
@@ -66,24 +66,24 @@
'draft': 'text-slate-400 bg-slate-500/10 border-slate-500/20',
'created_waitsign': 'text-amber-400 bg-amber-500/10 border-amber-500/20',
'refusée': 'text-rose-400 bg-rose-500/10 border-rose-500/20',
'signed': 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20',
'signée': 'text-emerald-400 bg-emerald-500/10 border-emerald-500/20'
} %}
{% set statusLabels = {
'draft': 'Brouillon',
'crée': 'Créé',
'envoyée': 'Envoyé',
'created_waitsign': 'Attente Signature',
'refusée': 'Refusé',
'signed': 'Signé',
'signée': 'Signé'
} %}
{% set currentStatus = quote.state|lower %}
<span class="px-3 py-1.5 rounded-lg border text-[8px] font-black uppercase tracking-[0.15em] whitespace-nowrap {{ statusClasses[currentStatus] ?? 'text-slate-400 bg-slate-500/10 border-slate-500/20' }}">
{% if currentStatus == 'en attends de signature' %}
{% if currentStatus == 'created_waitsign' %}
<span class="inline-block w-1.5 h-1.5 rounded-full bg-amber-500 mr-1.5 animate-pulse"></span>
{% elseif currentStatus == 'signée' %}
{% elseif currentStatus == 'signed' or currentStatus == 'signée' %}
<span class="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500 mr-1.5 shadow-[0_0_5px_#10b981]"></span>
{% endif %}
{{ statusLabels[currentStatus] ?? currentStatus }}
@@ -93,30 +93,62 @@
{# MONTANT #}
<td class="px-6 py-4 text-center">
<span class="text-sm font-black text-white">
{{ 0|number_format(2, ',', ' ') }}
{{ (quote|totalQuoto)|number_format(2, ',', ' ') }}
</span>
</td>
{# ACTIONS #}
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end space-x-2">
{# Modifier #}
{# Renvoyer lien de signature #}
{% if quote.state == "created_waitsign" %}
<a href="{{ path('app_crm_devis', {resend: quote.id}) }}"
title="Renvoyer le lien de signature"
class="p-2 bg-indigo-600/10 hover:bg-indigo-600 text-indigo-500 hover:text-white rounded-xl transition-all border border-indigo-500/20 shadow-lg shadow-indigo-600/5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</a>
{% endif %}
{# Modifier : Interdit si signé #}
{% if quote.state != "signed" and quote.state != "signée" %}
<a href="{{ path('app_crm_devis_add', {id: quote.id}) }}" class="p-2 bg-blue-600/10 hover:bg-blue-600 text-blue-500 hover:text-white rounded-xl transition-all border border-blue-500/20 shadow-lg shadow-blue-600/5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
</a>
{% endif %}
{# PDF #}
<a href="{{ path('app_crm_devis_add', {id: quote.id}) }}" target="_blank" class="p-2 bg-emerald-600/10 hover:bg-emerald-600 text-emerald-500 hover:text-white rounded-xl transition-all border border-emerald-500/20 shadow-lg shadow-emerald-600/5">
{# PDF Conditionnel #}
{% if quote.state == "signed" or quote.state == "signée" %}
{# PDF Signé #}
<a download="SIGN_{{ quote.num }}.pdf" href="{{ vich_uploader_asset(quote, 'devisSignFile') }}" title="Télécharger le devis signé" target="_blank" class="p-2 bg-emerald-600/10 hover:bg-emerald-600 text-emerald-500 hover:text-white rounded-xl transition-all border border-emerald-500/20 shadow-lg shadow-emerald-600/5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
</a>
{# Certificat Audit #}
<a download="AUDIT_{{ quote.num }}.pdf" href="{{ vich_uploader_asset(quote, 'devisAuditFile') }}" title="Télécharger le certificat d'audit" target="_blank" class="p-2 bg-purple-600/10 hover:bg-purple-600 text-purple-500 hover:text-white rounded-xl transition-all border border-purple-500/20 shadow-lg shadow-purple-600/5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</a>
{% else %}
{# PDF Brouillon #}
<a download="{{ quote.num }}.pdf" href="{{ vich_uploader_asset(quote,'devisDocuSealFile') }}" target="_blank" class="p-2 bg-slate-600/10 hover:bg-slate-600 text-slate-500 hover:text-white rounded-xl transition-all border border-slate-500/20">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
</a>
{% endif %}
{# Delete #}
{# Delete : Interdit si signé #}
{% if quote.state != "signed" and quote.state != "signée" %}
<a href="{{ path('app_crm_devis_add', {id: quote.id}) }}?_token={{ csrf_token('delete' ~ quote.id) }}"
data-turbo-method="post"
data-turbo-confirm="Confirmer la suppression du devis {{ quote.num }} ?"
class="p-2 bg-rose-500/10 hover:bg-rose-600 text-rose-500 hover:text-white rounded-xl transition-all border border-rose-500/20">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</a>
{% else %}
<div title="Un devis signé ne peut être supprimé" class="p-2 bg-slate-800/30 text-slate-600 rounded-xl border border-white/5 cursor-not-allowed">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
</div>
{% endif %}
</div>
</td>
</tr>

View File

@@ -0,0 +1,49 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-section background-color="#ffffff" padding-top="0px">
<mj-column width="100%">
<mj-text font-size="20px" color="#2563eb" font-family="Arial, sans-serif" font-weight="bold" align="center">
Signature de votre devis
</mj-text>
<mj-divider border-color="#2563eb" border-width="2px" width="50px" />
<mj-text font-size="16px" color="#333333" font-family="Arial, sans-serif" line-height="1.5">
Bonjour <strong>{{ datas.devis.customer.name }} {{ datas.devis.customer.surname }}</strong>,
</mj-text>
<mj-text font-size="14px" color="#555555" font-family="Arial, sans-serif" line-height="1.5">
Merci de nous avoir sollicités pour votre événement. Vous trouverez ci-dessous les détails de votre devis. Pour confirmer votre réservation, merci de le signer numériquement via le bouton ci-dessous.
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#f9fafb" border="1px solid #e5e7eb" border-radius="8px">
<mj-column width="100%">
<mj-table font-family="Arial, sans-serif" font-size="14px">
<tr style="border-bottom:1px solid #ecedee;text-align:left;padding:15px 0;">
<th style="padding: 10px 0;">Référence :</th>
<th style="padding: 10px 0; text-align:right;">#{{ datas.devis.num }}</th>
</tr>
<tr>
<td style="padding: 10px 0;">Montant Total HT :</td>
<td style="padding: 10px 0; text-align:right; font-weight:bold;">{{ (datas.devis|totalQuoto)|number_format(2, ',', ' ') }} €</td>
</tr>
<tr>
<td style="padding: 5px 0; color:#888888; font-size:12px;" colspan="2">
(TVA non applicable, art. 293 B du CGI)
</td>
</tr>
</mj-table>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff">
<mj-column width="100%">
<mj-button background-color="#2563eb" color="white" font-size="16px" font-weight="bold" href="{{ datas.signLink }}" border-radius="10px" padding-top="20px">
SIGNER MON DEVIS EN LIGNE
</mj-button>
<mj-text font-size="12px" color="#999999" align="center" padding-top="10px">
Le lien de signature est sécurisé et conforme à la réglementation en vigueur.
</mj-text>
</mj-column>
</mj-section>
{% endblock %}

View File

@@ -0,0 +1,42 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-section background-color="#ffffff" padding-top="0px">
<mj-column width="100%">
<mj-text font-size="24px" color="#10b981" font-family="Arial, sans-serif" font-weight="bold" align="center">
Signature confirmée !
</mj-text>
<mj-text font-size="16px" color="#333333" font-family="Arial, sans-serif" align="center">
Votre devis <strong>#{{ datas.devis.num }}</strong> est désormais signé.
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#fffbeb" border="2px solid #fbbf24" border-radius="10px">
<mj-column width="100%">
<mj-text font-size="16px" color="#92400e" font-family="Arial, sans-serif" font-weight="bold" align="center">
⚠️ ACTION REQUISE : PAIEMENT DE L'ACOMPTE
</mj-text>
<mj-text font-size="14px" color="#b45309" font-family="Arial, sans-serif" line-height="1.5" align="center">
Pour valider définitivement votre réservation, vous disposez de <strong>3 jours</strong> pour effectuer le paiement de l'acompte.
<br/><br/>
Passé ce délai, votre réservation sera <strong>automatiquement annulée</strong> et les dates seront libérées.
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff">
<mj-column width="100%">
<mj-text font-size="14px" color="#555555" font-family="Arial, sans-serif">
Bonjour {{ datas.devis.customer.name }},<br/><br/>
Nous avons bien reçu votre signature électronique. Vous trouverez en pièces jointes de cet e-mail :
</mj-text>
<mj-text font-size="13px" color="#555555" font-family="Arial, sans-serif">
<ul>
<li>Votre devis signé au format PDF</li>
<li>Le certificat d'audit de la signature</li>
</ul>
</mj-text>
</mj-column>
</mj-section>
{% endblock %}

View File

@@ -0,0 +1,45 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-section background-color="#ffffff" padding-top="20px">
<mj-column width="100%">
<mj-text font-size="14px" color="#475569" font-family="Arial, sans-serif" line-height="1.6">
Bonne nouvelle ! Le client <strong>{{ datas.devis.customer.name }} {{ datas.devis.customer.surname }}</strong> vient de signer numériquement son devis.
</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff" padding="0px">
<mj-column width="90%" background-color="#f8fafc" border="1px solid #e2e8f0" border-radius="8px">
<mj-table font-family="Arial, sans-serif" font-size="14px" padding="15px">
<tr>
<td style="padding: 5px 0; color: #64748b;">N° Devis :</td>
<td style="padding: 5px 0; text-align: right; font-weight: bold;">{{ datas.devis.num }}</td>
</tr>
<tr>
<td style="padding: 5px 0; color: #64748b;">Client :</td>
<td style="padding: 5px 0; text-align: right;">{{ datas.devis.customer.name }}</td>
</tr>
<tr>
<td style="padding: 5px 0; color: #64748b;">Montant HT :</td>
<td style="padding: 5px 0; text-align: right; font-weight: bold; color: #2563eb;">{{ datas.devis.totalHt|number_format(2, ',', ' ') }} €</td>
</tr>
<tr>
<td style="padding: 5px 0; color: #64748b;">Date de signature :</td>
<td style="padding: 5px 0; text-align: right;">{{ "now"|date("d/m/Y à H:i") }}</td>
</tr>
</mj-table>
</mj-column>
</mj-section>
<mj-section background-color="#ffffff">
<mj-column width="100%">
<mj-text font-size="13px" color="#ef4444" font-family="Arial, sans-serif" font-style="italic" align="center">
Rappel : Le client a reçu l'instruction de régler l'acompte sous 3 jours.
</mj-text>
<mj-button background-color="#1e293b" color="white" font-size="14px" font-weight="bold" href="https://reservation.ludikevent.fr/crm/devis" border-radius="6px" padding-top="20px">
VOIR LE DEVIS SUR LE CRM
</mj-button>
</mj-column>
</mj-section>
{% endblock %}

View File

@@ -0,0 +1,85 @@
{% extends 'base.twig' %}
{% block title %}Signature confirmée - Ludikevent{% endblock %}
{% block body %}
<div class="min-h-screen flex items-center justify-center bg-slate-50 py-12 px-4 sm:px-6 lg:px-8 font-sans">
<div class="max-w-md w-full space-y-8 p-10 bg-white rounded-2xl shadow-xl border border-slate-100 text-center">
{# Logo #}
<div class="flex justify-center">
<img src="{{ asset('provider/images/logo.png') }}" class="h-16 w-auto" alt="Ludikevent"/>
</div>
{# Icône de succès #}
<div class="flex justify-center">
<div class="rounded-full bg-emerald-100 p-3">
<svg class="h-12 w-12 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
</div>
{# Contenu principal #}
<div class="space-y-4">
<h1 class="text-2xl font-extrabold text-slate-900">
Signature effectuée !
</h1>
<p class="text-slate-500 text-sm">
La signature numérique du devis <span class="font-bold text-blue-600">#{{ devis.num }}</span> a été validée avec succès.
</p>
</div>
{# ALERT PAIEMENT ACOMPTE #}
<div class="bg-amber-50 border-l-4 border-amber-400 p-4 my-6 text-left">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-amber-400" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<p class="text-sm font-bold text-amber-800">Action requise : Paiement de l'acompte</p>
<p class="text-xs text-amber-700 mt-1">
Vous disposez de <strong>3 jours</strong> pour effectuer le paiement de l'acompte afin de valider définitivement votre réservation. Passé ce délai, votre réservation sera <strong>automatiquement annulée</strong>.
</p>
</div>
</div>
</div>
<hr class="border-slate-100">
{# Prochaines étapes #}
<div class="space-y-6 text-left">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-blue-600 text-xs font-bold">1</span>
</div>
<p class="text-sm text-slate-600">
Vous allez recevoir prochainement votre <span class="font-semibold">contrat de location</span> par e-mail.
</p>
</div>
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<span class="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100 text-blue-600 text-xs font-bold">2</span>
</div>
<p class="text-sm text-slate-600">
Un <span class="font-semibold text-blue-600">lien sécurisé</span> vous sera transmis pour gérer vos documents et effectuer votre paiement.
</p>
</div>
</div>
{# Footer / Bouton retour #}
<div class="pt-6">
<p class="text-xs text-slate-400 mb-6 italic">
Une copie du devis signé a été envoyée à : {{ devis.customer.email }}
</p>
<a href="https://reservation.ludikevent.fr" class="inline-flex items-center justify-center px-5 py-3 border border-transparent text-base font-medium rounded-xl text-white bg-blue-600 hover:bg-blue-700 transition-colors shadow-lg shadow-blue-200 w-full">
Retour au site
</a>
</div>
</div>
</div>
{% endblock %}