feat(Product.php): Ajoute ProductDoc pour gérer les documents.
 feat(Contrats.php): Gère les fichiers du contrat via VichUploader.
 feat(templates): Crée template mail signature contrat.
 feat(SignatureController): Ajoute la signature du contrat.
 feat(ContratsController): Crée contrat depuis devis et liste contrats.
 feat(Client): Crée soumission contrat Docuseal.
 feat(DevisPdfService): Corrige l'assurance RC Pro.
 feat(.env): Ajoute CONTRAT_BASEURL.
 feat(ProductDocType): Crée formulaire pour les documents produit.
 feat(contrats/list.twig): Liste et actions pour les contrats.
 feat(UtmEvent.js): Track click document produit.
 feat(ContratEvent.php): Crée event pour envoi contrat.
 feat(admin.js): Initialise la recherche dynamique des contrats.
 feat(ContratPdfService): Génère le PDF du contrat DocuSeal.
 feat(products/add.twig): Ajoute gestion des documents produits.
 feat(ContratController): Crée controlleur contrat.
 feat(ContratSubscriber.php): Envoi du contrat par email.
 feat(reservation/produit.twig): Affiche les documents produit.
 feat(ProductController.php): Refactorisation et ajout des documents.
```
This commit is contained in:
Serreau Jovann
2026-01-22 15:58:57 +01:00
parent 9eafbbe2d9
commit afa6133907
32 changed files with 2263 additions and 286 deletions

1
.env
View File

@@ -85,6 +85,7 @@ STRIPE_WEBHOOKS_SECRET=
SIGN_URL=https://785fe10a414b.ngrok-free.app
STRIPE_BASEURL=https://785fe10a414b.ngrok-free.app
CONTRAT_BASEURL=https://785fe10a414b.ngrok-free.app
MINIO_S3_URL=
MINIO_S3_CLIENT_ID=

View File

@@ -21,14 +21,41 @@ Sentry.init({
replaysOnErrorSampleRate: 1.0
});
// Cache global pour éviter de fetch les produits à chaque nouvelle ligne
let productCache = null;
/**
* Gère le filtrage dynamique des listes (Contrats, Devis, etc.)
*/
function initDynamicSearch() {
const searchInput = document.getElementById('searchContrat');
const listContainer = document.getElementById('contratsList');
if (searchInput && listContainer) {
searchInput.addEventListener('input', function() {
const filter = this.value.toLowerCase();
const cards = listContainer.querySelectorAll('.contrat-card');
cards.forEach(card => {
// On récupère tout le texte de la carte pour une recherche globale
const content = card.textContent.toLowerCase();
if (content.includes(filter)) {
card.classList.remove('hidden');
// Optionnel : petite animation de ré-apparition
card.style.opacity = "1";
card.style.transform = "scale(1)";
} else {
card.classList.add('hidden');
card.style.opacity = "0";
card.style.transform = "scale(0.95)";
}
});
});
}
}
/**
* Initialise les composants de l'interface d'administration.
*/
function initAdminLayout() {
initDynamicSearch();
// Enregistrement des Custom Elements
if (!customElements.get('repeat-line')) {
customElements.define('repeat-line', RepeatLine, { extends: 'div' });

View File

@@ -32,6 +32,14 @@ export class UtmEvent extends HTMLElement {
}
try {
if (event == "click_pdf_product") {
const data = JSON.parse(dataRaw);
umami.track({
website: websiteId,
name:'Téléchargement document produit',
data: data
});
}
if (event === "view_catalogue") {
umami.track('Affichage du catalogue');
}

View File

@@ -5,10 +5,30 @@ vich_uploader:
uri_prefix: /images/image_product
upload_destination: '%kernel.project_dir%/public/images/image_product'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
doc_product:
uri_prefix: /images/doc_product
upload_destination: '%kernel.project_dir%/public/images/doc_product'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
image_options:
uri_prefix: /images/image_options
upload_destination: '%kernel.project_dir%/public/images/image_options'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
contrat_file:
uri_prefix: /images/contrat_file
upload_destination: '%kernel.project_dir%/public/images/contrat_file'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
contrat_docuseal:
uri_prefix: /images/contrat_docuseal
upload_destination: '%kernel.project_dir%/public/images/contrat_docuseal'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
contrat_signed:
uri_prefix: /images/contrat_signed
upload_destination: '%kernel.project_dir%/public/images/contrat_signed'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
contrat_audit:
uri_prefix: /images/contrat_audit
upload_destination: '%kernel.project_dir%/public/images/contrat_audit'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
devis_file:
uri_prefix: /pdf/devis_file
upload_destination: '%kernel.project_dir%/public/pdf/devis_file'

View File

@@ -0,0 +1,40 @@
<?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 Version20260122105631 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 contrat_line (id SERIAL NOT NULL, contrat_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_9C4011E81823061F ON contrat_line (contrat_id)');
$this->addSql('CREATE TABLE product_doc (id SERIAL NOT NULL, product_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, is_public BOOLEAN NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_666EA97B4584665A ON product_doc (product_id)');
$this->addSql('ALTER TABLE contrat_line ADD CONSTRAINT FK_9C4011E81823061F FOREIGN KEY (contrat_id) REFERENCES contrats (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE product_doc ADD CONSTRAINT FK_666EA97B4584665A FOREIGN KEY (product_id) REFERENCES product (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 contrat_line DROP CONSTRAINT FK_9C4011E81823061F');
$this->addSql('ALTER TABLE product_doc DROP CONSTRAINT FK_666EA97B4584665A');
$this->addSql('DROP TABLE contrat_line');
$this->addSql('DROP TABLE product_doc');
}
}

View File

@@ -0,0 +1,37 @@
<?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 Version20260122105934 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 product_doc ADD doc_product_name VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE product_doc ADD doc_product_size INT DEFAULT NULL');
$this->addSql('ALTER TABLE product_doc ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN product_doc.updated_at IS \'(DC2Type:datetime_immutable)\'');
}
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_doc DROP doc_product_name');
$this->addSql('ALTER TABLE product_doc DROP doc_product_size');
$this->addSql('ALTER TABLE product_doc DROP updated_at');
}
}

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 Version20260122113118 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('DROP SEQUENCE contrat_line_id_seq CASCADE');
$this->addSql('CREATE TABLE contrats_option (id SERIAL NOT NULL, contrat_id INT DEFAULT NULL, name VARCHAR(255) NOT NULL, price DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX IDX_A4A2C35C1823061F ON contrats_option (contrat_id)');
$this->addSql('ALTER TABLE contrats_option ADD CONSTRAINT FK_A4A2C35C1823061F FOREIGN KEY (contrat_id) REFERENCES contrats (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE contrat_line DROP CONSTRAINT fk_9c4011e81823061f');
$this->addSql('DROP TABLE contrat_line');
}
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('CREATE SEQUENCE contrat_line_id_seq INCREMENT BY 1 MINVALUE 1 START 1');
$this->addSql('CREATE TABLE contrat_line (id SERIAL NOT NULL, contrat_id INT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE INDEX idx_9c4011e81823061f ON contrat_line (contrat_id)');
$this->addSql('ALTER TABLE contrat_line ADD CONSTRAINT fk_9c4011e81823061f FOREIGN KEY (contrat_id) REFERENCES contrats (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('ALTER TABLE contrats_option DROP CONSTRAINT FK_A4A2C35C1823061F');
$this->addSql('DROP TABLE contrats_option');
}
}

View File

@@ -0,0 +1,34 @@
<?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 Version20260122113452 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 contrats ALTER details DROP NOT NULL');
$this->addSql('ALTER TABLE contrats ALTER type_sol DROP NOT NULL');
}
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 contrats ALTER details SET NOT NULL');
$this->addSql('ALTER TABLE contrats ALTER type_sol SET NOT NULL');
}
}

View File

@@ -0,0 +1,32 @@
<?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 Version20260122113504 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 contrats ALTER access DROP NOT NULL');
}
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 contrats ALTER access SET NOT NULL');
}
}

View File

@@ -0,0 +1,32 @@
<?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 Version20260122113523 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 contrats ALTER distance_power DROP NOT NULL');
}
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 contrats ALTER distance_power SET NOT NULL');
}
}

View File

@@ -0,0 +1,32 @@
<?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 Version20260122113534 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 contrats ALTER notes DROP NOT NULL');
}
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 contrats ALTER notes SET NOT NULL');
}
}

View File

@@ -0,0 +1,49 @@
<?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 Version20260122120330 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 contrats ADD devis_file_name VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE contrats ADD devis_file_size INT DEFAULT NULL');
$this->addSql('ALTER TABLE contrats ADD devis_docu_seal_file_name VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE contrats ADD devis_docu_seal_file_size INT DEFAULT NULL');
$this->addSql('ALTER TABLE contrats ADD devis_signed_file_name VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE contrats ADD devis_signed_file_size INT DEFAULT NULL');
$this->addSql('ALTER TABLE contrats ADD devis_audit_file_name VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE contrats ADD devis_audit_file_size INT DEFAULT NULL');
$this->addSql('ALTER TABLE contrats ADD update_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN contrats.update_at IS \'(DC2Type:datetime_immutable)\'');
}
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 contrats DROP devis_file_name');
$this->addSql('ALTER TABLE contrats DROP devis_file_size');
$this->addSql('ALTER TABLE contrats DROP devis_docu_seal_file_name');
$this->addSql('ALTER TABLE contrats DROP devis_docu_seal_file_size');
$this->addSql('ALTER TABLE contrats DROP devis_signed_file_name');
$this->addSql('ALTER TABLE contrats DROP devis_signed_file_size');
$this->addSql('ALTER TABLE contrats DROP devis_audit_file_name');
$this->addSql('ALTER TABLE contrats DROP devis_audit_file_size');
$this->addSql('ALTER TABLE contrats DROP update_at');
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace App\Controller;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Form\RequestPasswordConfirmType;
use App\Form\RequestPasswordRequestType;
use App\Logger\AppLogger;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
use App\Service\ResetPassword\Event\ResetPasswordEvent;
use Doctrine\ORM\EntityManagerInterface;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class ContratController extends AbstractController
{
#[Route('/gestion-contrat/{num}', name: 'gestion_contrat')]
public function gestionContrat()
{
}
}

View File

@@ -3,71 +3,125 @@
namespace App\Controller\Dashboard;
use App\Entity\Contrats;
use App\Entity\ContratsLine;
use App\Entity\ContratsOption;
use App\Entity\Devis;
use App\Event\Signature\ContratEvent;
use App\Form\Type\ContratsType;
use App\Logger\AppLogger;
use App\Repository\AccountRepository;
use App\Repository\DevisRepository;
use App\Repository\ContratsRepository;
use App\Service\Pdf\ContratPdfService;
use App\Service\Signature\Client;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route;
class ContratsController extends AbstractController
{
/**
* Liste des administrateurs
* Liste des contrats
*/
#[Route(path: '/crm/contrats', name: 'app_crm_contrats', options: ['sitemap' => false], methods: ['GET'])]
public function contrats(AccountRepository $accountRepository, AppLogger $appLogger): Response
{
$appLogger->record('VIEW', 'Consultation de la liste des contrats');
return $this->render('dashboard/contrats/list.twig',[
'contrats' => [],
]);
public function contrats(
PaginatorInterface $paginator,
AppLogger $appLogger,
EventDispatcherInterface $eventDispatcher,
ContratsRepository $contratsRepository,
Request $request
): Response {
// --- ACTION D'ENVOI PAR EMAIL ---
if ($request->query->has('idSend')) {
$contrat = $contratsRepository->find($request->query->get('idSend'));
if (!$contrat) {
$this->addFlash("danger", "Contrat introuvable.");
return $this->redirectToRoute('app_crm_contrats');
}
// Déclenchement de l'événement (ton Subscriber s'occupe de l'envoi du mail)
$event = new ContratEvent($contrat);
$eventDispatcher->dispatch($event);
$this->addFlash("success", "Le contrat a bien été envoyé à " . $contrat->getCustomer()->getEmail());
$appLogger->record('RESEND', "Renvoi du contrat N°" . $contrat->getNumReservation() . " effectué");
return $this->redirectToRoute('app_crm_contrats');
}
// --- LOG DE CONSULTATION ---
$appLogger->record('VIEW', 'Consultation de la liste des contrats');
// --- AFFICHAGE DE LA LISTE ---
$query = $contratsRepository->findBy([], ['createAt' => 'DESC']);
$pagination = $paginator->paginate(
$query,
$request->query->getInt('page', 1),
10
);
return $this->render('dashboard/contrats/list.twig', [
'contrats' => $pagination,
]);
}
#[Route(path: '/crm/contrats/add', name: 'app_crm_contrats_create', options: ['sitemap' => false], methods: ['GET','POST'])]
public function contratsAdd(Request $request,DevisRepository $devisRepository, AppLogger $appLogger): Response
{
$devis = $devisRepository->find($request->get('idDevis',0));
/**
* Création d'un contrat à partir d'un devis
*/
#[Route(path: '/crm/contrats/add', name: 'app_crm_contrats_create', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function contratsAdd(
EntityManagerInterface $entityManager,
Request $request,
Client $client,
DevisRepository $devisRepository,
AppLogger $appLogger,
EventDispatcherInterface $eventDispatcher,
KernelInterface $kernel,
): Response {
$devis = $devisRepository->find($request->get('idDevis', 0));
$c = new Contrats();
$lines =[
[
'id' => 0,
'name' => '',
'priceHt1Day' => 0,
'priceHtSupDay' => 0,
'caution' => 0,
]
];
if($devis instanceof Devis){
$lines = [['id' => 0, 'name' => '', 'priceHt1Day' => 0, 'priceHtSupDay' => 0, 'caution' => 0]];
$options = [['id' => 0, 'name' => '', 'priceHt' => 0]];
if ($devis instanceof Devis) {
$c->setDateAt($devis->getStartAt());
$c->setEndAt($devis->getEndAt());
$c->setCustomer($devis->getCustomer());
$c->setDevis($devis);
$c->setAddressEvent($devis->getAddressShip()->getAddress());
$c->setAddress2Event($devis->getAddressShip()->getAddress2());
$c->setAddress3Event($devis->getAddressShip()->getAddress3());
$c->setZipCodeEvent($devis->getAddressShip()->getZipcode());
$c->setTownEvent($devis->getAddressShip()->getCity());
// Mapping adresse de l'événement
if ($devis->getAddressShip()) {
$c->setAddressEvent($devis->getAddressShip()->getAddress());
$c->setAddress2Event($devis->getAddressShip()->getAddress2());
$c->setAddress3Event($devis->getAddressShip()->getAddress3());
$c->setZipCodeEvent($devis->getAddressShip()->getZipcode());
$c->setTownEvent($devis->getAddressShip()->getCity());
}
$lines = [];
$options = [];
foreach ($devis->getDevisLines() as $line){
$lines[] =[
foreach ($devis->getDevisLines() as $line) {
$lines[] = [
'id' => $line->getId(),
'name' => $line->getProduct()->getName()." - ".$line->getProduct()->getRef(),
'name' => $line->getProduct()->getName() . " - " . $line->getProduct()->getRef(),
'priceHt1Day' => $line->getPriceHt(),
'priceHtSupDay' => $line->getPriceHtSup(),
'caution' => $line->getProduct()->getCaution(),
];
}
foreach ($devis->getDevisOptions() as $line){
$options[] =[
foreach ($devis->getDevisOptions() as $line) {
$options[] = [
'id' => $line->getId(),
'name' => $line->getOption()->getName(),
'priceHt' => $line->getPriceHt(),
@@ -75,28 +129,92 @@ class ContratsController extends AbstractController
}
}
$form = $this->createForm(ContratsType::class,$c);
$form = $this->createForm(ContratsType::class, $c);
$form->handleRequest($request);
if($form->isSubmitted() && $form->isValid()){
//save line save options
//generate reservation number
//send contrat customer
//customer signed contrat + redirection to paiment interface
// 1 option, full paiement
// 2 option 25% arhee
// 3 options paiment caution <7j
if ($form->isSubmitted() && $form->isValid()) {
// Récupération sécurisée des données de lignes et d'options
$postData = $request->request->all();
if (isset($postData['lines'])) {
foreach ($postData['lines'] as $line) {
$vc = new ContratsLine();
$vc->setContrat($c);
$vc->setType("");
$vc->setName($line['name']);
$vc->setPrice1DayHt($line['priceHt1Day']);
$vc->setPriceSupDayHt($line['priceHtSupDay']);
$vc->setCaution($line['caution']);
$entityManager->persist($vc);
}
}
if (isset($postData['options'])) {
foreach ($postData['options'] as $line) {
$vc = new ContratsOption();
$vc->setContrat($c);
$vc->setName($line['name']);
$vc->setPrice($line['priceHt']);
$entityManager->persist($vc);
}
}
// Génération des données de réservation
$reservationNumber = $this->generateReservationNumber();
$c->setNumReservation($reservationNumber);
$c->setIsSigned(false);
$c->setCreateAt(new \DateTimeImmutable());
$contrateService = new ContratPdfService($kernel,$c,true);
$contentDocuseal = $contrateService->generate();
$tmpPathDocuseal = sys_get_temp_dir() . '/docuseal_' . uniqid() . '.pdf';
file_put_contents($tmpPathDocuseal, $contentDocuseal);
$fileDocuseal = new UploadedFile($tmpPathDocuseal, 'dc_' . $devis->getNum() . '.pdf', 'application/pdf', 0, true);
$c->setDevisDocuSealFile($fileDocuseal);
$contrateService = new ContratPdfService($kernel,$c,false);
$contentDevis = $contrateService->generate();
$tmpPathDevis = sys_get_temp_dir() . '/devis_' . uniqid() . '.pdf';
file_put_contents($tmpPathDevis, $contentDevis);
$fileDevis = new UploadedFile($tmpPathDevis, 'devis_' . $devis->getNum() . '.pdf', 'application/pdf', 0, true);
$c->setDevisFile($fileDevis);
$entityManager->persist($c);
$entityManager->flush();
$client->createSubmissionContrat($c);
// Flash & Logs
$this->addFlash('success', "Le contrat $reservationNumber a été généré avec succès.");
$appLogger->record('CREATE', "Génération contrat : $reservationNumber pour le client " . $c->getCustomer()->getName());
return $this->redirectToRoute('app_crm_contrats');
}
$appLogger->record('VIEW', 'Consultation page création contrat');
$appLogger->record('VIEW', 'Consultation création d\'un contract');
return $this->render('dashboard/contrats/add.twig',[
return $this->render('dashboard/contrats/add.twig', [
'devis' => $devis,
'form'=> $form->createView(),
'form' => $form->createView(),
'lines' => $lines,
'options' => $options,
]);
}
/**
* Génère un numéro de réservation sécurisé
*/
private function generateReservationNumber(): string
{
$prefix = 'RESERV-' . date('Ymd');
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
$randomString = '';
for ($i = 0; $i < 10; $i++) {
$randomString .= $alphabet[random_int(0, strlen($alphabet) - 1)];
}
return $prefix . '-' . $randomString;
}
}

View File

@@ -2,313 +2,213 @@
namespace App\Controller\Dashboard;
use App\Entity\Account;
use App\Entity\Options;
use App\Entity\Product;
use App\Event\Object\EventAdminCreate;
use App\Event\Object\EventAdminDeleted;
use App\Form\AccountPasswordType;
use App\Form\AccountType;
use App\Entity\ProductDoc;
use App\Form\OptionsType;
use App\Form\ProductDocType;
use App\Form\ProductType;
use App\Logger\AppLogger;
use App\Repository\AccountLoginRegisterRepository;
use App\Repository\AccountRepository;
use App\Repository\AuditLogRepository;
use App\Repository\OptionsRepository;
use App\Repository\ProductRepository;
use App\Service\Mailer\Mailer;
use App\Service\Stripe\Client;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
use Presta\SitemapBundle\Messenger\DumpSitemapMessage;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface;
use Symfony\Component\Uid\Uuid;
use Presta\SitemapBundle\Messenger\DumpSitemapMessage;
use Vich\UploaderBundle\Templating\Helper\UploaderHelper;
class ProductController extends AbstractController
{
#[Route(path: '/crm/products/json', name: 'app_crm_product_json', options: ['sitemap' => false], methods: ['GET'])]
// --- JSON ENDPOINTS ---
#[Route(path: '/crm/products/json', name: 'app_crm_product_json', methods: ['GET'])]
public function productsJson(ProductRepository $productRepository, UploaderHelper $uploaderHelper): Response
{
$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(),
// 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(),
];
}
$products = array_map(fn($p) => [
'id' => $p->getId(),
'name' => $p->getName(),
'image' => $uploaderHelper->asset($p, 'imageFile') ?: "/provider/images/favicon.png",
'price1day' => $p->getPriceDay(),
'priceSup' => $p->getPriceSup(),
'caution' => $p->getCaution(),
], $productRepository->findAll());
return $this->json($products);
}
#[Route(path: '/crm/options/json', name: 'app_crm_options_json', options: ['sitemap' => false], methods: ['GET'])]
#[Route(path: '/crm/options/json', name: 'app_crm_options_json', 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(),
];
}
$options = array_map(fn($o) => [
'id' => $o->getId(),
'name' => $o->getName(),
'image' => $uploaderHelper->asset($o, 'imageFile') ?: "/provider/images/favicon.png",
'price' => $o->getPriceHt(),
], $optionsRepository->findAll());
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
// --- LISTING ---
#[Route(path: '/crm/products', name: 'app_crm_product', methods: ['GET'])]
public function products(OptionsRepository $optionsRepo, ProductRepository $productRepo, AppLogger $logger, PaginatorInterface $paginator, Request $request): Response
{
$appLogger->record('VIEW','Consultation liste des produits');
$logger->record('VIEW', 'Consultation du catalogue (Produits & Options)');
return $this->render('dashboard/products.twig', [
// Utilisation de 'product_page' pour les produits
'products' => $paginator->paginate(
$productRepository->findBy([], ['ref' => 'asc']),
$request->query->getInt('product_page', 1),
10,
['pageParameterName' => 'product_page'] // <--- Important
),
// Utilisation de 'option_page' pour les options
'options' => $paginator->paginate(
$optionsRepository->findBy([], ['id' => 'asc']),
$request->query->getInt('option_page', 1),
10,
['pageParameterName' => 'option_page'] // <--- Important
),
'products' => $paginator->paginate($productRepo->findBy([], ['ref' => 'asc']), $request->query->getInt('product_page', 1), 10, ['pageParameterName' => 'product_page']),
'options' => $paginator->paginate($optionsRepo->findBy([], ['id' => 'asc']), $request->query->getInt('option_page', 1), 10, ['pageParameterName' => 'option_page']),
]);
}
#[Route(path: '/crm/products/options/add', name: 'app_crm_product_options_add', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function optionsAdd(
EntityManagerInterface $entityManager,
AppLogger $appLogger,
Client $client,
Request $request,
MessageBusInterface $messageBus,
): Response {
$appLogger->record('VIEW', 'Consultation page création d\'une options');
$options = new Options();
$form = $this->createForm(OptionsType::class, $options);
$form->handleRequest($request);
if($form->isSubmitted() && $form->isValid()) {
$productName = $options->getName();
$appLogger->record('CREATE', sprintf('Création du options : %s', $productName));
$options->setStripeId("");
$options->setUpdatedAt(new \DateTimeImmutable());
$client->createOptions($options);
$entityManager->persist($options);
$entityManager->flush();
$messageBus->dispatch(new DumpSitemapMessage());
$this->addFlash('success', sprintf('L\'options "%s" a été ajouté au catalogue avec succès.', $productName));
return $this->redirectToRoute('app_crm_product');
}
return $this->render('dashboard/options/add.twig', [
'form' => $form->createView(),
'product' => $options // Optionnel, utile pour l'aperçu d'image si défini
]);
}
#[Route(path: '/crm/products/add', name: 'app_crm_product_add', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function productAdd(
EntityManagerInterface $entityManager,
AppLogger $appLogger,
Client $client,
Request $request,
MessageBusInterface $messageBus,
): Response {
$appLogger->record('VIEW', 'Consultation page création d\'un produit');
// --- PRODUITS (ADD/EDIT/DELETE) ---
#[Route(path: '/crm/products/add', name: 'app_crm_product_add', methods: ['GET', 'POST'])]
public function productAdd(EntityManagerInterface $em, AppLogger $logger, Client $stripe, Request $request, MessageBusInterface $bus): Response
{
$product = new Product();
$form = $this->createForm(ProductType::class, $product);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// On récupère les données pour le log avant la sauvegarde
$productName = $product->getName();
$productRef = $product->getRef();
$em->persist($product);
$em->flush();
$stripe->createProduct($product);
$logger->record('CREATE', "Nouveau produit : [{$product->getRef()}] {$product->getName()}");
$bus->dispatch(new DumpSitemapMessage());
// Sauvegarde en base de données
$entityManager->persist($product);
$entityManager->flush();
$client->createProduct($product);
// Log de l'action de création
$appLogger->record('CREATE', sprintf('Création du produit : [%s] %s', $productRef, $productName));
// Message flash de succès
$this->addFlash('success', sprintf('Le produit "%s" a été ajouté au catalogue avec succès.', $productName));
$messageBus->dispatch(new DumpSitemapMessage());
// Redirection vers le listing des produits
return $this->redirectToRoute('app_crm_product');
$this->addFlash('success', "Le produit {$product->getName()} a été créé.");
return $this->redirectToRoute('app_crm_product_edit', ['id' => $product->getId()]);
}
return $this->render('dashboard/products/add.twig', [
'form' => $form->createView(),
'product' => $product // Optionnel, utile pour l'aperçu d'image si défini
]);
return $this->render('dashboard/products/add.twig', ['form' => $form->createView(), 'product' => $product, 'is_edit' => false]);
}
#[Route(path: '/crm/products/edit/{id}', name: 'app_crm_product_edit', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function productEdit(
Product $product,
EntityManagerInterface $entityManager,
AppLogger $appLogger,
Request $request,
Client $stripeService
): Response {
$appLogger->record('VIEW', 'Consultation modification produit : ' . $product->getName());
#[Route(path: '/crm/products/edit/{id}', name: 'app_crm_product_edit', methods: ['GET', 'POST'])]
public function productEdit(Product $product, EntityManagerInterface $em, AppLogger $logger, Request $request, Client $stripe): Response
{
// 1. Gestion de la suppression de document
if ($idDoc = $request->query->get('idDoc')) {
$doc = $em->getRepository(ProductDoc::class)->find($idDoc);
if ($doc && $doc->getProduct() === $product) {
$docName = $doc->getName();
$em->remove($doc);
$em->flush();
$logger->record('DELETE', "Document supprimé sur {$product->getName()} : $docName");
$this->addFlash('success', 'Document supprimé.');
}
return $this->redirectToRoute('app_crm_product_edit', ['id' => $product->getId()]);
}
// 2. Formulaire Document
$doc = new ProductDoc();
$doc->setProduct($product);
$formDoc = $this->createForm(ProductDocType::class, $doc);
$formDoc->handleRequest($request);
if ($formDoc->isSubmitted() && $formDoc->isValid()) {
$doc->setUpdatedAt(new \DateTimeImmutable());
$em->persist($doc);
$em->flush();
$logger->record('CREATE', "Document ajouté sur {$product->getName()} : {$doc->getName()}");
$this->addFlash('success', 'Document ajouté avec succès.');
return $this->redirectToRoute('app_crm_product_edit', ['id' => $product->getId()]);
}
// 3. Formulaire Produit
$form = $this->createForm(ProductType::class, $product);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// 1. Mise à jour Stripe si le produit possède un ID Stripe
if ($product->getProductId()) {
$stripeResult = $stripeService->updateProduct($product);
if (!$stripeResult['state']) {
$this->addFlash('warning', 'Erreur synchro Stripe : ' . $stripeResult['message']);
}
$stripe->updateProduct($product);
}
// 2. Sauvegarde en base locale
$entityManager->flush();
$appLogger->record('UPDATE', 'Mise à jour du produit : ' . $product->getName());
$this->addFlash('success', 'Le produit a été mis à jour avec succès.');
$em->flush();
$logger->record('UPDATE', "Modification produit : {$product->getName()}");
$this->addFlash('success', 'Produit mis à jour.');
return $this->redirectToRoute('app_crm_product');
}
return $this->render('dashboard/products/add.twig', [
'form' => $form->createView(),
'formDoc' => $formDoc->createView(),
'product' => $product,
'is_edit' => true
]);
}
#[Route(path: '/crm/products/options/edit/{id}', name: 'app_crm_product_options_edit', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function productOptions(
Options $product,
EntityManagerInterface $entityManager,
AppLogger $appLogger,
Request $request,
Client $stripeService
): Response {
$appLogger->record('VIEW', 'Consultation modification options : ' . $product->getName());
#[Route(path: '/crm/products/delete/{id}', name: 'app_crm_product_delete', methods: ['POST'])]
public function productDelete(Product $product, EntityManagerInterface $em, Request $request, AppLogger $logger, Client $stripe): Response
{
if ($this->isCsrfTokenValid('delete'.$product->getId(), $request->request->get('_token'))) {
$name = $product->getName();
$stripe->deleteProduct($product);
$em->remove($product);
$em->flush();
$form = $this->createForm(OptionsType::class, $product);
$logger->record('DELETE', "Suppression définitive produit : $name");
$this->addFlash('success', "Le produit $name a été supprimé.");
}
return $this->redirectToRoute('app_crm_product');
}
// --- OPTIONS (ADD/EDIT/DELETE) ---
#[Route(path: '/crm/products/options/add', name: 'app_crm_product_options_add', methods: ['GET', 'POST'])]
public function optionsAdd(EntityManagerInterface $em, AppLogger $logger, Client $stripe, Request $request): Response {
$option = new Options();
$form = $this->createForm(OptionsType::class, $option);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$option->setStripeId("")->setUpdatedAt(new \DateTimeImmutable());
$em->persist($option);
$em->flush();
// 1. Mise à jour Stripe si le produit possède un ID Stripe
if ($product->getStripeId()) {
$stripeResult = $stripeService->updateOptions($product);
if (!$stripeResult['state']) {
$this->addFlash('warning', 'Erreur synchro Stripe : ' . $stripeResult['message']);
}
}
// 2. Sauvegarde en base locale
$entityManager->flush();
$appLogger->record('UPDATE', 'Mise à jour de l\'options : ' . $product->getName());
$this->addFlash('success', 'Le options a été mis à jour avec succès.');
$stripe->createOptions($option);
$logger->record('CREATE', "Nouvelle option : {$option->getName()}");
$this->addFlash('success', "L'option {$option->getName()} a été ajoutée.");
return $this->redirectToRoute('app_crm_product');
}
return $this->render('dashboard/options/add.twig', [
'form' => $form->createView(),
'options' => $product,
'is_edit' => true
]);
return $this->render('dashboard/options/add.twig', ['form' => $form->createView(), 'product' => $option]);
}
#[Route(path: '/crm/products/delete/{id}', name: 'app_crm_product_delete', options: ['sitemap' => false], methods: ['POST'])]
public function productDelete(
Product $product,
EntityManagerInterface $entityManager,
Request $request,
AppLogger $appLogger,
Client $client
): Response {
// 1. Vérification du jeton CSRF (sécurité contre les suppressions via URL forcée)
if ($this->isCsrfTokenValid('delete' . $product->getId(), $request->query->get('_token'))) {
#[Route(path: '/crm/products/options/edit/{id}', name: 'app_crm_product_options_edit', methods: ['GET', 'POST'])]
public function optionsEdit(Options $option, EntityManagerInterface $em, AppLogger $logger, Request $request, Client $stripe): Response {
$form = $this->createForm(OptionsType::class, $option);
$form->handleRequest($request);
$productName = $product->getName();
$productRef = $product->getRef();
// 2. Log de l'action avant suppression
$appLogger->record('DELETE', sprintf('Suppression du produit : [%s] %s', $productRef, $productName));
$client->deleteProduct($product);
// 3. Suppression en base de données
$entityManager->remove($product);
$entityManager->flush();
$this->addFlash('success', sprintf('Le produit "%s" a été supprimé avec succès.', $productName));
} else {
$this->addFlash('error', 'Jeton de sécurité invalide. Impossible de supprimer le produit.');
if ($form->isSubmitted() && $form->isValid()) {
if ($option->getStripeId()) {
$stripe->updateOptions($option);
}
$em->flush();
$logger->record('UPDATE', "Modification option : {$option->getName()}");
$this->addFlash('success', 'Option mise à jour.');
return $this->redirectToRoute('app_crm_product');
}
// 4. Redirection vers le catalogue
return $this->redirectToRoute('app_crm_product'); // Remplace par le nom de ta route de listing
return $this->render('dashboard/options/add.twig', ['form' => $form->createView(), 'options' => $option, 'is_edit' => true]);
}
#[Route(path: '/crm/products/options/delete/{id}', name: 'app_crm_product_option_delete', options: ['sitemap' => false], methods: ['POST'])]
public function productOptionsDelete(
Options $product,
EntityManagerInterface $entityManager,
Request $request,
AppLogger $appLogger,
Client $client
): Response {
// 1. Vérification du jeton CSRF (sécurité contre les suppressions via URL forcée)
if ($this->isCsrfTokenValid('delete' . $product->getId(), $request->query->get('_token'))) {
#[Route(path: '/crm/products/options/delete/{id}', name: 'app_crm_product_option_delete', methods: ['POST'])]
public function optionDelete(Options $option, EntityManagerInterface $em, Request $request, AppLogger $logger, Client $stripe): Response {
if ($this->isCsrfTokenValid('delete'.$option->getId(), $request->request->get('_token'))) {
$name = $option->getName();
$stripe->deleteOptions($option);
$em->remove($option);
$em->flush();
$productName = $product->getName();
// 2. Log de l'action avant suppression
$appLogger->record('DELETE', sprintf('Suppression du produit : %s', $productName));
$client->deleteOptions($product);
// 3. Suppression en base de données
$entityManager->remove($product);
$entityManager->flush();
$this->addFlash('success', sprintf('L\'options "%s" a été supprimé avec succès.', $productName));
} else {
$this->addFlash('error', 'Jeton de sécurité invalide. Impossible de supprimer le produit.');
$logger->record('DELETE', "Suppression option : $name");
$this->addFlash('success', "L'option $name a été supprimée.");
}
// 4. Redirection vers le catalogue
return $this->redirectToRoute('app_crm_product'); // Remplace par le nom de ta route de listing
return $this->redirectToRoute('app_crm_product');
}
}

View File

@@ -40,6 +40,9 @@ class SignatureController extends AbstractController
Request $request,
Mailer $mailer,
): Response {
if ($request->get('type') === "contrat") {
dd();
}
if ($request->get('type') === "devis") {
$devis = $devisRepository->find($request->get('id'));

View File

@@ -7,8 +7,12 @@ use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Attribute\Uploadable;
use Vich\UploaderBundle\Mapping\Attribute\UploadableField;
#[ORM\Entity(repositoryClass: ContratsRepository::class)]
#[Uploadable]
class Contrats
{
#[ORM\Id]
@@ -46,22 +50,22 @@ class Contrats
#[ORM\Column(length: 255)]
private ?string $type = null;
#[ORM\Column(type: Types::TEXT)]
#[ORM\Column(type: Types::TEXT,nullable: true)]
private ?string $details = null;
#[ORM\Column(length: 255)]
#[ORM\Column(length: 255,nullable: true)]
private ?string $typeSol = null;
#[ORM\Column(length: 255)]
private ?string $pente = null;
#[ORM\Column(type: Types::TEXT)]
#[ORM\Column(type: Types::TEXT,nullable: true)]
private ?string $access = null;
#[ORM\Column]
#[ORM\Column(nullable: true)]
private ?float $distancePower = null;
#[ORM\Column(type: Types::TEXT)]
#[ORM\Column(type: Types::TEXT,nullable: true)]
private ?string $notes = null;
#[ORM\Column]
@@ -88,10 +92,48 @@ class Contrats
#[ORM\OneToMany(targetEntity: ContratsLine::class, mappedBy: 'contrat')]
private Collection $contratsLines;
/**
* @var Collection<int, ContratsOption>
*/
#[ORM\OneToMany(targetEntity: ContratsOption::class, mappedBy: 'contrat')]
private Collection $contratsOptions;
#[UploadableField(mapping: 'contrat_file', fileNameProperty: 'devisFileName', size: 'devisFileSize')]
private ?File $devisFile = null;
#[ORM\Column(nullable: true)]
private ?string $devisFileName = null;
#[ORM\Column(nullable: true)]
private ?int $devisFileSize = null;
#[UploadableField(mapping: 'contrat_docuseal', fileNameProperty: 'devisDocuSealFileName', size: 'devisDocuSealFileSize')]
private ?File $devisDocuSealFile = null;
#[ORM\Column(nullable: true)]
private ?string $devisDocuSealFileName = null;
#[ORM\Column(nullable: true)]
private ?int $devisDocuSealFileSize = null;
#[UploadableField(mapping: 'contrat_signed', fileNameProperty: 'devisSignedFileName', size: 'devisSignedFileSize')]
private ?File $devisSignFile = null;
#[ORM\Column(nullable: true)]
private ?string $devisSignedFileName = null;
#[ORM\Column(nullable: true)]
private ?int $devisSignedFileSize = null;
#[UploadableField(mapping: 'contrat_audit', fileNameProperty: 'devisAuditFileName', size: 'devisAuditFileSize')]
private ?File $devisAuditFile = null;
#[ORM\Column(nullable: true)]
private ?string $devisAuditFileName = null;
#[ORM\Column(nullable: true)]
private ?int $devisAuditFileSize = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updateAt = null;
public function __construct()
{
$this->contratsPayments = new ArrayCollection();
$this->contratsLines = new ArrayCollection();
$this->contratsOptions = new ArrayCollection();
}
public function getId(): ?int
@@ -224,7 +266,7 @@ class Contrats
return $this->details;
}
public function setDetails(string $details): static
public function setDetails(?string $details): static
{
$this->details = $details;
@@ -398,4 +440,317 @@ class Contrats
return $this;
}
/**
* @return Collection<int, ContratsOption>
*/
public function getContratsOptions(): Collection
{
return $this->contratsOptions;
}
public function addContratsOption(ContratsOption $contratsOption): static
{
if (!$this->contratsOptions->contains($contratsOption)) {
$this->contratsOptions->add($contratsOption);
$contratsOption->setContrat($this);
}
return $this;
}
public function removeContratsOption(ContratsOption $contratsOption): static
{
if ($this->contratsOptions->removeElement($contratsOption)) {
// set the owning side to null (unless already changed)
if ($contratsOption->getContrat() === $this) {
$contratsOption->setContrat(null);
}
}
return $this;
}
/**
* @return File|null
*/
public function getDevisSignFile(): ?File
{
return $this->devisSignFile;
}
/**
* @return int|null
*/
public function getDevisSignedFileSize(): ?int
{
return $this->devisSignedFileSize;
}
/**
* @return string|null
*/
public function getDevisSignedFileName(): ?string
{
return $this->devisSignedFileName;
}
/**
* @return int|null
*/
public function getDevisFileSize(): ?int
{
return $this->devisFileSize;
}
/**
* @return string|null
*/
public function getDevisFileName(): ?string
{
return $this->devisFileName;
}
/**
* @return File|null
*/
public function getDevisFile(): ?File
{
return $this->devisFile;
}
/**
* @return int|null
*/
public function getDevisDocuSealFileSize(): ?int
{
return $this->devisDocuSealFileSize;
}
/**
* @return string|null
*/
public function getDevisDocuSealFileName(): ?string
{
return $this->devisDocuSealFileName;
}
/**
* @return File|null
*/
public function getDevisDocuSealFile(): ?File
{
return $this->devisDocuSealFile;
}
/**
* @return int|null
*/
public function getDevisAuditFileSize(): ?int
{
return $this->devisAuditFileSize;
}
/**
* @return string|null
*/
public function getDevisAuditFileName(): ?string
{
return $this->devisAuditFileName;
}
/**
* @return File|null
*/
public function getDevisAuditFile(): ?File
{
return $this->devisAuditFile;
}
/**
* @return \DateTimeImmutable|null
*/
public function getUpdateAt(): ?\DateTimeImmutable
{
return $this->updateAt;
}
/**
* @param int|null $devisAuditFileSize
*/
public function setDevisAuditFileSize(?int $devisAuditFileSize): void
{
$this->devisAuditFileSize = $devisAuditFileSize;
}
/**
* @param string|null $devisFileName
*/
public function setDevisFileName(?string $devisFileName): void
{
$this->devisFileName = $devisFileName;
}
/**
* @param string|null $devisDocuSealFileName
*/
public function setDevisDocuSealFileName(?string $devisDocuSealFileName): void
{
$this->devisDocuSealFileName = $devisDocuSealFileName;
}
/**
* @param int|null $devisDocuSealFileSize
*/
public function setDevisDocuSealFileSize(?int $devisDocuSealFileSize): void
{
$this->devisDocuSealFileSize = $devisDocuSealFileSize;
}
/**
* @param int|null $devisFileSize
*/
public function setDevisFileSize(?int $devisFileSize): void
{
$this->devisFileSize = $devisFileSize;
}
/**
* @param string|null $devisSignedFileName
*/
public function setDevisSignedFileName(?string $devisSignedFileName): void
{
$this->devisSignedFileName = $devisSignedFileName;
}
/**
* @param int|null $devisSignedFileSize
*/
public function setDevisSignedFileSize(?int $devisSignedFileSize): void
{
$this->devisSignedFileSize = $devisSignedFileSize;
}
/**
* @param \DateTimeImmutable|null $updateAt
*/
public function setUpdateAt(?\DateTimeImmutable $updateAt): void
{
$this->updateAt = $updateAt;
}
/**
* @param File|null $devisSignFile
*/
public function setDevisSignFile(?File $devisSignFile): void
{
$this->devisSignFile = $devisSignFile;
}
/**
* @param string|null $adress2Event
*/
public function setAdress2Event(?string $adress2Event): void
{
$this->adress2Event = $adress2Event;
}
/**
* @param string|null $adress3Event
*/
public function setAdress3Event(?string $adress3Event): void
{
$this->adress3Event = $adress3Event;
}
/**
* @param string|null $adressEvent
*/
public function setAdressEvent(?string $adressEvent): void
{
$this->adressEvent = $adressEvent;
}
/**
* @param Collection $contratsLines
*/
public function setContratsLines(Collection $contratsLines): void
{
$this->contratsLines = $contratsLines;
}
/**
* @param Collection $contratsOptions
*/
public function setContratsOptions(Collection $contratsOptions): void
{
$this->contratsOptions = $contratsOptions;
}
/**
* @param Collection $contratsPayments
*/
public function setContratsPayments(Collection $contratsPayments): void
{
$this->contratsPayments = $contratsPayments;
}
/**
* @param File|null $devisAuditFile
*/
public function setDevisAuditFile(?File $devisAuditFile): void
{
$this->devisAuditFile = $devisAuditFile;
}
/**
* @param string|null $devisAuditFileName
*/
public function setDevisAuditFileName(?string $devisAuditFileName): void
{
$this->devisAuditFileName = $devisAuditFileName;
}
/**
* @param File|null $devisDocuSealFile
*/
public function setDevisDocuSealFile(?File $devisDocuSealFile): void
{
$this->devisDocuSealFile = $devisDocuSealFile;
}
/**
* @param File|null $devisFile
*/
public function setDevisFile(?File $devisFile): void
{
$this->devisFile = $devisFile;
}
/**
* @return string|null
*/
public function getAdress2Event(): ?string
{
return $this->adress2Event;
}
/**
* @return string|null
*/
public function getAdress3Event(): ?string
{
return $this->adress3Event;
}
/**
* @return string|null
*/
public function getAdressEvent(): ?string
{
return $this->adressEvent;
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Entity;
use App\Repository\ContratsOptionRepository;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: ContratsOptionRepository::class)]
class ContratsOption
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'contratsOptions')]
private ?Contrats $contrat = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\Column]
private ?float $price = null;
public function getId(): ?int
{
return $this->id;
}
public function getContrat(): ?Contrats
{
return $this->contrat;
}
public function setContrat(?Contrats $contrat): static
{
$this->contrat = $contrat;
return $this;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getPrice(): ?float
{
return $this->price;
}
public function setPrice(float $price): static
{
$this->price = $price;
return $this;
}
}

View File

@@ -75,10 +75,17 @@ class Product
#[ORM\Column(nullable: true)]
private ?int $qt = null;
/**
* @var Collection<int, ProductDoc>
*/
#[ORM\OneToMany(targetEntity: ProductDoc::class, mappedBy: 'product')]
private Collection $productDocs;
public function __construct()
{
$this->devisLines = new ArrayCollection();
$this->productReserves = new ArrayCollection();
$this->productDocs = new ArrayCollection();
}
public function slug()
{
@@ -332,4 +339,34 @@ class Product
{
return $this->updatedAt;
}
/**
* @return Collection<int, ProductDoc>
*/
public function getProductDocs(): Collection
{
return $this->productDocs;
}
public function addProductDoc(ProductDoc $productDoc): static
{
if (!$this->productDocs->contains($productDoc)) {
$this->productDocs->add($productDoc);
$productDoc->setProduct($this);
}
return $this;
}
public function removeProductDoc(ProductDoc $productDoc): static
{
if ($this->productDocs->removeElement($productDoc)) {
// set the owning side to null (unless already changed)
if ($productDoc->getProduct() === $this) {
$productDoc->setProduct(null);
}
}
return $this;
}
}

152
src/Entity/ProductDoc.php Normal file
View File

@@ -0,0 +1,152 @@
<?php
namespace App\Entity;
use App\Repository\ProductDocRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Attribute\Uploadable;
use Vich\UploaderBundle\Mapping\Attribute\UploadableField;
#[ORM\Entity(repositoryClass: ProductDocRepository::class)]
#[Uploadable]
class ProductDoc
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'productDocs')]
private ?Product $product = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\Column]
private ?bool $isPublic = null;
#[UploadableField(mapping: 'doc_product', fileNameProperty: 'docProductName', size: 'docProductSize')]
private ?File $docProduct = null;
#[ORM\Column(nullable: true)]
private ?string $docProductName = null;
#[ORM\Column(nullable: true)]
private ?int $docProductSize = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
public function json()
{
return json_encode([
'name' => $this->name,
'product' => $this->product->getName(),
]);
}
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 getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function isPublic(): ?bool
{
return $this->isPublic;
}
public function setIsPublic(bool $isPublic): static
{
$this->isPublic = $isPublic;
return $this;
}
/**
* @return \DateTimeImmutable|null
*/
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
/**
* @return File|null
*/
public function getDocProduct(): ?File
{
return $this->docProduct;
}
/**
* @return string|null
*/
public function getDocProductName(): ?string
{
return $this->docProductName;
}
/**
* @return int|null
*/
public function getDocProductSize(): ?int
{
return $this->docProductSize;
}
/**
* @param \DateTimeImmutable|null $updatedAt
*/
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
/**
* @param File|null $docProduct
*/
public function setDocProduct(?File $docProduct): void
{
$this->docProduct = $docProduct;
}
/**
* @param string|null $docProductName
*/
public function setDocProductName(?string $docProductName): void
{
$this->docProductName = $docProductName;
}
/**
* @param int|null $docProductSize
*/
public function setDocProductSize(?int $docProductSize): void
{
$this->docProductSize = $docProductSize;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Event\Signature;
use App\Entity\Contrats;
class ContratEvent
{
private Contrats $contrats;
public function __construct(Contrats $contrats)
{
$this->contrats = $contrats;
}
/**
* @return Contrats
*/
public function getContrats(): Contrats
{
return $this->contrats;
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Event\Signature;
use App\Entity\Contrats;
use App\Service\Mailer\Mailer;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Vich\UploaderBundle\Templating\Helper\UploaderHelper;
#[AsEventListener(event: ContratEvent::class, method: 'onContratSend')]
class ContratSubscriber
{
public function __construct(
private readonly Mailer $mailer,
private readonly UrlGeneratorInterface $urlGenerator,
private readonly KernelInterface $kernel,
private readonly UploaderHelper $uploaderHelper
) {}
public function onContratSend(ContratEvent $event): void
{
$contrat = $event->getContrats();
$customer = $contrat->getCustomer();
// Récupération du chemin relatif via VichUploader
$contratPath = $this->uploaderHelper->asset($contrat, 'devisFile');
$absolutePath = $this->kernel->getProjectDir() . "/public" . $contratPath;
$attachments = [];
// Vérification si le fichier existe physiquement avant de l'attacher
if ($contratPath && file_exists($absolutePath)) {
$attachments[] = new DataPart(
file_get_contents($absolutePath),
"Contrat_Ludikevent_" . $contrat->getNumReservation() . ".pdf",
"application/pdf"
);
}
// Envoi du mail
$this->mailer->send(
$customer->getEmail(),
$customer->getSurname() . " " . $customer->getName(),
"[Ludikevent] - Contrat de location N°" . $contrat->getNumReservation(),
"mails/sign/contrat.twig",
[
'contrat' => $contrat,
'contratLink' => $_ENV['CONTRAT_BASEURL'] . $this->urlGenerator->generate('gestion_contrat', ['num' => $contrat->getNumReservation()])
],
$attachments
);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Form;
use App\Entity\Customer;
use App\Entity\Product;
use App\Entity\ProductDoc;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class ProductDocType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name', TextType::class, [
'label' => 'Nom du document',
'required' => true,
])
->add('isPublic',ChoiceType::class, [
'choices' => [
'Non' => false,
'Oui' => true,
],
'label' => 'Afficher sur la fiche produit',
'required' => true,
])
->add('docProduct',FileType::class,[
'label' => 'Document du produit',
'required' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => ProductDoc::class,
]);
}
}

View File

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

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\ProductDoc;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<ProductDoc>
*/
class ProductDocRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, ProductDoc::class);
}
// /**
// * @return ProductDoc[] Returns an array of ProductDoc 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): ?ProductDoc
// {
// return $this->createQueryBuilder('p')
// ->andWhere('p.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -0,0 +1,385 @@
<?php
namespace App\Service\Pdf;
use App\Entity\Contrats;
use Fpdf\Fpdf;
use Symfony\Component\HttpKernel\KernelInterface;
class ContratPdfService extends Fpdf
{
private Contrats $contrats;
private string $logo;
private bool $isExtraPage = false;
private bool $isIntegrateDocusealFields;
public function __construct(KernelInterface $kernel, Contrats $contrats,bool $isIntegrateDocusealFields = false, $orientation = 'P', $unit = 'mm', $size = 'A4')
{
parent::__construct($orientation, $unit, $size);
$this->contrats = $contrats;
$this->isIntegrateDocusealFields = $isIntegrateDocusealFields; // Stockage
$this->logo = $kernel->getProjectDir()."/public/provider/images/favicon.png";
$this->AliasNbPages();
$this->SetAutoPageBreak(true, 35);
}
/**
* Convertit l'UTF-8 en Windows-1252 pour FPDF et gère l'Euro
*/
private function clean(?string $text): string
{
if (!$text) return '';
$text = iconv('UTF-8', 'windows-1252//TRANSLIT//IGNORE', $text);
return str_replace('€', chr(128), $text);
}
/**
* Helper pour afficher le symbole Euro proprement
*/
private function euro(): string
{
return ' ' . chr(128);
}
public function Header()
{
// On n'affiche le header standard que sur les pages de devis (pas CGV/Signature)
if ($this->page > 0 && !$this->isExtraPage) {
$this->SetY(10);
if (file_exists($this->logo)) {
$this->Image($this->logo, 10, 10, 12);
$this->SetX(25);
}
$this->SetFont('Arial', 'B', 14);
$this->SetTextColor(0, 0, 0);
$this->Cell(0, 7, $this->clean('Lilian SEGARD - Ludikevent'), 0, 1, 'L');
$this->SetX(25);
$this->SetFont('Arial', '', 8);
$this->SetTextColor(80, 80, 80);
$this->Cell(0, 4, $this->clean('SIRET : 930 488 408 00012 | RCS : 930 488 408'), 0, 1, 'L');
$this->SetX(25);
$this->Cell(0, 4, $this->clean('6 Rue du Château 02800 Danizy France'), 0, 1, 'L');
$this->SetX(25);
$this->Cell(0, 4, $this->clean('Tél. : 06 14 17 24 47'), 0, 1, 'L');
$this->SetX(25);
$this->Cell(0, 4, $this->clean('contact@ludikevent.fr | www.ludikevent.fr'), 0, 1, 'L');
$this->SetY(40);
$this->SetFont('Arial', 'B', 16);
$this->SetTextColor(37, 99, 235);
$this->Cell(0, 10, $this->clean('Contrat de location N° ' . $this->contrats->getNumReservation()), 0, 1, 'L');
$this->SetDrawColor(37, 99, 235);
$this->SetLineWidth(0.5);
$this->Line(10, $this->GetY(), 200, $this->GetY());
}
}
public function generate(): string
{
$this->AddPage();
$this->renderMainContent(); // Ajout du contenu principal (Page 1)
$this->addCGV(); // Page 2
$this->addSignaturePage(); // Page 3
return $this->Output('S');
}
private function addCGV(): void
{
$this->isExtraPage = true;
$this->AddPage();
$this->SetMargins(15, 15, 15);
$this->SetAutoPageBreak(true, 20);
$this->SetY(15);
// --- ENTÊTE ---
$this->SetFont('Arial', 'B', 12);
$this->SetTextColor(37, 99, 235); // Bleu Ludikevent
$this->Cell(0, 10, $this->clean('CONDITIONS GÉNÉRALES DE VENTE'), 0, 1, 'C');
$this->SetFont('Arial', 'B', 10);
$this->SetTextColor(0, 0, 0);
$this->Cell(0, 5, $this->clean('Lilian SEGARD - Ludikevent'), 0, 1, 'C');
$this->SetFont('Arial', '', 8);
$this->SetTextColor(80, 80, 80);
$this->Cell(0, 4, $this->clean('SIRET : 930 488 408 00012 | 6 Rue du Château 02800 Danizy France'), 0, 1, 'C');
$this->Cell(0, 4, $this->clean('Email : contact@ludikevent.fr | Téléphone : 06 14 17 24 47'), 0, 1, 'C');
$this->Cell(0, 4, $this->clean('Assurance RC Pro : 17006220 0001'), 0, 1, 'C');
$this->Ln(5);
$this->SetFont('Arial', 'I', 9);
$this->SetTextColor(0, 0, 0);
$this->MultiCell(0, 5, $this->clean("Toute réservation implique l'acceptation sans réserve des présentes Conditions Générales de Vente."), 0, 'C');
$this->Ln(5);
// --- TEXTE INTÉGRAL ---
$fullText = [
"ARTICLE 1 OBJET ET CHAMP D'APPLICATION" => "Les présentes Conditions Générales de Vente (ci-après \"CGV\") régissent les relations contractuelles entre Lilian SEGARD - Ludikevent (ci-après \"le Prestataire\") et toute personne physique ou morale (ci-après \"le Client\" ou \"le Locataire\") dans le cadre de :\n• La location de structures gonflables professionnelles appartenant à Lilian SEGARD - Ludikevent\n• La mise en relation avec des propriétaires privés pour du matériel récréatif destiné à des événements privés (anniversaires, baptêmes, etc.)\nDans le cas d'une mise en relation entre particuliers : Lilian SEGARD - Ludikevent intervient uniquement en qualité d'intermédiaire. Le contrat de location est conclu directement entre le propriétaire du matériel et le locataire. Lilian SEGARD - Ludikevent n'assume aucune responsabilité liée à l'état, la conformité ou l'utilisation dudit matériel.",
"ARTICLE 2 RÉSERVATION" => "La réservation devient ferme et définitive après réunion des trois conditions cumulatives suivantes :\n✓ Confirmation écrite par Lilian SEGARD - Ludikevent (email ou courrier)\n✓ Paiement effectif des arrhes de 25% du montant total\n✓ Acceptation expresse et signature des présentes CGV\nLes prestations sont proposées sous réserve de disponibilité du matériel aux dates demandées. Lilian SEGARD - Ludikevent se réserve le droit de refuser toute réservation si les conditions d'installation ou de sécurité ne sont pas réunies, sans que sa responsabilité ne puisse être engagée.",
"ARTICLE 3 TARIFS ET CONDITIONS DE PAIEMENT" => "Les tarifs sont exprimés en euros (€) toutes taxes comprises (TTC) et sont ceux en vigueur au jour de la réservation, conformément au devis ou contrat signé.\nModalités de paiement :\n• Arrhes de 25% à la réservation (non remboursables sauf cas de force majeure dûment justifié)\n• Solde dû au plus tard le jour de l'installation, AVANT le montage des structures\nEn cas de non-paiement du solde à la date prévue, Lilian SEGARD - Ludikevent se réserve le droit de ne pas procéder à la livraison ni à l'installation, sans que le Client puisse prétendre à un quelconque remboursement des arrhes versées. Des frais de livraison, d'installation et de déplacement peuvent s'appliquer selon la distance et les conditions d'accès au lieu de l'événement. Modes de paiement acceptés : virement bancaire, chèque (à établir à l'ordre de Lilian SEGARD - Ludikevent).",
"ARTICLE 4 DROIT DE RÉTRACTATION" => "Conformément à l'article L221-28 du Code de la consommation, le droit de rétractation de 14 jours ne s'applique pas aux prestations de services pleinement exécutées avant la fin du délai de rétractation et dont l'exécution a commencé après accord préalable exprès du consommateur et renoncement exprès à son droit de rétractation. En outre, aucun droit de rétractation n'est applicable pour une prestation de services dont la date d'exécution est fixée à une date déterminée ou à période déterminée (événement daté).",
"ARTICLE 5 CONDITIONS D'ANNULATION" => "5.1 Annulation par le Client :\n• Annulation après versement des arrhes : les arrhes restent acquises au Prestataire à titre d'indemnité forfaitaire\n• Annulation moins de 15 jours calendaires avant la date de l'événement : arrhes non remboursables + facturation de 50% du solde restant dû\n• Annulation moins de 7 jours calendaires avant la date de l'événement : intégralité du montant reste due\n• Cas de force majeure dûment justifié (certificat médical, décès, catastrophe naturelle) : étude au cas par cas, possibilité de report de la prestation\n\n5.2 Annulation par Lilian SEGARD - Ludikevent :\nEn cas d'impossibilité totale d'assurer la prestation pour cause de force majeure (conditions météorologiques extrêmes, défaillance matérielle majeure, cas fortuit), Lilian SEGARD - Ludikevent s'engage à : informer le Client dans les meilleurs délais, proposer une solution de report ou de remplacement dans la mesure du possible, procéder au remboursement intégral des sommes versées si aucune solution alternative n'est acceptable. La responsabilité de Lilian SEGARD - Ludikevent ne saurait être engagée au-delà du remboursement des sommes effectivement perçues.",
"ARTICLE 6 CAUTION DE GARANTIE" => "Une caution de garantie peut être exigée selon le type et la valeur du matériel loué :\n• Structures professionnelles Lilian SEGARD - Ludikevent | Selon devis | Restitution immédiate après état des lieux de fin de location et contrôle de conformité\n• Matériel mis en relation (propriétaires privés) | Selon convention propriétaire | Restitution selon accord entre parties\nLa caution peut être conservée totalement ou partiellement en cas de : dégradation du matériel, salissure importante nécessitant un nettoyage approfondi, perte d'éléments ou d'accessoires, non-respect des conditions d'utilisation ayant entraîné des dommages.",
"ARTICLE 7 LIVRAISON, INSTALLATION ET RÉCUPÉRATION" => "7.1 Structures professionnelles Lilian SEGARD - Ludikevent :\nInstallation : L'installation est réalisée exclusivement par le personnel qualifié de Lilian SEGARD - Ludikevent. Le Client doit fournir un terrain plat, propre, dégagé, avec une alimentation électrique 220V à moins de 50 mètres. L'accès doit être praticable pour les véhicules.\nRécupération : La récupération du matériel est effectuée par Lilian SEGARD - Ludikevent aux horaires convenus.\n7.2 Matériel en mise en relation :\nLilian SEGARD - Ludikevent peut assurer l'installation et la récupération pour le compte du propriétaire privé. Le Client locataire reste tenu d'assurer une surveillance permanente et constante. Des frais de déplacement supplémentaires peuvent s'appliquer au-delà d'un rayon de 30 km depuis Danizy.",
"ARTICLE 8 ÉTATS DES LIEUX ET TRANSFERT DE RESPONSABILITÉ" => "8.1 État des lieux d'installation : Un état des lieux contradictoire est OBLIGATOIREMENT réalisé. La signature par le Client vaut reconnaissance de conformité et TRANSFERT COMPLET DE LA RESPONSABILITÉ DU MATÉRIEL AU CLIENT. Le Client assume l'ENTIÈRE et EXCLUSIVE responsabilité du matériel, de son utilisation, de sa surveillance et des dommages qui pourraient en résulter.\n8.2 État des lieux de fin de location : Un état des lieux contradictoire est réalisé lors de la récupération. Sa signature vaut RETOUR DE LA RESPONSABILITÉ À Lilian SEGARD - Ludikevent.\n8.3 Absence de signature : En cas d'absence ou de refus de signature, l'état des lieux établi par Lilian SEGARD - Ludikevent fera foi. Le matériel sera réputé avoir été livré en parfait état et la responsabilité du Client reste pleine et entière durant toute la période de location.",
"ARTICLE 9 OBLIGATIONS ET RESPONSABILITÉS DU CLIENT" => "Le Client s'engage IMPÉRATIVEMENT à :\n9.1 Surveillance obligatoire : Assurer une surveillance PERMANENTE, CONSTANTE et ACTIVE par un adulte majeur responsable.\n9.2 Respect des consignes : Ébriété interdite, retrait des chaussures, bijoux, objets pointus, pas de nourriture ou boissons.\n9.3 Conditions météorologiques : ARRÊTER IMMÉDIATEMENT l'utilisation en cas de vent > 40 km/h, pluie ou orage.\n9.4 Intégrité du matériel : Ne JAMAIS déplacer, démonter ou modifier les structures, ni débrancher la soufflerie. Signaler toute anomalie immédiatement.",
"ARTICLE 10 EXCLUSION ET LIMITATION DE RESPONSABILITÉ" => "10.1 Lilian SEGARD - Ludikevent garantit exclusivement la livraison d'un matériel conforme et une installation aux normes.\n10.2 Durant la période de location, Lilian SEGARD - Ludikevent décline FORMELLEMENT et INTÉGRALEMENT toute responsabilité concernant : les dommages corporels (blessures, accidents) subis par les utilisateurs, les dommages matériels causés aux tiers, les conséquences d'une surveillance insuffisante ou du non-respect des consignes.\n10.3 Le Client reconnaît être le SEUL responsable de la sécurité, assumer tous les risques et renoncer IRRÉVOCABLEMENT à tout recours contre Lilian SEGARD - Ludikevent.",
"ARTICLE 11 ASSURANCE OBLIGATOIRE DU CLIENT" => "Le Client doit OBLIGATOIREMENT disposer d'une assurance RC en cours de validité couvrant l'utilisation de structures gonflables. En l'absence d'assurance valide, Lilian SEGARD - Ludikevent se réserve le droit d'annuler la prestation sans remboursement possible.",
"ARTICLE 12 CONDITIONS MÉTÉOROLOGIQUES" => "La sécurité est prioritaire. Lilian SEGARD - Ludikevent peut refuser l'installation ou interrompre la prestation en cas de danger. Aucun remboursement ne pourra être réclamé si le matériel a déjà été installé conformément au contrat.",
"ARTICLE 13 DÉGRADATIONS, PERTES ET VOLS" => "Toute dégradation ou vol survenu durant la location sera INTÉGRALEMENT facturé au Client (réparation ou valeur de remplacement). Toute dégradation non signalée lors de l'état des lieux d'installation est irréfragablement imputable au Client.",
"ARTICLE 14 PROTECTION DES DONNÉES PERSONNELLES" => "Données collectées EXCLUSIVEMENT pour la gestion du contrat (RGPD). Droit d'accès et de rectification via contact@ludikevent.fr.",
"ARTICLE 15 RÉCLAMATIONS" => "Toute réclamation doit être adressée par écrit sous 8 jours à : Lilian SEGARD - Ludikevent, 6 Rue du Château, 02800 Danizy. Passé ce délai, aucune réclamation n'est recevable.",
"ARTICLE 16 MÉDIATION" => "Le Client consommateur peut recourir gratuitement à un médiateur de la consommation en vue de la résolution amiable du litige.",
"ARTICLE 17 DROIT APPLICABLE ET JURIDICTION COMPÉTENTE" => "Loi française. Compétence EXCLUSIVE aux tribunaux du ressort du siège de Lilian SEGARD - Ludikevent (Tribunal de Saint-Quentin).",
"ARTICLE 18 ACCEPTATION DES CONDITIONS GÉNÉRALES DE VENTE" => "Le fait de procéder à une réservation vaut ACCEPTATION PLEINE, ENTIÈRE et SANS RÉSERVE des présentes CGV. Le Client reconnaît en avoir pris connaissance et les avoir comprises."
];
foreach ($fullText as $titre => $corps) {
$this->SetFont('Arial', 'B', 9);
$this->SetTextColor(37, 99, 235);
$this->MultiCell(0, 5, $this->clean($titre), 0, 'L');
$this->SetFont('Arial', '', 8.5);
$this->SetTextColor(0, 0, 0);
$this->MultiCell(0, 4, $this->clean($corps), 0, 'L');
$this->Ln(3);
}
}
private function addSignaturePage(): void
{
$this->isExtraPage = true;
$this->AddPage();
$this->SetY(30);
// Titre
$this->SetFont('Arial', 'B', 14);
$this->SetTextColor(37, 99, 235);
$this->Cell(0, 10, $this->clean("BON POUR ACCORD ET SIGNATURE"), 0, 1, 'C');
$this->Ln(10);
$this->SetFont('Arial', '', 10);
$this->SetTextColor(0, 0, 0);
$this->Cell(0, 7, $this->clean("Fait à Danizy, le : " . date('d/m/Y')), 0, 1, 'L');
$this->Ln(10);
// --- SECTION DES CASES À COCHER ---
$this->SetFont('Arial', '', 10);
$checkPoints = [
"J'accepte sans réserve les Conditions Générales de Vente ci-jointes." => "cgv",
"Je reconnais avoir pris connaissance de l'obligation de disposer d'une assurance RC." => "assurance",
"Je reconnais avoir pris connaissance des mesures de sécurité et de surveillance." => "securite",
"Je reconnais avoir pris connaissance du paiement des arrhes de 25% pour validation (non remboursables)." => "arrhes"
];
foreach ($checkPoints as $label => $role) {
$currentY = $this->GetY();
$this->Rect(15, $currentY + 1, 5, 5); // Le carré reste visible pour tout le monde
$this->SetX(25);
$this->MultiCell(0, 7, $this->clean($label), 0, 'L');
// AJOUT CONDITIONNEL DOCUSEAL
if ($this->isIntegrateDocusealFields) {
$this->SetXY(15, $currentY + 1);
$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(10);
}
$this->Ln(15);
$ySign = $this->GetY();
// --- BLOC PRESTATAIRE ---
$this->SetXY(15, $ySign);
$this->SetFont('Arial', 'B', 10);
$this->Cell(85, 8, $this->clean("Le Prestataire"), 0, 1, 'C');
$this->SetX(15);
$this->Cell(85, 45, "", 1, 0);
$this->SetXY(17, $ySign + 12);
// AJOUT CONDITIONNEL SIGNATURE PRESTATAIRE
if ($this->isIntegrateDocusealFields) {
$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 ---
$this->SetTextColor(0, 0, 0);
$this->SetXY(110, $ySign);
$this->SetFont('Arial', 'B', 10);
$this->Cell(85, 8, $this->clean("Le Client (Lu et approuvé)"), 0, 1, 'C');
$this->SetX(110);
$this->Cell(85, 45, "", 1, 0);
// AJOUT CONDITIONNEL SIGNATURE CLIENT
if ($this->isIntegrateDocusealFields) {
$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');
}
}
private function renderAddressBlock(string $label, $address, string $align, int $x, int $y)
{
if (!$address) return;
$this->SetXY($x, $y);
$this->SetFont('Arial', 'B', 8); $this->SetTextColor(100, 100, 100);
$this->Cell(90, 5, $this->clean($label), 0, 1, $align);
$this->SetFont('Arial', '', 10); $this->SetTextColor(50, 50, 50);
$lines = [$address->getAddress(), $address->getAddress2(), $address->getZipcode() . ' ' . $address->getCity()];
foreach ($lines as $line) { if ($line) { $this->SetX($x); $this->Cell(90, 5, $this->clean($line), 0, 1, $align); } }
}
public function Footer(): void
{
$this->SetY(-15);
$this->SetFont('Arial', 'I', 7);
$this->SetTextColor(150, 150, 150);
$this->Cell(0, 10, $this->clean('Contrat N° '.$this->contrats->getNumReservation().' - Ludikevent - Page ' . $this->PageNo() . '/{nb}'), 0, 0, 'C');
}
private function renderMainContent(): void
{
// --- 1. BLOCS IDENTITÉ (LOCATAIRE & LIEU) ---
$this->SetY(55);
$this->SetFont('Arial', 'B', 10);
$this->SetFillColor(245, 245, 245);
$this->Cell(92, 7, $this->clean(" LE LOCATAIRE"), 0, 0, 'L', true);
$this->Cell(6, 7, "", 0, 0);
$this->Cell(92, 7, $this->clean(" LIEU DE L'ÉVÉNEMENT"), 0, 1, 'L', true);
$this->Ln(2);
$startY = $this->GetY();
$this->SetFont('Arial', '', 10);
$this->SetTextColor(50, 50, 50);
// Gauche : Client
$this->SetX(10);
$nomComplet = $this->contrats->getCustomer()->getSurname() . ' ' . $this->contrats->getCustomer()->getName();
$this->MultiCell(92, 5, $this->clean($nomComplet . "\n" . "Tél : " . $this->contrats->getCustomer()->getPhone() . "\n" . "Email : " . $this->contrats->getCustomer()->getEmail()), 0, 'L');
// Droite : Adresse
$this->SetXY(108, $startY);
$adresseEvent = $this->contrats->getAddressEvent() . "\n" . ($this->contrats->getAddress2Event() ? $this->contrats->getAddress2Event() . "\n" : "") . ($this->contrats->getAddress3Event() ? $this->contrats->getAddress3Event() . "\n" : "") . $this->contrats->getZipCodeEvent() . " " . $this->contrats->getTownEvent();
$this->MultiCell(92, 5, $this->clean($adresseEvent), 0, 'L');
$this->Ln(8);
// --- 2. LOGISTIQUE & TECHNIQUE ---
$this->SetFont('Arial', 'B', 10);
$this->SetFillColor(250, 250, 250);
$this->Cell(0, 7, $this->clean(" LOGISTIQUE ET INSTALLATION"), 0, 1, 'L', true);
$this->Ln(2);
$this->SetFont('Arial', '', 9);
$this->SetFont('Arial', 'B', 9); $this->Cell(45, 5, $this->clean("Nature du sol :"), 0, 0);
$this->SetFont('Arial', '', 9); $this->Cell(50, 5, $this->clean($this->contrats->getTypeSol()), 0, 0);
$this->SetFont('Arial', 'B', 9); $this->Cell(45, 5, $this->clean("Distance électricité :"), 0, 0);
$this->SetFont('Arial', '', 9); $this->Cell(0, 5, $this->clean($this->contrats->getDistancePower()), 0, 1);
$this->SetFont('Arial', 'B', 9); $this->Cell(45, 5, $this->clean("Pente / Dénivelé :"), 0, 0);
$this->SetFont('Arial', '', 9); $this->Cell(50, 5, $this->clean($this->contrats->getPente()), 0, 0);
$this->SetFont('Arial', 'B', 9); $this->Cell(45, 5, $this->clean("Accès véhicule :"), 0, 0);
$this->SetFont('Arial', '', 9); $this->Cell(0, 5, $this->clean($this->contrats->getAccess()), 0, 1);
$this->Ln(6);
// --- 3. TABLEAU FINANCIER ---
$interval = $this->contrats->getDateAt()->diff($this->contrats->getEndAt());
$nbJoursTotal = $interval->days + 1;
$nbJoursSup = max(0, $nbJoursTotal - 1);
$this->SetFont('Arial', 'B', 9);
$this->SetFillColor(37, 99, 235);
$this->SetTextColor(255, 255, 255);
$this->Cell(100, 8, $this->clean("Désignation"), 1, 0, 'L', true);
$this->Cell(30, 8, $this->clean("Tarif HT"), 1, 0, 'C', true);
$this->Cell(30, 8, $this->clean("Durée"), 1, 0, 'C', true);
$this->Cell(30, 8, $this->clean("Sous-Total"), 1, 1, 'C', true);
$this->SetTextColor(0, 0, 0);
$this->SetFont('Arial', '', 9);
$totalHt = 0; $totalCaution = 0;
foreach ($this->contrats->getContratsLines() as $line) {
$sousTotal = $line->getPrice1DayHt() + ($line->getPriceSupDayHt() * $nbJoursSup);
$totalHt += $sousTotal;
$totalCaution += $line->getCaution();
$this->Cell(100, 7, $this->clean($line->getName()), 1, 0, 'L');
$this->Cell(30, 7, number_format($line->getPrice1DayHt(), 2) . $this->euro(), 1, 0, 'C');
$this->Cell(30, 7, $nbJoursTotal . " j.", 1, 0, 'C');
$this->Cell(30, 7, number_format($sousTotal, 2) . $this->euro(), 1, 1, 'R');
}
foreach ($this->contrats->getContratsOptions() as $opt) {
$totalHt += $opt->getPrice();
$this->SetFillColor(245, 245, 245);
$this->Cell(100, 7, $this->clean("[Option] " . $opt->getName()), 1, 0, 'L', true);
$this->Cell(30, 7, number_format($opt->getPrice(), 2) . $this->euro(), 1, 0, 'C', true);
$this->Cell(30, 7, "Forfait", 1, 0, 'C', true);
$this->Cell(30, 7, number_format($opt->getPrice(), 2) . $this->euro(), 1, 1, 'R', true);
}
$this->Ln(5);
// --- 4. RÉCAPITULATIF FINANCIER ---
$arrhes = $totalHt * 0.25;
$this->SetX(110);
$this->SetFont('Arial', 'B', 10);
$this->Cell(60, 9, $this->clean("TOTAL GÉNÉRAL HT"), 1, 0, 'L');
$this->Cell(30, 9, number_format($totalHt, 2) . $this->euro(), 1, 1, 'R');
$this->SetX(110);
$this->SetTextColor(37, 99, 235);
$this->Cell(60, 9, $this->clean("ARRHES À VERSER (25%)"), 1, 0, 'L');
$this->Cell(30, 9, number_format($arrhes, 2) . $this->euro(), 1, 1, 'R');
$this->SetX(110);
$this->SetFont('Arial', 'I', 7); $this->SetTextColor(100, 100, 100);
$this->MultiCell(90, 4, $this->clean("* Arrhes non remboursables sauf cas de force majeure dûment justifié (voir CGV ART 3)."), 0, 'R');
$this->Ln(2);
$this->SetX(110);
$this->SetFont('Arial', 'B', 10); $this->SetTextColor(220, 38, 38);
$this->Cell(60, 9, $this->clean("CAUTION DE GARANTIE"), 1, 0, 'L');
$this->Cell(30, 9, number_format($totalCaution, 2) . $this->euro(), 1, 1, 'R');
$this->SetTextColor(0, 0, 0);
}
}

View File

@@ -276,7 +276,7 @@ class DevisPdfService extends Fpdf
$this->SetTextColor(80, 80, 80);
$this->Cell(0, 4, $this->clean('SIRET : 930 488 408 00012 | 6 Rue du Château 02800 Danizy France'), 0, 1, 'C');
$this->Cell(0, 4, $this->clean('Email : contact@ludikevent.fr | Téléphone : 06 14 17 24 47'), 0, 1, 'C');
$this->Cell(0, 4, $this->clean('Assurance RC Pro : [N° de police à compléter]'), 0, 1, 'C');
$this->Cell(0, 4, $this->clean('Assurance RC Pro : 17006220 0001'), 0, 1, 'C');
$this->Ln(5);
$this->SetFont('Arial', 'I', 9);

View File

@@ -2,6 +2,7 @@
namespace App\Service\Signature;
use App\Entity\Contrats;
use App\Entity\CustomerOrder;
use App\Entity\Devis;
use Doctrine\ORM\EntityManagerInterface;
@@ -163,4 +164,67 @@ class Client
$result = $this->docuseal->getSubmitter($getSignatureId);
$this->docuseal->archiveSubmission($result['submission_id']);
}
public function createSubmissionContrat(Contrats $devis): string
{
// Si aucune signature n'est lancée, on initialise la soumission
if ($devis->getSignID() === null) {
// URL où le client sera redirigé après signature
$completedRedirectUrl = $this->baseUrl . $this->urlGenerator->generate(
'app_sign_complete',
['type' => 'contrat', 'id' => $devis->getId()]
);
// Récupération du fichier via VichUploader (champ devisDocuSealFile)
$relativeFileUrl = $this->storage->resolveUri($devis, 'devisDocuSealFile');
$fileUrl = $this->baseUrl . $relativeFileUrl;
$submission = $this->docuseal->createSubmissionFromPdf([
'name' => 'Contrat N°' . $devis->getNumReservation(), // Correction : getNum()
'completed_redirect_url' => $completedRedirectUrl,
'send_email' => true,
'documents' => [
[
'name' => 'contrat_' . $devis->getNumReservation() . '.pdf', // Correction : getNum()
'file' => $fileUrl,
],
],
'submitters' => [
[
'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' => 'contrat'
]
],
],
]);
// Stockage de l'ID submitter de Docuseal dans ton entité
$devis->setSignID($submission['submitters'][1]['id']);
$this->entityManager->flush();
}
return $this->getLinkSign($devis->getSignID());
}
}

View File

@@ -1,2 +1,122 @@
{% extends 'dashboard/base.twig' %}
{% block title %}Contrats de locations{% endblock %}
{% block title_header %}Contrats de locations{% endblock %}
{% block body %}
<div class="space-y-6 pb-20">
{# --- BARRE DE RECHERCHE --- #}
<div class="relative group mb-12">
<div class="absolute inset-y-0 left-0 pl-6 flex items-center pointer-events-none">
<svg class="w-5 h-5 text-slate-500 group-focus-within:text-blue-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
<input type="text"
id="searchContrat"
placeholder="Rechercher un contrat (Nom, N° ou Ville)..."
class="w-full bg-white/5 border border-white/10 backdrop-blur-xl text-white text-sm rounded-[1.5rem] pl-14 pr-6 py-5 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition-all placeholder:text-slate-500 font-medium">
</div>
<div id="contratsList" class="space-y-6">
{% for contrat in contrats %}
<div class="contrat-card bg-white/5 border border-white/10 backdrop-blur-md rounded-[2rem] overflow-hidden hover:border-blue-500/40 transition-all group">
<div class="grid grid-cols-1 lg:grid-cols-12">
{# --- COLONNE 1 : NUMÉRO & STATUS --- #}
<div class="lg:col-span-2 p-8 bg-white/[0.02] flex flex-col justify-center border-r border-white/5">
<span class="text-[9px] font-black text-blue-500 uppercase tracking-widest mb-2 block text-search">Référence</span>
<h3 class="text-white font-black italic text-lg tracking-tighter text-search">{{ contrat.numReservation }}</h3>
<div class="mt-4">
{% if contrat.isSigned %}
<span class="px-3 py-1 bg-emerald-500/10 text-emerald-500 text-[8px] font-black uppercase rounded-lg border border-emerald-500/20 inline-flex items-center gap-1">
<svg class="w-2 h-2" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path></svg>
Signé
</span>
{% else %}
<span class="px-3 py-1 bg-amber-500/10 text-amber-500 text-[8px] font-black uppercase rounded-lg border border-amber-500/20 inline-flex items-center gap-1">
<svg class="w-2 h-2" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"></path></svg>
En attente
</span>
{% endif %}
</div>
</div>
{# --- COLONNE 2 : CLIENT --- #}
<div class="lg:col-span-4 p-8 border-r border-white/5">
<div class="flex items-start gap-4">
<div class="w-10 h-10 bg-blue-600/10 rounded-xl flex items-center justify-center text-blue-500 shrink-0">
<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="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>
</div>
<div>
<span class="text-[9px] font-black text-slate-500 uppercase tracking-widest mb-1 block">Locataire</span>
<p class="text-white font-bold text-lg uppercase italic text-search">{{ contrat.customer.surname }} {{ contrat.customer.name }}</p>
<div class="mt-2 space-y-1">
<p class="text-slate-400 text-xs flex items-center gap-2">
<svg class="w-3 h-3 text-blue-500/50" 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"></path></svg>
{{ contrat.customer.email }}
</p>
<p class="text-slate-400 text-xs flex items-center gap-2">
<svg class="w-3 h-3 text-blue-500/50" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"></path></svg>
{{ contrat.customer.phone }}
</p>
</div>
</div>
</div>
</div>
{# --- COLONNE 3 : ÉVÉNEMENT --- #}
<div class="lg:col-span-4 p-8 border-r border-white/5">
<div class="flex items-start gap-4">
<div class="w-10 h-10 bg-amber-500/10 rounded-xl flex items-center justify-center text-amber-500 shrink-0">
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
</div>
<div>
<span class="text-[9px] font-black text-slate-500 uppercase tracking-widest mb-1 block">Lieu de l'événement</span>
<p class="text-white font-medium text-sm leading-relaxed">
{{ contrat.addressEvent }}<br>
<span class="text-amber-500 font-bold tracking-wider text-search">{{ contrat.zipCodeEvent }}</span>
<span class="text-white font-black uppercase italic text-search">{{ contrat.townEvent }}</span>
</p>
</div>
</div>
</div>
{# --- COLONNE 4 : ACTIONS (2/12) --- #}
<div class="lg:col-span-2 p-6 flex flex-row lg:flex-col justify-center gap-2">
{# VOIR #}
<a href="{{ path('app_crm_contrats', {id: contrat.id}) }}"
title="Voir les détails"
class="flex-1 lg:flex-none py-3 bg-white/5 hover:bg-blue-600 text-white rounded-xl flex items-center justify-center transition-all group/btn border border-white/5 shadow-lg shadow-black/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="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path></svg>
</a>
{# TÉLÉCHARGER #}
<a download="contrat N°{{ contrat.numReservation }}"
href="{{ vich_uploader_asset(contrat,'devisFile') }}"
title="Télécharger le PDF"
class="flex-1 lg:flex-none py-3 bg-white/5 hover:bg-emerald-600 text-white rounded-xl flex items-center justify-center transition-all border border-white/5 shadow-lg shadow-black/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"></path></svg>
</a>
{# ENVOYER PAR EMAIL #}
<a href="{{ path('app_crm_contrats', {idSend: contrat.id}) }}"
title="Envoyer le contrat au client"
onclick="return confirm('Souhaitez-vous envoyer ce contrat par email à {{ contrat.customer.email }} ?')"
class="flex-1 lg:flex-none py-3 bg-white/5 hover:bg-indigo-500 text-white rounded-xl flex items-center justify-center transition-all border border-white/5 shadow-lg shadow-black/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="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path>
</svg>
</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{{ knp_pagination_render(contrats) }}
{# --- JS SIMPLE POUR LA RECHERCHE INSTANTANÉE --- #}
{% endblock %}

View File

@@ -3,6 +3,25 @@
{% block title %}Fiche Produit{% endblock %}
{% block title_header %}Gestion du <span class="text-blue-500">Matériel</span>{% endblock %}
{% block actions %}
<div class="flex items-center gap-4">
<a target="_blank" rel="nofollow"
href="https://reservation.ludikevent.fr{{ path('reservation_product_show', {id: product.slug}) }}"
class="flex items-center px-6 py-3 bg-white/5 hover:bg-white/10 border border-white/10 hover:border-blue-500/50 text-white text-[10px] font-black uppercase tracking-[0.2em] rounded-2xl transition-all group shadow-xl backdrop-blur-md">
<svg class="w-4 h-4 mr-2.5 text-blue-500 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
Voir sur le site
<svg class="w-3 h-3 ml-2 text-slate-500 group-hover:translate-x-1 group-hover:-translate-y-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
</svg>
</a>
</div>
{% endblock %}
{% block body %}
<div class="max-w-6xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-700">
{{ form_start(form) }}
@@ -143,11 +162,6 @@
{# FOOTER ACTIONS #}
<div class="mt-12 mb-20 flex items-center justify-between backdrop-blur-xl bg-slate-900/40 p-6 rounded-[2rem] border border-white/5 shadow-xl">
<a href="{{ path('app_crm_product') }}" class="px-8 py-3 text-[10px] font-black text-slate-400 hover:text-white uppercase tracking-widest transition-colors flex items-center group">
<svg class="w-4 h-4 mr-2 transform group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
Retour au catalogue
</a>
<button type="submit" class="relative overflow-hidden group px-12 py-4 bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-black uppercase tracking-[0.2em] rounded-2xl transition-all shadow-lg shadow-blue-600/30">
<span class="relative z-10 flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" /></svg>
@@ -158,5 +172,87 @@
</div>
{{ form_end(form) }}
{# 03. DOCUMENTS TECHNIQUES (PDF) #}
{% if formDoc is defined %}
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl mt-8">
<h3 class="text-lg font-bold text-white mb-6 flex items-center">
<span class="w-8 h-8 bg-amber-600/20 text-amber-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">03</span>
Documents & Notices
</h3>
{# LISTE DES DOCUMENTS EXISTANTS #}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-10">
{% for doc in product.productDocs %}
<div class="flex items-center justify-between p-4 bg-white/5 border border-white/5 rounded-2xl group hover:bg-white/10 transition-all">
<div class="flex items-center">
<div class="w-10 h-10 bg-red-500/20 text-red-500 rounded-xl flex items-center justify-center mr-4">
<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="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>
</div>
<div>
<div class="text-xs font-black text-white uppercase tracking-wider">{{ doc.name }}</div>
<div class="text-[9px] font-bold {{ doc.isPublic ? 'text-emerald-500' : 'text-slate-500' }} uppercase tracking-tighter">
{{ doc.isPublic ? '● Public' : '○ Privé (Interne)' }}
</div>
</div>
</div>
<div class="flex gap-2">
<a href="{{ vich_uploader_asset(doc, 'docProduct') }}" target="_blank" class="p-2 text-slate-400 hover:text-white transition-colors">
<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="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/></svg>
</a>
<form data-turbo="false" method="post" action="{{ path('app_crm_product_edit', {'id': product.id,act:'deleted',idDoc:doc.id}) }}"
onsubmit="return confirm('Confirmer la suppression de ce document ?');"
class="inline-block">
<input type="hidden" name="_token" value="{{ csrf_token('delete' ~ doc.id) }}">
<button type="submit" class="p-2 text-slate-400 hover:text-rose-500 transition-colors" title="Supprimer">
<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>
</form>
{# Ici tu pourrais ajouter un lien de suppression #}
</div>
</div>
{% else %}
<div class="md:col-span-2 py-8 text-center border-2 border-dashed border-white/5 rounded-3xl">
<p class="text-[10px] font-black text-slate-600 uppercase tracking-[0.2em]">Aucun document attaché</p>
</div>
{% endfor %}
</div>
<div class="h-px bg-white/5 w-full mb-10"></div>
{# FORMULAIRE D'AJOUT #}
{{ form_start(formDoc) }}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 items-end">
<div>
{{ form_label(formDoc.name, 'Nom du document', {'label_attr': {'class': 'text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] ml-1 mb-2 block'}}) }}
{{ form_widget(formDoc.name, {'attr': {'placeholder': 'Ex: Notice technique PDF', 'class': 'w-full bg-slate-900/50 border-white/5 rounded-2xl text-white focus:ring-amber-500/20 focus:border-amber-500 transition-all py-4 px-5 font-bold text-sm'}}) }}
</div>
<div>
{{ form_label(formDoc.isPublic, 'Visibilité Client', {'label_attr': {'class': 'text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] ml-1 mb-2 block'}}) }}
{{ form_widget(formDoc.isPublic, {'attr': {'class': 'w-full bg-slate-900/50 border-white/5 rounded-2xl text-white focus:ring-blue-500/20 focus:border-blue-500 transition-all py-4 px-5 font-bold text-sm cursor-pointer'}}) }}
</div>
<div class="md:col-span-2 bg-slate-950/40 p-6 rounded-[2rem] border border-dashed border-white/10 group hover:border-amber-500/30 transition-all">
{{ form_label(formDoc.docProduct, 'Fichier PDF (Notice, Certificat...)', {'label_attr': {'class': 'text-[10px] font-black text-slate-500 uppercase tracking-widest mb-4 block text-center'}}) }}
{{ form_widget(formDoc.docProduct, {
'attr': {
'class': 'block w-full text-xs text-slate-400 file:mr-4 file:py-3 file:px-6 file:rounded-xl file:border-0 file:text-[10px] file:font-black file:uppercase file:tracking-widest file:bg-amber-600 file:text-white hover:file:bg-amber-500 transition-all cursor-pointer'
}
}) }}
</div>
</div>
<div class="mt-8 flex justify-end">
<button type="submit" class="group px-8 py-4 bg-amber-600/10 hover:bg-amber-600 text-amber-500 hover:text-white text-[10px] font-black uppercase tracking-widest rounded-2xl transition-all border border-amber-500/20 flex items-center">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M12 4v16m8-8H4"/></svg>
Ajouter au dossier
</button>
</div>
{{ form_end(formDoc) }}
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-section background-color="#ffffff" padding="40px 20px" border-radius="20px">
<mj-column>
<mj-text align="center" font-weight="900" font-size="10px" color="#3b82f6" letter-spacing="2px" text-transform="uppercase">
Ludikevent • Location
</mj-text>
<mj-text align="center" font-weight="900" font-style="italic" font-size="28px" color="#1e293b" text-transform="uppercase" padding-top="10px">
Votre <span style="color: #3b82f6;">Contrat</span> de location
</mj-text>
<mj-divider border-width="1px" border-color="#e2e8f0" width="100px" padding-top="20px" padding-bottom="20px" />
<mj-text font-size="15px" color="#475569" line-height="1.6">
Bonjour <strong>{{ datas.contrat.customer.surname }} {{ datas.contrat.customer.name }}</strong>,
</mj-text>
<mj-text font-size="15px" color="#475569" line-height="1.6">
Vous trouverez ci-joint votre contrat pour votre événement à <strong>{{ datas.contrat.townEvent }}</strong>.
<br/><br/>
Pour confirmer votre réservation, merci de prendre connaissance du document et de le <strong>signer électroniquement</strong> en cliquant sur le bouton ci-dessous :
</mj-text>
<mj-section background-color="#f8fafc" border="1px solid #e2e8f0" border-radius="15px" padding="20px">
<mj-column>
<mj-text font-size="12px" color="#64748b" padding="0">Référence Dossier</mj-text>
<mj-text font-size="16px" font-weight="bold" color="#1e293b" padding="5px 0 0 0">#{{ datas.contrat.numReservation }}</mj-text>
</mj-column>
<mj-column>
<mj-text font-size="12px" color="#64748b" padding="0">Lieu</mj-text>
<mj-text font-size="16px" font-weight="bold" color="#1e293b" padding="5px 0 0 0">{{ datas.contrat.townEvent }}</mj-text>
</mj-column>
</mj-section>
<mj-button background-color="#3b82f6" color="#ffffff" font-size="13px" font-weight="900" text-transform="uppercase" border-radius="10px" href="{{ datas.contratLink }}" padding-top="35px" inner-padding="16px 35px">
Accéder à la signature en ligne
</mj-button>
<mj-text align="center" font-size="11px" color="#94a3b8" padding-top="40px">
Ce document est également disponible en pièce jointe de cet e-mail.
<br/>
Besoin d'aide ? Appelez-nous au <strong>06 14 17 24 47</strong>.
</mj-text>
</mj-column>
</mj-section>
{% endblock %}

View File

@@ -170,6 +170,39 @@
</div>
</div>
{# --- DOCUMENTS PUBLICS (NOTICES, PDF) --- #}
{% set publicDocs = product.productDocs|filter(doc => doc.isPublic) %}
{% if publicDocs|length > 0 %}
<div class="mt-12 mb-12">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-[0.3em] mb-4 block italic">Ressources techniques</span>
<div class="space-y-3">
{% for doc in publicDocs %}
<div class="cta-file">
<utm-event event="click_pdf_product" data="{{ doc.json }}" ></utm-event>
<a href="{{ vich_uploader_asset(doc, 'docProduct') }}"
download="{{ doc.name }}.pdf"
class="flex items-center justify-between p-5 bg-white border border-slate-100 rounded-3xl hover:border-blue-500 hover:shadow-lg transition-all group">
<div class="flex items-center gap-4">
<div class="w-10 h-10 bg-red-50 text-red-500 rounded-xl flex items-center justify-center group-hover:bg-red-500 group-hover:text-white transition-colors">
<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="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>
</div>
<div>
<p class="text-[11px] font-black text-slate-900 uppercase italic tracking-wider">{{ doc.name }}</p>
</div>
</div>
<div class="text-slate-300 group-hover:text-blue-600 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
</svg>
</div>
</a>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</main>
{# --- SECTION SUGGESTIONS (CROSS-SELLING) --- #}