diff --git a/.env b/.env index 866689e..a691ab6 100644 --- a/.env +++ b/.env @@ -83,8 +83,8 @@ STRIPE_PK=pk_test_51SUA22173W4aeFB1nO6oFfDZ12HOTffDKtCshhZ8rkUg6kUO2ZaQC0tK72rhE STRIPE_SK=sk_test_51SUA22173W4aeFB16EB2LxGI0hNvNJzFshDI98zRImWBIhSfzqOGAz5TlPxSpUWbj3x4COm6kmSsaal9FpQR1A7M0022DvjbbR STRIPE_WEBHOOKS_SECRET= -SIGN_URL=https://e3358705e82c.ngrok-free.app -STRIPE_BASEURL=https://e3358705e82c.ngrok-free.app +SIGN_URL=https://e2221f1f9e85.ngrok-free.app +STRIPE_BASEURL=https://e2221f1f9e85.ngrok-free.app MINIO_S3_URL= MINIO_S3_CLIENT_ID= diff --git a/.gitignore b/.gitignore index 1aeadb7..024971a 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,4 @@ backup/*.sql /public/images/**/*.jpeg /public/images/**/*.webp /public/images/*/*.png +/public/pdf/**/*.pdf diff --git a/ansible/playbook.yml b/ansible/playbook.yml index 1390292..fc152ba 100644 --- a/ansible/playbook.yml +++ b/ansible/playbook.yml @@ -107,6 +107,7 @@ - "{{ path }}/var/log" # Specific for log, though var/log might be created by composer later - "{{ path }}/public/media" # For uploads - "{{ path }}/public/images" # For uploads + - "{{ path }}/public/pdf" # For uploads - "{{ path }}/public/tmp-sign" # For upload - "{{ path }}/sauvegarde" @@ -245,5 +246,6 @@ - "{{ path }}/public/media" - "{{ path }}/sauvegarde" - "{{ path }}/public/images" # For uploads + - "{{ path }}/public/pdf" - "{{ path }}/public/tmp-sign" # For uploads diff --git a/assets/libs/DevisManager.js b/assets/libs/DevisManager.js index 50d2b76..7b52631 100644 --- a/assets/libs/DevisManager.js +++ b/assets/libs/DevisManager.js @@ -1,35 +1,39 @@ export class DevisManager extends HTMLDivElement { connectedCallback() { this.customerSelect = this.querySelector('select'); + // On s'assure de bien cibler les éléments dans le DOM this.billAddress = this.parentElement.parentElement.querySelector('#billAddress'); this.shipAddress = this.parentElement.parentElement.querySelector('#shipAddress'); - // Initialisation du message par défaut au chargement this.setAddressPlaceholder("Sélectionnez un client..."); if (this.customerSelect) { - this.customerSelect.addEventListener('change', (e) => this.updateCustomerInfo(e.target.value)); + // Utiliser TomSelect si présent sur le client, sinon l'event natif + if (this.customerSelect.tomselect) { + this.customerSelect.tomselect.on('change', (value) => this.updateCustomerInfo(value)); + } else { + this.customerSelect.addEventListener('change', (e) => this.updateCustomerInfo(e.target.value)); + } } } - /** - * Met à jour le texte d'aide dans les champs TomSelect - */ setAddressPlaceholder(text) { [this.billAddress, this.shipAddress].forEach(el => { if (el && el.tomselect) { el.tomselect.settings.placeholder = text; - el.tomselect.inputState(); // Force la mise à jour visuelle + el.tomselect.inputState(); } }); } async updateCustomerInfo(customerId) { - // Purge et message d'attente + // Vider les options actuelles proprement [this.billAddress, this.shipAddress].forEach(el => { - if (el.tomselect) { + if (el && el.tomselect) { el.tomselect.clear(); el.tomselect.clearOptions(); + // On désactive pendant le chargement pour éviter une saisie erronée + el.tomselect.disable(); } }); @@ -38,25 +42,38 @@ export class DevisManager extends HTMLDivElement { return; } - // Pendant le chargement this.setAddressPlaceholder("Chargement des adresses..."); try { const resp = await fetch("/crm/customer/address/" + customerId); const data = await resp.json(); + [this.billAddress, this.shipAddress].forEach(el => el?.tomselect?.enable()); + if (data.addressList && data.addressList.length > 0) { this.setAddressPlaceholder("Choisir une adresse..."); data.addressList.forEach(itemList => { - const option = { value: itemList.id, text: itemList.label }; + const option = { value: itemList.id, text: itemList.label }; // Adapté aux valueField/labelField par défaut + const option2 = { value: itemList.id, text: itemList.label }; // Adapté aux valueField/labelField par défaut if (this.billAddress.tomselect) this.billAddress.tomselect.addOption(option); - if (this.shipAddress.tomselect) this.shipAddress.tomselect.addOption(option); + if (this.shipAddress.tomselect) this.shipAddress.tomselect.addOption(option2); }); - // Optionnel : Sélectionner la première adresse par défaut - this.billAddress.tomselect.setValue(data.addressList[0].id); - this.shipAddress.tomselect.setValue(data.addressList[0].id); + // --- SÉLECTION ET SYNCHRONISATION --- + const firstId = data.addressList[0].id; + + if (this.billAddress.tomselect) { + + this.billAddress.tomselect.setValue(firstId); + this.billAddress.dispatchEvent(new Event('change', { bubbles: true })); + } + + if (this.shipAddress.tomselect) { + this.shipAddress.tomselect.setValue(firstId); + this.shipAddress.dispatchEvent(new Event('change', { bubbles: true })); + } + } else { this.setAddressPlaceholder("Aucune adresse trouvée"); } @@ -64,6 +81,7 @@ export class DevisManager extends HTMLDivElement { } catch (error) { console.error("Erreur adresses:", error); this.setAddressPlaceholder("Erreur de chargement"); + [this.billAddress, this.shipAddress].forEach(el => el?.tomselect?.enable()); } } } diff --git a/assets/libs/initTomSelect.js b/assets/libs/initTomSelect.js index f728834..bee27cb 100644 --- a/assets/libs/initTomSelect.js +++ b/assets/libs/initTomSelect.js @@ -23,20 +23,31 @@ export function initTomSelect(parent = document) { // Dans admin.js, section onChange de TomSelect : onChange: (id) => { if (!id) return; - const product = data.find(p => p.id == id); + + // On s'assure de trouver le produit (id peut être string ou int) + const product = data.find(p => String(p.id) === String(id)); + if (product) { + // On remonte au parent le plus proche (le bloc de ligne du devis) const row = el.closest('.form-repeater__row') || el.closest('fieldset'); + if (!row) return; - // Remplir Prix J1 + // Ciblage précis des inputs const priceInput = row.querySelector('input[name*="[price_ht]"]'); - if (priceInput) priceInput.value = product.price1day; - - // Remplir Prix Sup (Nouveau champ) const priceSupInput = row.querySelector('input[name*="[price_sup_ht]"]'); - if (priceSupInput) priceSupInput.value = product.priceSup; - // Déclencher les events - [priceInput, priceSupInput].forEach(i => i?.dispatchEvent(new Event('change', { bubbles: true }))); + if (priceInput) { + priceInput.value = product.price1day; + // Indispensable pour que d'autres scripts (calcul totaux) voient le changement + priceInput.dispatchEvent(new Event('input', { bubbles: true })); + priceInput.dispatchEvent(new Event('change', { bubbles: true })); + } + + if (priceSupInput) { + priceSupInput.value = product.priceSup; + priceSupInput.dispatchEvent(new Event('input', { bubbles: true })); + priceSupInput.dispatchEvent(new Event('change', { bubbles: true })); + } } }, render: { diff --git a/config/packages/vich_uploader.yaml b/config/packages/vich_uploader.yaml index 526cb9d..f9706fb 100644 --- a/config/packages/vich_uploader.yaml +++ b/config/packages/vich_uploader.yaml @@ -6,18 +6,18 @@ vich_uploader: upload_destination: '%kernel.project_dir%/public/images/image_product' namer: Vich\UploaderBundle\Naming\SmartUniqueNamer devis_file: - uri_prefix: /images/devis_file - upload_destination: '%kernel.project_dir%/public/images/devis_file' + uri_prefix: /pdf/devis_file + upload_destination: '%kernel.project_dir%/public/pdf/devis_file' namer: Vich\UploaderBundle\Naming\SmartUniqueNamer devis_docuseal: - uri_prefix: /images/devis_docusign - upload_destination: '%kernel.project_dir%/public/images/devis_docusign' + uri_prefix: /pdf/devis_docusign + upload_destination: '%kernel.project_dir%/public/pdf/devis_docusign' namer: Vich\UploaderBundle\Naming\SmartUniqueNamer devis_signed: - uri_prefix: /images/devis_signed - upload_destination: '%kernel.project_dir%/public/images/devis_signed' + uri_prefix: /pdf/devis_signed + upload_destination: '%kernel.project_dir%/public/pdf/devis_signed' namer: Vich\UploaderBundle\Naming\SmartUniqueNamer devis_audit: - uri_prefix: /images/devis_audit - upload_destination: '%kernel.project_dir%/public/images/devis_audit' + uri_prefix: /pdf/devis_audit + upload_destination: '%kernel.project_dir%/public/pdf/devis_audit' namer: Vich\UploaderBundle\Naming\SmartUniqueNamer diff --git a/public/pdf/.gitignore b/public/pdf/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/src/Controller/Dashboard/DevisController.php b/src/Controller/Dashboard/DevisController.php index 824f09f..ba1d94c 100644 --- a/src/Controller/Dashboard/DevisController.php +++ b/src/Controller/Dashboard/DevisController.php @@ -2,19 +2,24 @@ namespace App\Controller\Dashboard; +use App\Entity\CustomerAddress; use App\Entity\Devis; use App\Entity\DevisLine; use App\Form\NewDevisType; use App\Logger\AppLogger; use App\Repository\AccountRepository; +use App\Repository\CustomerAddressRepository; use App\Repository\CustomerRepository; use App\Repository\DevisRepository; use App\Repository\ProductRepository; use App\Service\Pdf\DevisPdfService; +use App\Service\Signature\Client; use Doctrine\ORM\EntityManagerInterface; use Knp\Bundle\PaginatorBundle\KnpPaginatorBundle; use Knp\Component\Pager\PaginatorInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\KernelInterface; @@ -26,21 +31,15 @@ class DevisController extends AbstractController * Liste des administrateurs */ #[Route(path: '/crm/devis', name: 'app_crm_devis', options: ['sitemap' => false], methods: ['GET'])] - public function devis(KernelInterface $kernel,DevisRepository $devisRepository,AppLogger $appLogger,PaginatorInterface $paginator,Request $request): Response + public function devis(Client $client,EntityManagerInterface $entityManager,KernelInterface $kernel,DevisRepository $devisRepository,AppLogger $appLogger,PaginatorInterface $paginator,Request $request): Response { - $d = $devisRepository->findAll()[0]; - $f = new DevisPdfService($kernel,$d); - $f->generate(); - $f->Output('I'); - - $appLogger->record('VIEW', 'Consultation de la liste des devis'); return $this->render('dashboard/devis/list.twig',[ 'quotes' => $paginator->paginate($devisRepository->findBy([],['createA'=>'asc']),$request->get('page', 1),20), ]); } #[Route(path: '/crm/devis/add', name: 'app_crm_devis_add', options: ['sitemap' => false], methods: ['GET','POST'])] - public function devisAdd(ProductRepository $productRepository,EntityManagerInterface $entityManager,CustomerRepository $customerRepository,DevisRepository $devisRepository, AppLogger $appLogger,Request $request): Response + public function devisAdd(Client $client,KernelInterface $kernel,CustomerAddressRepository $customerAddress,ProductRepository $productRepository,EntityManagerInterface $entityManager,CustomerRepository $customerRepository,DevisRepository $devisRepository, AppLogger $appLogger,Request $request): Response { $devisNumber ="DEVIS-".sprintf('%05d',$devisRepository->count()+1); $appLogger->record('VIEW', 'Consultation de la création d\'un devis'); @@ -53,6 +52,8 @@ class DevisController extends AbstractController $form = $this->createForm(NewDevisType::class,$devis); if($request->isMethod('POST')){ + $devis->setBillAddress($customerAddress->find($_POST['devis']['bill_address'])); + $devis->setAddressShip($customerAddress->find($_POST['devis']['ship_address'])); $devis->setCustomer($customerRepository->find($_POST['new_devis']['customer'])); foreach ($_POST['lines'] as $cd=>$line) { $rLine = new DevisLine(); @@ -67,8 +68,33 @@ class DevisController extends AbstractController $entityManager->persist($rLine); } $entityManager->persist($devis); - $entityManager->flush(); + + $docusealService = new DevisPdfService($kernel, $devis, true); + $contentDocuseal = $docusealService->generate(); + + $tmpPathDocuseal = sys_get_temp_dir() . '/docuseal_' . uniqid() . '.pdf'; + file_put_contents($tmpPathDocuseal, $contentDocuseal); + + $fileDocuseal = new UploadedFile($tmpPathDocuseal,'dc_'.$devis->getNum() . '.pdf','application/pdf',0,true); + $devis->setDevisDocuSealFile($fileDocuseal); + + + $devisService = new DevisPdfService($kernel, $devis, false); + $contentDevis = $devisService->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); + $devis->setDevisFile($fileDevis); + + + $devis->setState("created_waitsign"); + $devis->setUpdateAt(new \DateTimeImmutable()); + $entityManager->flush(); + $client->createSubmissionDevis($devis); + return $this->redirectToRoute('app_crm_devis'); } return $this->render('dashboard/devis/add.twig',[ 'form' => $form->createView(), diff --git a/src/Controller/SignatureController.php b/src/Controller/SignatureController.php new file mode 100644 index 0000000..181d8c0 --- /dev/null +++ b/src/Controller/SignatureController.php @@ -0,0 +1,34 @@ +devis = $devis; + $this->isIntegrateDocusealFields = $isIntegrateDocusealFields; // Stockage $this->logo = $kernel->getProjectDir()."/public/provider/images/favicon.png"; $this->AliasNbPages(); @@ -318,45 +320,49 @@ class DevisPdfService extends Fpdf foreach ($checkPoints as $label => $role) { $currentY = $this->GetY(); - $this->Rect(15, $currentY + 1, 5, 5); + $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'); - // Balise DocuSeal Check - $this->SetXY(15, $currentY + 1); - $this->SetTextColor(255, 255, 255); - $this->SetFont('Arial', '', 4); - $this->Cell(5, 5, '{{Check;required=true;role=Client;name='.$role.'}}', 0, 0, 'C'); + // AJOUT CONDITIONNEL DOCUSEAL + if ($this->isIntegrateDocusealFields) { + $this->SetXY(15, $currentY + 1); + $this->SetTextColor(255, 255, 255); // Blanc (invisible) + $this->SetFont('Arial', '', 4); + $this->Cell(5, 5, '{{Check;required=true;role=Client;name='.$role.'}}', 0, 0, 'C'); + + $this->SetTextColor(0, 0, 0); + $this->SetFont('Arial', '', 10); + } - $this->SetTextColor(0, 0, 0); - $this->SetFont('Arial', '', 10); $this->Ln(4); } $this->Ln(15); $ySign = $this->GetY(); - // --- BLOCS DE SIGNATURE --- - - // Cadre Prestataire (Lilian SEGARD) + // --- 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); // Rectangle de signature + $this->Cell(85, 45, "", 1, 0); - // Texte à l'intérieur du rectangle du prestataire $this->SetXY(17, $ySign + 12); $this->SetFont('Arial', 'I', 8); $this->SetTextColor(100, 100, 100); + $mention = "Signée par Lilian SEGARD - Ludikevent - Le ".date('d/m/Y')." par signature numérique validée"; + $this->MultiCell(81, 5, $this->clean($mention), 0, 'C'); - // Balise Signature Ludikevent (Cachée pour DocuSeal) - $this->SetXY(15, $ySign + 25); - $this->SetTextColor(255, 255, 255); - $this->SetFont('Arial', '', 4); - $this->Cell(85, 5, '{{Sign;type=signature;role=Ludikevent}}', 0, 0, 'C'); + // AJOUT CONDITIONNEL SIGNATURE PRESTATAIRE + if ($this->isIntegrateDocusealFields) { + $this->SetXY(15, $ySign + 25); + $this->SetTextColor(255, 255, 255); + $this->SetFont('Arial', '', 4); + $this->Cell(85, 5, '{{Sign;type=signature;role=Ludikevent}}', 0, 0, 'C'); + } - // Cadre Client + // --- BLOC CLIENT --- $this->SetTextColor(0, 0, 0); $this->SetXY(110, $ySign); $this->SetFont('Arial', 'B', 10); @@ -364,11 +370,13 @@ class DevisPdfService extends Fpdf $this->SetX(110); $this->Cell(85, 45, "", 1, 0); - // Balise Signature Client (Cachée pour DocuSeal) - $this->SetXY(110, $ySign + 20); - $this->SetTextColor(255, 255, 255); - $this->SetFont('Arial', '', 4); - $this->Cell(85, 5, '{{Sign;type=signature;role=Client}}', 0, 0, 'C'); + // AJOUT CONDITIONNEL SIGNATURE CLIENT + if ($this->isIntegrateDocusealFields) { + $this->SetXY(110, $ySign + 20); + $this->SetTextColor(255, 255, 255); + $this->SetFont('Arial', '', 4); + $this->Cell(85, 5, '{{Sign;type=signature;role=Client}}', 0, 0, 'C'); + } } private function renderAddressBlock(string $label, $address, string $align, int $x, int $y) @@ -382,19 +390,28 @@ class DevisPdfService extends Fpdf foreach ($lines as $line) { if ($line) { $this->SetX($x); $this->Cell(90, 5, $this->clean($line), 0, 1, $align); } } } - public function Footer() + public function Footer(): void { + // Positionnement à 1,5 cm du bas $this->SetY(-15); + $this->SetFont('Arial', 'I', 7); + $this->SetTextColor(150, 150, 150); - // BALISE DOCUSEAL CACHÉE (INITIALES) - $this->SetFont('Arial', '', 4); - $this->SetTextColor(255, 255, 255); - $this->Cell(40, 10, '{{Photo;type=initials;role=Client}}', 0, 0, 'L'); + // Texte gauche : Identification du devis + $this->Cell(0, 10, $this->clean('Devis Ludik Event - Lilian SEGARD - SIRET 93048840800012'), 0, 0, 'L'); - // TEXTE VISIBLE + // --- AJOUT DU CHAMP DOCUSEAL DANS LE FOOTER --- + if ($this->isIntegrateDocusealFields) { + $this->SetX(-60); // On se place vers la droite + $this->SetTextColor(255, 255, 255); // Invisible + $this->SetFont('Arial', '', 4); + // Utilisation d'un champ "Initials" qui est souvent utilisé en bas de page + $this->Cell(30, 10, '{{Initials;role=Client;name=paraphe}}', 0, 0, 'R'); + } + + // Numérotation des pages à droite + $this->SetTextColor(150, 150, 150); $this->SetFont('Arial', 'I', 8); - $this->SetTextColor(128, 128, 128); - $this->Cell(110, 10, $this->clean('Devis N° ' . $this->devis->getNum() . ' | Page ' . $this->PageNo() . '/{nb}'), 0, 0, 'C'); - $this->Cell(0, 10, 'Lilian SEGARD - Ludikevent - 930 488 408', 0, 0, 'R'); + $this->Cell(0, 10, 'Page ' . $this->PageNo() . '/{nb}', 0, 0, 'R'); } } diff --git a/src/Service/Signature/Client.php b/src/Service/Signature/Client.php index 1f1ce6c..c3f9b73 100644 --- a/src/Service/Signature/Client.php +++ b/src/Service/Signature/Client.php @@ -24,10 +24,10 @@ class Client ) { // Configuration via les variables d'environnement $key = $_ENV['ESYSIGN_APIEY'] ?? ''; - $this->baseUrl = "https://signature.esy-web.dev"; + $this->baseUrl = $_ENV['SIGN_URL']; // L'URL API est le point d'entrée pour le SDK Docuseal - $apiUrl = rtrim($this->baseUrl, '/') . '/api'; + $apiUrl = rtrim("https://signature.esy-web.dev", '/') . '/api'; $this->docuseal = new \Docuseal\Api($key, $apiUrl); } @@ -60,6 +60,11 @@ class Client ], ], 'submitters' => [ + [ + 'role' => 'Ludikevent', + 'email' => 'contact@ludikevent.fr', + 'completed' => true, + ], [ 'role' => 'Client', 'email' => $devis->getCustomer()->getEmail(), @@ -73,7 +78,7 @@ class Client ]); // Stockage de l'ID submitter de Docuseal dans ton entité - $devis->setSignatureId($submission['submitters'][0]['id']); + $devis->setSignatureId($submission['submitters'][1]['id']); $this->entityManager->flush(); } diff --git a/templates/dashboard/devis/list.twig b/templates/dashboard/devis/list.twig index 430009b..6164a7e 100644 --- a/templates/dashboard/devis/list.twig +++ b/templates/dashboard/devis/list.twig @@ -63,19 +63,17 @@ {# STATUT DYNAMIQUE #}