From afa61339072866329b7531f7dbef94fcd4a4f0c0 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Thu, 22 Jan 2026 15:58:57 +0100 Subject: [PATCH] =?UTF-8?q?```=20=E2=9C=A8=20feat(Product.php):=20Ajoute?= =?UTF-8?q?=20ProductDoc=20pour=20g=C3=A9rer=20les=20documents.=20?= =?UTF-8?q?=E2=9C=A8=20feat(Contrats.php):=20G=C3=A8re=20les=20fichiers=20?= =?UTF-8?q?du=20contrat=20via=20VichUploader.=20=E2=9C=A8=20feat(templates?= =?UTF-8?q?):=20Cr=C3=A9e=20template=20mail=20signature=20contrat.=20?= =?UTF-8?q?=E2=9C=A8=20feat(SignatureController):=20Ajoute=20la=20signatur?= =?UTF-8?q?e=20du=20contrat.=20=E2=9C=A8=20feat(ContratsController):=20Cr?= =?UTF-8?q?=C3=A9e=20contrat=20depuis=20devis=20et=20liste=20contrats.=20?= =?UTF-8?q?=E2=9C=A8=20feat(Client):=20Cr=C3=A9e=20soumission=20contrat=20?= =?UTF-8?q?Docuseal.=20=E2=9C=A8=20feat(DevisPdfService):=20Corrige=20l'as?= =?UTF-8?q?surance=20RC=20Pro.=20=E2=9C=A8=20feat(.env):=20Ajoute=20CONTRA?= =?UTF-8?q?T=5FBASEURL.=20=E2=9C=A8=20feat(ProductDocType):=20Cr=C3=A9e=20?= =?UTF-8?q?formulaire=20pour=20les=20documents=20produit.=20=E2=9C=A8=20fe?= =?UTF-8?q?at(contrats/list.twig):=20Liste=20et=20actions=20pour=20les=20c?= =?UTF-8?q?ontrats.=20=E2=9C=A8=20feat(UtmEvent.js):=20Track=20click=20doc?= =?UTF-8?q?ument=20produit.=20=E2=9C=A8=20feat(ContratEvent.php):=20Cr?= =?UTF-8?q?=C3=A9e=20event=20pour=20envoi=20contrat.=20=E2=9C=A8=20feat(ad?= =?UTF-8?q?min.js):=20Initialise=20la=20recherche=20dynamique=20des=20cont?= =?UTF-8?q?rats.=20=E2=9C=A8=20feat(ContratPdfService):=20G=C3=A9n=C3=A8re?= =?UTF-8?q?=20le=20PDF=20du=20contrat=20DocuSeal.=20=E2=9C=A8=20feat(produ?= =?UTF-8?q?cts/add.twig):=20Ajoute=20gestion=20des=20documents=20produits.?= =?UTF-8?q?=20=E2=9C=A8=20feat(ContratController):=20Cr=C3=A9e=20controlle?= =?UTF-8?q?ur=20contrat.=20=E2=9C=A8=20feat(ContratSubscriber.php):=20Envo?= =?UTF-8?q?i=20du=20contrat=20par=20email.=20=E2=9C=A8=20feat(reservation/?= =?UTF-8?q?produit.twig):=20Affiche=20les=20documents=20produit.=20?= =?UTF-8?q?=E2=9C=A8=20feat(ProductController.php):=20Refactorisation=20et?= =?UTF-8?q?=20ajout=20des=20documents.=20```?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 1 + assets/admin.js | 31 +- assets/tools/UtmEvent.js | 8 + config/packages/vich_uploader.yaml | 20 + migrations/Version20260122105631.php | 40 ++ migrations/Version20260122105934.php | 37 ++ migrations/Version20260122113118.php | 42 ++ migrations/Version20260122113452.php | 34 ++ migrations/Version20260122113504.php | 32 ++ migrations/Version20260122113523.php | 32 ++ migrations/Version20260122113534.php | 32 ++ migrations/Version20260122120330.php | 49 +++ src/Controller/ContratController.php | 33 ++ .../Dashboard/ContratsController.php | 208 ++++++++-- .../Dashboard/ProductController.php | 354 ++++++---------- src/Controller/SignatureController.php | 3 + src/Entity/Contrats.php | 367 ++++++++++++++++- src/Entity/ContratsOption.php | 65 +++ src/Entity/Product.php | 37 ++ src/Entity/ProductDoc.php | 152 +++++++ src/Event/Signature/ContratEvent.php | 23 ++ src/Event/Signature/ContratSubscriber.php | 56 +++ src/Form/ProductDocType.php | 49 +++ src/Repository/ContratsOptionRepository.php | 43 ++ src/Repository/ProductDocRepository.php | 43 ++ src/Service/Pdf/ContratPdfService.php | 385 ++++++++++++++++++ src/Service/Pdf/DevisPdfService.php | 2 +- src/Service/Signature/Client.php | 64 +++ templates/dashboard/contrats/list.twig | 120 ++++++ templates/dashboard/products/add.twig | 106 ++++- templates/mails/sign/contrat.twig | 48 +++ templates/revervation/produit.twig | 33 ++ 32 files changed, 2263 insertions(+), 286 deletions(-) create mode 100644 migrations/Version20260122105631.php create mode 100644 migrations/Version20260122105934.php create mode 100644 migrations/Version20260122113118.php create mode 100644 migrations/Version20260122113452.php create mode 100644 migrations/Version20260122113504.php create mode 100644 migrations/Version20260122113523.php create mode 100644 migrations/Version20260122113534.php create mode 100644 migrations/Version20260122120330.php create mode 100644 src/Controller/ContratController.php create mode 100644 src/Entity/ContratsOption.php create mode 100644 src/Entity/ProductDoc.php create mode 100644 src/Event/Signature/ContratEvent.php create mode 100644 src/Event/Signature/ContratSubscriber.php create mode 100644 src/Form/ProductDocType.php create mode 100644 src/Repository/ContratsOptionRepository.php create mode 100644 src/Repository/ProductDocRepository.php create mode 100644 src/Service/Pdf/ContratPdfService.php create mode 100644 templates/mails/sign/contrat.twig diff --git a/.env b/.env index ea85ad6..a10c588 100644 --- a/.env +++ b/.env @@ -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= diff --git a/assets/admin.js b/assets/admin.js index 4ab1b3d..6503b3f 100644 --- a/assets/admin.js +++ b/assets/admin.js @@ -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' }); diff --git a/assets/tools/UtmEvent.js b/assets/tools/UtmEvent.js index 81dfabf..b1bf454 100644 --- a/assets/tools/UtmEvent.js +++ b/assets/tools/UtmEvent.js @@ -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'); } diff --git a/config/packages/vich_uploader.yaml b/config/packages/vich_uploader.yaml index 1a67cfa..06ceeb2 100644 --- a/config/packages/vich_uploader.yaml +++ b/config/packages/vich_uploader.yaml @@ -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' diff --git a/migrations/Version20260122105631.php b/migrations/Version20260122105631.php new file mode 100644 index 0000000..a0da3e3 --- /dev/null +++ b/migrations/Version20260122105631.php @@ -0,0 +1,40 @@ +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'); + } +} diff --git a/migrations/Version20260122105934.php b/migrations/Version20260122105934.php new file mode 100644 index 0000000..3447258 --- /dev/null +++ b/migrations/Version20260122105934.php @@ -0,0 +1,37 @@ +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'); + } +} diff --git a/migrations/Version20260122113118.php b/migrations/Version20260122113118.php new file mode 100644 index 0000000..65e8d38 --- /dev/null +++ b/migrations/Version20260122113118.php @@ -0,0 +1,42 @@ +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'); + } +} diff --git a/migrations/Version20260122113452.php b/migrations/Version20260122113452.php new file mode 100644 index 0000000..646ac82 --- /dev/null +++ b/migrations/Version20260122113452.php @@ -0,0 +1,34 @@ +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'); + } +} diff --git a/migrations/Version20260122113504.php b/migrations/Version20260122113504.php new file mode 100644 index 0000000..802a205 --- /dev/null +++ b/migrations/Version20260122113504.php @@ -0,0 +1,32 @@ +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'); + } +} diff --git a/migrations/Version20260122113523.php b/migrations/Version20260122113523.php new file mode 100644 index 0000000..9fd2156 --- /dev/null +++ b/migrations/Version20260122113523.php @@ -0,0 +1,32 @@ +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'); + } +} diff --git a/migrations/Version20260122113534.php b/migrations/Version20260122113534.php new file mode 100644 index 0000000..3d7284d --- /dev/null +++ b/migrations/Version20260122113534.php @@ -0,0 +1,32 @@ +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'); + } +} diff --git a/migrations/Version20260122120330.php b/migrations/Version20260122120330.php new file mode 100644 index 0000000..de5b0ab --- /dev/null +++ b/migrations/Version20260122120330.php @@ -0,0 +1,49 @@ +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'); + } +} diff --git a/src/Controller/ContratController.php b/src/Controller/ContratController.php new file mode 100644 index 0000000..cdea22f --- /dev/null +++ b/src/Controller/ContratController.php @@ -0,0 +1,33 @@ + 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; + } } diff --git a/src/Controller/Dashboard/ProductController.php b/src/Controller/Dashboard/ProductController.php index 123730b..668050f 100644 --- a/src/Controller/Dashboard/ProductController.php +++ b/src/Controller/Dashboard/ProductController.php @@ -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'); } } diff --git a/src/Controller/SignatureController.php b/src/Controller/SignatureController.php index 91b2c46..dcdeef7 100644 --- a/src/Controller/SignatureController.php +++ b/src/Controller/SignatureController.php @@ -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')); diff --git a/src/Entity/Contrats.php b/src/Entity/Contrats.php index 6662cf4..93decb1 100644 --- a/src/Entity/Contrats.php +++ b/src/Entity/Contrats.php @@ -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 + */ + #[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 + */ + 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; + } + + + } diff --git a/src/Entity/ContratsOption.php b/src/Entity/ContratsOption.php new file mode 100644 index 0000000..9cb9716 --- /dev/null +++ b/src/Entity/ContratsOption.php @@ -0,0 +1,65 @@ +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; + } +} diff --git a/src/Entity/Product.php b/src/Entity/Product.php index 6900225..97a9498 100644 --- a/src/Entity/Product.php +++ b/src/Entity/Product.php @@ -75,10 +75,17 @@ class Product #[ORM\Column(nullable: true)] private ?int $qt = null; + /** + * @var Collection + */ + #[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 + */ + 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; + } } diff --git a/src/Entity/ProductDoc.php b/src/Entity/ProductDoc.php new file mode 100644 index 0000000..cb136b3 --- /dev/null +++ b/src/Entity/ProductDoc.php @@ -0,0 +1,152 @@ + $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; + } +} diff --git a/src/Event/Signature/ContratEvent.php b/src/Event/Signature/ContratEvent.php new file mode 100644 index 0000000..5c0bee8 --- /dev/null +++ b/src/Event/Signature/ContratEvent.php @@ -0,0 +1,23 @@ +contrats = $contrats; + } + + /** + * @return Contrats + */ + public function getContrats(): Contrats + { + return $this->contrats; + } +} diff --git a/src/Event/Signature/ContratSubscriber.php b/src/Event/Signature/ContratSubscriber.php new file mode 100644 index 0000000..a64fbc7 --- /dev/null +++ b/src/Event/Signature/ContratSubscriber.php @@ -0,0 +1,56 @@ +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 + ); + } +} diff --git a/src/Form/ProductDocType.php b/src/Form/ProductDocType.php new file mode 100644 index 0000000..a9cf4ae --- /dev/null +++ b/src/Form/ProductDocType.php @@ -0,0 +1,49 @@ +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, + ]); + } +} diff --git a/src/Repository/ContratsOptionRepository.php b/src/Repository/ContratsOptionRepository.php new file mode 100644 index 0000000..cc1411d --- /dev/null +++ b/src/Repository/ContratsOptionRepository.php @@ -0,0 +1,43 @@ + + */ +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() + // ; + // } +} diff --git a/src/Repository/ProductDocRepository.php b/src/Repository/ProductDocRepository.php new file mode 100644 index 0000000..2dd01bf --- /dev/null +++ b/src/Repository/ProductDocRepository.php @@ -0,0 +1,43 @@ + + */ +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() + // ; + // } +} diff --git a/src/Service/Pdf/ContratPdfService.php b/src/Service/Pdf/ContratPdfService.php new file mode 100644 index 0000000..3b67d19 --- /dev/null +++ b/src/Service/Pdf/ContratPdfService.php @@ -0,0 +1,385 @@ +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); + } + + +} diff --git a/src/Service/Pdf/DevisPdfService.php b/src/Service/Pdf/DevisPdfService.php index e219b4b..ef1e4ef 100644 --- a/src/Service/Pdf/DevisPdfService.php +++ b/src/Service/Pdf/DevisPdfService.php @@ -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); diff --git a/src/Service/Signature/Client.php b/src/Service/Signature/Client.php index af56811..c318cf3 100644 --- a/src/Service/Signature/Client.php +++ b/src/Service/Signature/Client.php @@ -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()); + } } diff --git a/templates/dashboard/contrats/list.twig b/templates/dashboard/contrats/list.twig index 995d347..b966613 100644 --- a/templates/dashboard/contrats/list.twig +++ b/templates/dashboard/contrats/list.twig @@ -1,2 +1,122 @@ {% extends 'dashboard/base.twig' %} +{% block title %}Contrats de locations{% endblock %} +{% block title_header %}Contrats de locations{% endblock %} + +{% block body %} +
+ + {# --- BARRE DE RECHERCHE --- #} +
+
+ + + +
+ +
+ +
+ {% for contrat in contrats %} +
+
+ + {# --- COLONNE 1 : NUMÉRO & STATUS --- #} +
+ Référence + +
+ {% if contrat.isSigned %} + + + Signé + + {% else %} + + + En attente + + {% endif %} +
+
+ + {# --- COLONNE 2 : CLIENT --- #} +
+
+
+ +
+
+ Locataire + +
+

+ + {{ contrat.customer.email }} +

+

+ + {{ contrat.customer.phone }} +

+
+
+
+
+ + {# --- COLONNE 3 : ÉVÉNEMENT --- #} +
+
+
+ +
+
+ Lieu de l'événement +

+ {{ contrat.addressEvent }}
+ {{ contrat.zipCodeEvent }} + {{ contrat.townEvent }} +

+
+
+
+ + {# --- COLONNE 4 : ACTIONS (2/12) --- #} +
+ {# VOIR #} + + + + + {# TÉLÉCHARGER #} + + + + + {# ENVOYER PAR EMAIL #} + + + + + +
+
+
+ {% endfor %} +
+
+ + {{ knp_pagination_render(contrats) }} + + {# --- JS SIMPLE POUR LA RECHERCHE INSTANTANÉE --- #} +{% endblock %} diff --git a/templates/dashboard/products/add.twig b/templates/dashboard/products/add.twig index 62587b6..e4362e4 100644 --- a/templates/dashboard/products/add.twig +++ b/templates/dashboard/products/add.twig @@ -3,6 +3,25 @@ {% block title %}Fiche Produit{% endblock %} {% block title_header %}Gestion du Matériel{% endblock %} +{% block actions %} + +{% endblock %} {% block body %}
{{ form_start(form) }} @@ -143,11 +162,6 @@ {# FOOTER ACTIONS #}
- - - Retour au catalogue - -
{{ form_end(form) }} + + {# 03. DOCUMENTS TECHNIQUES (PDF) #} + {% if formDoc is defined %} +
+

+ 03 + Documents & Notices +

+ + {# LISTE DES DOCUMENTS EXISTANTS #} +
+ {% for doc in product.productDocs %} +
+
+
+ +
+
+
{{ doc.name }}
+
+ {{ doc.isPublic ? '● Public' : '○ Privé (Interne)' }} +
+
+
+
+ + + +
+ + +
+ {# Ici tu pourrais ajouter un lien de suppression #} +
+
+ {% else %} +
+

Aucun document attaché

+
+ {% endfor %} +
+ +
+ + {# FORMULAIRE D'AJOUT #} + {{ form_start(formDoc) }} +
+
+ {{ 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'}}) }} +
+ +
+ {{ 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'}}) }} +
+ +
+ {{ 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' + } + }) }} +
+
+ +
+ +
+ {{ form_end(formDoc) }} +
+ {% endif %}
{% endblock %} diff --git a/templates/mails/sign/contrat.twig b/templates/mails/sign/contrat.twig new file mode 100644 index 0000000..cbeb36f --- /dev/null +++ b/templates/mails/sign/contrat.twig @@ -0,0 +1,48 @@ +{% extends 'mails/base.twig' %} + +{% block content %} + + + + Ludikevent • Location + + + + Votre Contrat de location + + + + + + Bonjour {{ datas.contrat.customer.surname }} {{ datas.contrat.customer.name }}, + + + + Vous trouverez ci-joint votre contrat pour votre événement à {{ datas.contrat.townEvent }}. +

+ Pour confirmer votre réservation, merci de prendre connaissance du document et de le signer électroniquement en cliquant sur le bouton ci-dessous : +
+ + + + Référence Dossier + #{{ datas.contrat.numReservation }} + + + Lieu + {{ datas.contrat.townEvent }} + + + + + Accéder à la signature en ligne + + + + Ce document est également disponible en pièce jointe de cet e-mail. +
+ Besoin d'aide ? Appelez-nous au 06 14 17 24 47. +
+
+
+{% endblock %} diff --git a/templates/revervation/produit.twig b/templates/revervation/produit.twig index c7eb8c6..a413929 100644 --- a/templates/revervation/produit.twig +++ b/templates/revervation/produit.twig @@ -170,6 +170,39 @@ + {# --- DOCUMENTS PUBLICS (NOTICES, PDF) --- #} + {% set publicDocs = product.productDocs|filter(doc => doc.isPublic) %} + {% if publicDocs|length > 0 %} +
+ Ressources techniques +
+ {% for doc in publicDocs %} + + {% endfor %} +
+
+ {% endif %} {# --- SECTION SUGGESTIONS (CROSS-SELLING) --- #}