```
✨ 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:
43
migrations/Version20260119183356.php
Normal file
43
migrations/Version20260119183356.php
Normal 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');
|
||||
}
|
||||
}
|
||||
31
migrations/Version20260119183526.php
Normal file
31
migrations/Version20260119183526.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
// 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' => $paginator->paginate($devisRepository->findBy([],['createA'=>'asc']),$request->get('page', 1),20),
|
||||
'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',[
|
||||
|
||||
@@ -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
|
||||
]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
95
src/Entity/ProductReserve.php
Normal file
95
src/Entity/ProductReserve.php
Normal 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;
|
||||
}
|
||||
}
|
||||
20
src/Event/Signature/DevisSend.php
Normal file
20
src/Event/Signature/DevisSend.php
Normal 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;
|
||||
}
|
||||
}
|
||||
36
src/Event/Signature/DevisSubscriber.php
Normal file
36
src/Event/Signature/DevisSubscriber.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
43
src/Repository/ProductReserveRepository.php
Normal file
43
src/Repository/ProductReserveRepository.php
Normal 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()
|
||||
// ;
|
||||
// }
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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>
|
||||
|
||||
49
templates/mails/sign/devis.twig
Normal file
49
templates/mails/sign/devis.twig
Normal 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 %}
|
||||
42
templates/mails/sign/signed.twig
Normal file
42
templates/mails/sign/signed.twig
Normal 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 %}
|
||||
45
templates/mails/sign/signed_notification.twig
Normal file
45
templates/mails/sign/signed_notification.twig
Normal 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 %}
|
||||
85
templates/sign/sign_success.twig
Normal file
85
templates/sign/sign_success.twig
Normal 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 %}
|
||||
Reference in New Issue
Block a user