feat(Devis): Ajoute options, dates début/fin et améliore affichage PDF

Ajoute les champs date de début et fin au devis. Permet l'ajout d'options au devis. Améliore l'affichage du PDF.
```
This commit is contained in:
Serreau Jovann
2026-01-22 10:36:26 +01:00
parent 1d7102ec07
commit 7dc2978094
20 changed files with 646 additions and 111 deletions

View File

@@ -80,6 +80,63 @@ export function initTomSelect(parent = document) {
});
}
}
else if (el.getAttribute('data-load') === "options") {
const setupSelect = (data) => {
new TomSelect(el, {
valueField: 'id',
labelField: 'name',
searchField: 'name',
options: data,
maxOptions: null,
// LORSQU'ON SÉLECTIONNE UN PRODUIT
// Dans admin.js, section onChange de TomSelect :
onChange: (id) => {
if (!id) return;
// On s'assure de trouver le produit (id peut être string ou int)
const product = data.find(p => String(p.id) === String(id));
const row = el.closest('.form-repeater__row') || el.closest('fieldset');
if (!row) return;
const priceInput = row.querySelector('input[name*="[price_ht]"]');
if (priceInput) {
priceInput.value = product.price;
// Indispensable pour que d'autres scripts (calcul totaux) voient le changement
priceInput.dispatchEvent(new Event('input', { bubbles: true }));
priceInput.dispatchEvent(new Event('change', { bubbles: true }));
}
},
render: {
option: (data, escape) => `
<div class="flex items-center gap-3 py-2 px-3 border-b border-slate-800/50">
<img src="${escape(data.image)}" class="w-8 h-8 object-cover rounded shadow-sm">
<div class="flex flex-col">
<div class="text-[13px] font-bold text-white">${escape(data.name)}</div>
<div class="text-[10px] text-slate-400">${escape(data.price)}€</div>
</div>
</div>`,
item: (data, escape) => `
<div class="text-blue-400 font-bold flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span>
${escape(data.name)}
</div>`
}
});
};
// Utilisation du cache ou fetch
if (productCache) {
setupSelect(productCache);
} else {
fetch("/crm/options/json")
.then(r => r.json())
.then(data => {
productCache = data;
setupSelect(data);
});
}
}
// --- AUTRES SELECTS ---
else {
new TomSelect(el, {

View File

@@ -0,0 +1,42 @@
<?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 Version20260122085820 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 devis ADD start_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL');
$this->addSql('ALTER TABLE devis ADD end_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL');
$this->addSql('COMMENT ON COLUMN devis.start_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN devis.end_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('ALTER TABLE devis_line DROP start_at');
$this->addSql('ALTER TABLE devis_line DROP end_at');
}
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 devis DROP start_at');
$this->addSql('ALTER TABLE devis DROP end_at');
$this->addSql('ALTER TABLE devis_line ADD start_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL');
$this->addSql('ALTER TABLE devis_line ADD end_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL');
$this->addSql('COMMENT ON COLUMN devis_line.start_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN devis_line.end_at IS \'(DC2Type:datetime_immutable)\'');
}
}

View File

@@ -0,0 +1,36 @@
<?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 Version20260122092253 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 devis DROP CONSTRAINT fk_8b27c52b3adb05f1');
$this->addSql('DROP INDEX idx_8b27c52b3adb05f1');
$this->addSql('ALTER TABLE devis DROP options_id');
}
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 devis ADD options_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE devis ADD CONSTRAINT fk_8b27c52b3adb05f1 FOREIGN KEY (options_id) REFERENCES options (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_8b27c52b3adb05f1 ON devis (options_id)');
}
}

View File

@@ -0,0 +1,36 @@
<?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 Version20260122092321 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 options ADD devis_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE options ADD CONSTRAINT FK_D035FA8741DEFADA FOREIGN KEY (devis_id) REFERENCES devis (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_D035FA8741DEFADA ON options (devis_id)');
}
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 options DROP CONSTRAINT FK_D035FA8741DEFADA');
$this->addSql('DROP INDEX IDX_D035FA8741DEFADA');
$this->addSql('ALTER TABLE options DROP devis_id');
}
}

View File

@@ -0,0 +1,36 @@
<?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 Version20260122092540 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 options DROP CONSTRAINT fk_d035fa8741defada');
$this->addSql('DROP INDEX idx_d035fa8741defada');
$this->addSql('ALTER TABLE options DROP devis_id');
}
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 options ADD devis_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE options ADD CONSTRAINT fk_d035fa8741defada FOREIGN KEY (devis_id) REFERENCES devis (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX idx_d035fa8741defada ON options (devis_id)');
}
}

View File

@@ -0,0 +1,38 @@
<?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 Version20260122092618 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 devis_options (id SERIAL NOT NULL, devis_id INT DEFAULT NULL, option_id INT DEFAULT NULL, price_ht DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_42DB61DB41DEFADA ON devis_options (devis_id)');
$this->addSql('CREATE INDEX IDX_42DB61DBA7C41D6F ON devis_options (option_id)');
$this->addSql('ALTER TABLE devis_options ADD CONSTRAINT FK_42DB61DB41DEFADA FOREIGN KEY (devis_id) REFERENCES devis (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE devis_options ADD CONSTRAINT FK_42DB61DBA7C41D6F FOREIGN KEY (option_id) REFERENCES options (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 devis_options DROP CONSTRAINT FK_42DB61DB41DEFADA');
$this->addSql('ALTER TABLE devis_options DROP CONSTRAINT FK_42DB61DBA7C41D6F');
$this->addSql('DROP TABLE devis_options');
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Controller\Dashboard;
use App\Entity\CustomerAddress;
use App\Entity\Devis;
use App\Entity\DevisLine;
use App\Entity\DevisOptions;
use App\Event\Signature\DevisSend;
use App\Form\NewDevisType;
use App\Logger\AppLogger;
@@ -12,9 +13,11 @@ use App\Repository\AccountRepository;
use App\Repository\CustomerAddressRepository;
use App\Repository\CustomerRepository;
use App\Repository\DevisRepository;
use App\Repository\OptionsRepository;
use App\Repository\ProductRepository;
use App\Service\Pdf\DevisPdfService;
use App\Service\Signature\Client;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Bundle\PaginatorBundle\KnpPaginatorBundle;
use Knp\Component\Pager\PaginatorInterface;
@@ -40,6 +43,7 @@ class DevisController extends AbstractController
AppLogger $appLogger,
PaginatorInterface $paginator,
Request $request,
KernelInterface $kernel,
): Response {
@@ -77,7 +81,7 @@ class DevisController extends AbstractController
]);
}
#[Route(path: '/crm/devis/add', name: 'app_crm_devis_add', options: ['sitemap' => false], methods: ['GET','POST'])]
public function devisAdd(Client $client,EventDispatcherInterface $eventDispatcher,KernelInterface $kernel,CustomerAddressRepository $customerAddress,ProductRepository $productRepository,EntityManagerInterface $entityManager,CustomerRepository $customerRepository,DevisRepository $devisRepository, AppLogger $appLogger,Request $request): Response
public function devisAdd(Client $client,OptionsRepository $optionsRepository,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');
@@ -90,6 +94,9 @@ class DevisController extends AbstractController
$form = $this->createForm(NewDevisType::class,$devis);
if($request->isMethod('POST')){
$devis->setStartAt( new DateTimeImmutable($_POST['new_devis']['startAt']));
$devis->setEndAt( new DateTimeImmutable($_POST['new_devis']['endAt']));
$devis->setBillAddress($customerAddress->find($_POST['devis']['bill_address']));
$devis->setAddressShip($customerAddress->find($_POST['devis']['ship_address']));
$devis->setCustomer($customerRepository->find($_POST['new_devis']['customer']));
@@ -101,16 +108,22 @@ class DevisController extends AbstractController
$rLine->setDay($line['days']);
$rLine->setPriceHt(floatval($line['price_ht']));
$rLine->setPriceHtSup(floatval($line['price_sup_ht']));
$rLine->setStartAt(\DateTimeImmutable::createFromFormat('Y-m-d',$line['date_start']));
$rLine->setEndAt(\DateTimeImmutable::createFromFormat('Y-m-d',$line['date_end']));
$entityManager->persist($rLine);
}
foreach ($_POST['options'] as $line) {
$rLineOptions = new DevisOptions();
$rLineOptions->setDevis($devis);
$rLineOptions->setOption($optionsRepository->find($line['product_id']));
$rLineOptions->setPriceHt(floatval($line['price_ht']));
$entityManager->persist($rLineOptions);
}
$entityManager->persist($devis);
$entityManager->flush();
$docusealService = new DevisPdfService($kernel, $devis, true);
$contentDocuseal = $docusealService->generate();
$tmpPathDocuseal = sys_get_temp_dir() . '/docuseal_' . uniqid() . '.pdf';
file_put_contents($tmpPathDocuseal, $contentDocuseal);

View File

@@ -41,10 +41,14 @@ class ProductController extends AbstractController
{
$products = [];
foreach ($productRepository->findAll() as $product) {
// On récupère le chemin de l'image
$imagePath = $uploaderHelper->asset($product, 'imageFile');
$products[] = [
'id' => $product->getId(),
'name' => $product->getName(),
'image' => $uploaderHelper->asset($product, 'imageFile'),
// On s'assure que si Vich ne trouve rien, on renvoie null proprement
'image' => $imagePath ?: "/provider/images/favicon.png",
'price1day' => $product->getPriceDay(),
'priceSup' => $product->getPriceSup(),
'caution' => $product->getCaution(),
@@ -53,6 +57,25 @@ class ProductController extends AbstractController
return $this->json($products);
}
#[Route(path: '/crm/options/json', name: 'app_crm_options_json', options: ['sitemap' => false], methods: ['GET'])]
public function optionsJson(OptionsRepository $optionsRepository, UploaderHelper $uploaderHelper): Response
{
$options = [];
foreach ($optionsRepository->findAll() as $option) {
// Vérification identique pour les options
$imagePath = $uploaderHelper->asset($option, 'imageFile');
$options[] = [
'id' => $option->getId(),
'name' => $option->getName(),
'image' => $imagePath ?: "/provider/images/favicon.png",
'price' => $option->getPriceHt(),
];
}
return $this->json($options);
}
#[Route(path: '/crm/products', name: 'app_crm_product', options: ['sitemap' => false], methods: ['GET'])]
public function products(OptionsRepository $optionsRepository, ProductRepository $productRepository, AppLogger $appLogger, PaginatorInterface $paginator, Request $request): Response
{

View File

@@ -93,8 +93,8 @@ class SignatureController extends AbstractController
$productReserve = new ProductReserve();
$productReserve->setProduct($product);
$productReserve->setCustomer($devis->getCustomer());
$productReserve->setStartAt($line->getStartAt());
$productReserve->setEndAt($line->getEndAt());
$productReserve->setStartAt($devis->getStartAt());
$productReserve->setEndAt($devis->getEndAt());
$productReserve->setDevis($devis);
$entityManager->persist($productReserve);
}

View File

@@ -83,12 +83,30 @@ class Devis
#[ORM\OneToOne(mappedBy: 'devis', cascade: ['persist', 'remove'])]
private ?Contrats $contrats = null;
#[ORM\ManyToOne(inversedBy: 'devis')]
private ?Options $options = null;
#[ORM\Column]
private ?\DateTimeImmutable $startAt = null;
#[ORM\Column]
private ?\DateTimeImmutable $endAt = null;
/**
* @var Collection<int, Options>
*/
#[ORM\OneToMany(targetEntity: Options::class, mappedBy: 'devis')]
private Collection $options;
/**
* @var Collection<int, DevisOptions>
*/
#[ORM\OneToMany(targetEntity: DevisOptions::class, mappedBy: 'devis')]
private Collection $devisOptions;
public function __construct()
{
$this->devisLines = new ArrayCollection();
$this->options = new ArrayCollection();
$this->devisOptions = new ArrayCollection();
}
public function getId(): ?int
@@ -478,14 +496,64 @@ class Devis
return $this;
}
public function getOptions(): ?Options
/**
* @param \DateTimeImmutable|null $startAt
*/
public function setStartAt(?\DateTimeImmutable $startAt): void
{
return $this->options;
$this->startAt = $startAt;
}
public function setOptions(?Options $options): static
/**
* @return \DateTimeImmutable|null
*/
public function getStartAt(): ?\DateTimeImmutable
{
$this->options = $options;
return $this->startAt;
}
/**
* @param \DateTimeImmutable|null $endAt
*/
public function setEndAt(?\DateTimeImmutable $endAt): void
{
$this->endAt = $endAt;
}
/**
* @return \DateTimeImmutable|null
*/
public function getEndAt(): ?\DateTimeImmutable
{
return $this->endAt;
}
/**
* @return Collection<int, DevisOptions>
*/
public function getDevisOptions(): Collection
{
return $this->devisOptions;
}
public function addDevisOption(DevisOptions $devisOption): static
{
if (!$this->devisOptions->contains($devisOption)) {
$this->devisOptions->add($devisOption);
$devisOption->setDevis($this);
}
return $this;
}
public function removeDevisOption(DevisOptions $devisOption): static
{
if ($this->devisOptions->removeElement($devisOption)) {
// set the owning side to null (unless already changed)
if ($devisOption->getDevis() === $this) {
$devisOption->setDevis(null);
}
}
return $this;
}

View File

@@ -29,11 +29,6 @@ class DevisLine
#[ORM\Column]
private ?int $day = null;
#[ORM\Column]
private ?\DateTimeImmutable $startAt = null;
#[ORM\Column]
private ?\DateTimeImmutable $endAt = null;
#[ORM\ManyToOne(inversedBy: 'devisLines')]
private ?Product $product = null;
@@ -104,30 +99,6 @@ class DevisLine
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 getProduct(): ?Product
{
return $this->product;

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Entity;
use App\Repository\DevisOptionsRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: DevisOptionsRepository::class)]
class DevisOptions
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'devisOptions')]
private ?Devis $devis = null;
#[ORM\ManyToOne(inversedBy: 'devisOptions')]
private ?Options $option = null;
#[ORM\Column]
private ?float $priceHt = null;
public function getId(): ?int
{
return $this->id;
}
public function getDevis(): ?Devis
{
return $this->devis;
}
public function setDevis(?Devis $devis): static
{
$this->devis = $devis;
return $this;
}
public function getOption(): ?Options
{
return $this->option;
}
public function setOption(?Options $option): static
{
$this->option = $option;
return $this;
}
public function getPriceHt(): ?float
{
return $this->priceHt;
}
public function setPriceHt(float $priceHt): static
{
$this->priceHt = $priceHt;
return $this;
}
}

View File

@@ -26,11 +26,6 @@ class Options
#[ORM\Column]
private ?float $priceHt = null;
/**
* @var Collection<int, Devis>
*/
#[ORM\OneToMany(targetEntity: Devis::class, mappedBy: 'options')]
private Collection $devis;
#[ORM\Column(length: 255)]
private ?string $stripeId = null;
@@ -46,11 +41,18 @@ class Options
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
/**
* @var Collection<int, DevisOptions>
*/
#[ORM\OneToMany(targetEntity: DevisOptions::class, mappedBy: 'option')]
private Collection $devisOptions;
public function __construct()
{
$this->devis = new ArrayCollection();
$this->devisOptions = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
@@ -80,36 +82,6 @@ class Options
return $this;
}
/**
* @return Collection<int, Devis>
*/
public function getDevis(): Collection
{
return $this->devis;
}
public function addDevi(Devis $devi): static
{
if (!$this->devis->contains($devi)) {
$this->devis->add($devi);
$devi->setOptions($this);
}
return $this;
}
public function removeDevi(Devis $devi): static
{
if ($this->devis->removeElement($devi)) {
// set the owning side to null (unless already changed)
if ($devi->getOptions() === $this) {
$devi->setOptions(null);
}
}
return $this;
}
public function getStripeId(): ?string
{
return $this->stripeId;
@@ -180,4 +152,34 @@ class Options
return$s->slugify($this->id."-".$this->name);
}
/**
* @return Collection<int, DevisOptions>
*/
public function getDevisOptions(): Collection
{
return $this->devisOptions;
}
public function addDevisOption(DevisOptions $devisOption): static
{
if (!$this->devisOptions->contains($devisOption)) {
$this->devisOptions->add($devisOption);
$devisOption->setOption($this);
}
return $this;
}
public function removeDevisOption(DevisOptions $devisOption): static
{
if ($this->devisOptions->removeElement($devisOption)) {
// set the owning side to null (unless already changed)
if ($devisOption->getOption() === $this) {
$devisOption->setOption(null);
}
}
return $this;
}
}

View File

@@ -6,6 +6,7 @@ use App\Entity\Customer;
use App\Entity\Devis;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -31,6 +32,16 @@ class NewDevisType extends AbstractType
'readonly' => true,
]
])
->add('startAt',DateTimeType::class,[
'label' =>'Date de début de \'événément',
'required' => true,
'widget' => 'single_text',
])
->add('endAt',DateTimeType::class,[
'label' =>'Date de fin de \'événément',
'required' => true,
'widget' => 'single_text',
])
->add('customer', EntityType::class, [
'label' => 'Client',
'required' => true,

View File

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

View File

@@ -102,10 +102,28 @@ class DevisPdfService extends Fpdf
$this->renderAddressBlock('ADRESSE DE FACTURATION', $this->devis->getBillAddress(), 'L', 10, $yAddress);
$this->renderAddressBlock('ADRESSE DE PRESTATION', $this->devis->getAddressShip(), 'R', 110, $yAddress);
$this->SetY($yAddress + 35);
$this->Ln(10);
// --- AJOUT : BLOC DATES DE L'ÉVÉNEMENT ---
$this->SetY($yAddress + 25);
$this->SetDrawColor(230, 230, 230);
$this->SetFillColor(250, 250, 250);
$this->Rect(10, $this->GetY(), 190, 12, 2, 'DF'); // Petit encadré gris clair
$this->SetY($this->GetY() + 3.5);
$this->SetFont('Arial', 'B', 9);
$this->SetTextColor(80, 80, 80);
$this->Cell(45, 5, $this->clean('DATES DE PRESTATION :'), 0, 0, 'L');
$this->SetFont('Arial', '', 10);
$this->SetTextColor(0, 0, 0);
$dateStart = $this->devis->getStartAt() ? $this->devis->getStartAt()->format('d/m/Y à H:i') : 'N/C';
$dateEnd = $this->devis->getEndAt() ? $this->devis->getEndAt()->format('d/m/Y à H:i') : 'N/C';
$this->Cell(0, 5, $this->clean("Du $dateStart au $dateEnd"), 0, 1, 'L');
$this->SetY($this->GetY() + 8);
// --- TABLEAU DES PRODUITS ---
$this->SetFont('Arial', 'B', 8);
$this->SetFillColor(245, 247, 250);
$this->Cell(70, 10, $this->clean('Désignation'), 1, 0, 'L', true);
@@ -113,7 +131,7 @@ class DevisPdfService extends Fpdf
$this->Cell(25, 10, $this->clean('Tarif J1'), 1, 0, 'R', true);
$this->Cell(25, 10, $this->clean('Tarif Sup'), 1, 0, 'R', true);
$this->Cell(15, 10, $this->clean('TVA'), 1, 0, 'C', true);
$this->Cell(40, 10, $this->clean('Total HT'), 1, 1, 'R', true); // Total HT car TVA 0%
$this->Cell(40, 10, $this->clean('Total HT'), 1, 1, 'R', true);
$this->SetFont('Arial', '', 9);
$this->SetTextColor(0, 0, 0);
@@ -132,22 +150,13 @@ class DevisPdfService extends Fpdf
$productName = $line->getProduct()->getName();
$ref = $line->getProduct()->getRef();
$dateStart = $line->getStartAt() ? $line->getStartAt()->format('d/m/Y') : '';
$dateEnd = $line->getEndAt() ? $line->getEndAt()->format('d/m/Y') : '';
$currentY = $this->GetY();
// --- COLONNE DÉSIGNATION (NOM + REF / DATES) ---
$this->SetXY(10, $currentY);
$this->SetXY(10, $currentY+2.5);
$this->SetFont('Arial', 'B', 8);
$this->Cell(70, 5, $this->clean($productName . ' (Ref: ' . $ref . ')'), 0, 0, 'L');
$this->SetXY(10, $currentY + 5);
$this->SetFont('Arial', 'I', 7);
$this->SetTextColor(100, 100, 100);
$this->Cell(70, 4, $this->clean("Période : du $dateStart au $dateEnd"), 0, 0, 'L');
$this->SetTextColor(0, 0, 0);
// --- COLONNES NUMÉRIQUES ---
$this->SetXY(80, $currentY);
$this->SetFont('Arial', '', 8);
@@ -166,6 +175,41 @@ class DevisPdfService extends Fpdf
}
}
//options
// --- 2. TABLEAU DES OPTIONS (SÉPARÉ) ---
if (count($this->devis->getDevisOptions()) > 0) {
$this->Ln(5); // Espace entre les deux tableaux
// En-tête du tableau des options
$this->SetFont('Arial', 'B', 8);
$this->SetFillColor(230, 235, 245); // Bleu très léger pour différencier
$this->Cell(150, 8, $this->clean('Options & Services additionnels'), 1, 0, 'L', true);
$this->Cell(40, 8, $this->clean('Total HT'), 1, 1, 'R', true);
$this->SetFont('Arial', '', 9);
$this->SetTextColor(0, 0, 0);
foreach ($this->devis->getDevisOptions() as $devisOption) {
$option = $devisOption->getOption();
$priceHT = $option->getPriceHt();
$totalHT += $priceHT; // On l'ajoute au total général
$currentY = $this->GetY();
// Colonne Désignation
$this->SetXY(10, $currentY);
$this->Cell(150, 8, $this->clean($option->getName()), 'LRB', 0, 'L');
// Colonne Prix
$this->Cell(40, 8, number_format($priceHT, 2, ',', ' ') . $this->euro(), 'RB', 1, 'R');
if ($this->GetY() > 260) {
$this->AddPage();
}
}
}
// --- BLOC TOTAUX ---
$this->Ln(5);
$this->SetFont('Arial', 'B', 10);

View File

@@ -91,7 +91,6 @@ 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();
}

View File

@@ -28,18 +28,27 @@ class StripeExtension extends AbstractExtension
{
$totalHT = 0;
// 1. Calcul des lignes de produits (Location)
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 )
// Calcul : J1 + (Jours supplémentaires * Prix Sup)
$lineTotalHT = $price1Day + (max(0, $nbDays - 1) * $priceSupHT);
$totalHT += $lineTotalHT;
}
// 2. Calcul des options additionnelles
foreach ($devis->getDevisOptions() as $devisOption) {
// On récupère l'entité Option liée à la ligne de liaison
$option = $devisOption->getOption();
if ($option) {
$totalHT += $option->getPriceHt() ?? 0;
}
}
return (float) $totalHT;
}

View File

@@ -57,6 +57,7 @@
{{ form_label(form.customer, null, {'label_attr': {'class': label_class}}) }}
{{ form_widget(form.customer, {'attr': {'class': input_class}}) }}
</div>
</div>
{# SECTION ADRESSES #}
@@ -70,6 +71,17 @@
<select id="shipAddress" name="devis[ship_address]" class="{{ input_class }}"></select>
</div>
</div>
{# SECTION ADRESSES #}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div class="space-y-3">
{{ form_label(form.startAt, null, {'label_attr': {'class': label_class}}) }}
{{ form_widget(form.startAt, {'attr': {'class': input_class}}) }}
</div>
<div class="space-y-3">
{{ form_label(form.endAt, null, {'label_attr': {'class': label_class}}) }}
{{ form_widget(form.endAt, {'attr': {'class': input_class}}) }}
</div>
</div>
<hr class="border-white/5">
@@ -86,7 +98,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-5 items-end">
{# 1. PRODUIT #}
<div class="lg:col-span-3">
<div class="lg:col-span-5">
<label class="{{ label_class }}">Produit / Prestation</label>
<select data-load="product" name="lines[0][product_id]" class="{{ input_class }}" required>
<option value="">Sélectionner...</option>
@@ -94,7 +106,7 @@
</div>
{# 2. DURÉE #}
<div class="lg:col-span-1">
<div class="lg:col-span-2">
<label class="{{ label_class }}">Jours</label>
<input type="number" name="lines[0][days]" min="1" placeholder="1" class="{{ input_class }} [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" required />
</div>
@@ -106,22 +118,11 @@
</div>
{# 4. PRIX HT SUP #}
<div class="lg:col-span-1">
<div class="lg:col-span-2">
<label class="{{ label_class }}">HT Sup</label>
<input type="number" step="0.01" name="lines[0][price_sup_ht]" placeholder="0.00" class="{{ input_class }} text-right font-mono" required />
</div>
{# 5. DATES #}
<div class="lg:col-span-2">
<label class="{{ label_class }}">Date Début</label>
<input type="date" name="lines[0][date_start]" class="{{ input_class }} [color-scheme:dark]" required />
</div>
<div class="lg:col-span-2">
<label class="{{ label_class }}">Date Fin</label>
<input type="date" name="lines[0][date_end]" class="{{ input_class }} [color-scheme:dark]" required />
</div>
{# 6. SUPPRIMER #}
<div class="lg:col-span-1 flex justify-center pb-1">
<button type="button" data-ref="removeButton" class="p-4 bg-red-500/10 hover:bg-red-500 text-red-500 hover:text-white rounded-2xl transition-all duration-300 shadow-lg hover:shadow-red-500/20">
@@ -143,6 +144,47 @@
</div>
</div>
<div class="form-repeater" data-component="repeater2" is="repeat-line">
<div class="flex items-center justify-between mb-6 px-4">
<h4 class="text-sm font-black text-white uppercase tracking-widest">Détail des options</h4>
</div>
<ol class="form-repeater__rows space-y-4" data-ref="rows">
<li class="form-repeater__row group animate-in slide-in-from-right-5 duration-300">
<fieldset class="backdrop-blur-md bg-white/5 border border-white/10 rounded-[2.5rem] p-8 hover:border-blue-500/30 transition-all shadow-xl">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-5 items-end">
{# 1. PRODUIT #}
<div class="lg:col-span-9">
<label class="{{ label_class }}">Options</label>
<select data-load="options" name="options[0][product_id]" class="{{ input_class }}" required>
<option value="">Sélectionner...</option>
</select>
</div>
{# 3. PRIX HT J1 #}
<div class="lg:col-span-2">
<label class="{{ label_class }}">Prix Ht (€)</label>
<input type="number" step="0.01" name="options[0][price_ht]" placeholder="0.00" class="{{ input_class }} text-right font-mono" required />
</div>
{# 6. SUPPRIMER #}
<div class="lg:col-span-1 flex justify-center pb-1">
<button type="button" data-ref="removeButton" class="p-4 bg-red-500/10 hover:bg-red-500 text-red-500 hover:text-white rounded-2xl transition-all duration-300 shadow-lg hover:shadow-red-500/20">
<svg class="w-5 h-5" 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>
</button>
</div>
</div>
</fieldset>
</li>
</ol>
<div class="mt-6 px-4">
<button type="button" data-ref="addButton" class="w-full py-4 border-2 border-dashed border-white/10 hover:border-purple-500/50 bg-white/5 hover:bg-purple-500/10 rounded-3xl text-purple-400 text-[10px] font-black uppercase tracking-[0.4em] transition-all flex items-center justify-center space-x-3 group">
<span class="text-xl group-hover:rotate-90 transition-transform duration-300">+</span>
<span>Ajouter une option</span>
</button>
</div>
</div>
{# VALIDATION #}
<div class="pt-8 px-4">
<button type="submit" class="w-full py-6 bg-blue-600 hover:bg-blue-500 text-white text-[11px] font-black uppercase tracking-[0.5em] rounded-[2rem] shadow-2xl shadow-blue-600/30 transition-all hover:scale-[1.01] active:scale-95 flex items-center justify-center group">

View File

@@ -22,7 +22,7 @@
</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>
<td style="padding: 5px 0; text-align: right; font-weight: bold; color: #2563eb;">{{ (datas.devis|totalQuoto)|number_format(2, ',', ' ') }} €</td>
</tr>
<tr>
<td style="padding: 5px 0; color: #64748b;">Date de signature :</td>