diff --git a/assets/etl.js b/assets/etl.js index 0cdd4b7..0c52a0c 100644 --- a/assets/etl.js +++ b/assets/etl.js @@ -1,3 +1,63 @@ import './etl.scss'; console.log('ETL Mobile Loaded'); + +document.addEventListener('DOMContentLoaded', () => { + const modal = document.getElementById('lightbox-modal'); + if (!modal) return; + + const img = document.getElementById('lightbox-img'); + const video = document.getElementById('lightbox-video'); + const closeBtn = document.getElementById('lightbox-close'); + const triggers = document.querySelectorAll('.lightbox-trigger'); + + function openModal(src, type) { + if (type === 'photo') { + img.src = src; + img.classList.remove('hidden'); + video.classList.add('hidden'); + video.pause(); + } else { + video.src = src; + video.classList.remove('hidden'); + img.classList.add('hidden'); + video.play(); // Auto-play video + } + + modal.classList.remove('hidden'); + // Small timeout to allow transition + setTimeout(() => { + modal.classList.remove('opacity-0', 'pointer-events-none'); + }, 10); + } + + function closeModal() { + modal.classList.add('opacity-0', 'pointer-events-none'); + setTimeout(() => { + modal.classList.add('hidden'); + video.pause(); + video.src = ""; // Stop buffering + img.src = ""; + }, 300); + } + + triggers.forEach(trigger => { + trigger.addEventListener('click', (e) => { + e.preventDefault(); // Prevent default link behavior if any + const src = trigger.dataset.src; + const type = trigger.dataset.type; + if (src && type) { + openModal(src, type); + } + }); + }); + + closeBtn.addEventListener('click', closeModal); + + // Close on background click + modal.addEventListener('click', (e) => { + if (e.target === modal) { + closeModal(); + } + }); +}); \ No newline at end of file diff --git a/config/packages/vich_uploader.yaml b/config/packages/vich_uploader.yaml index b4515d0..ba51253 100644 --- a/config/packages/vich_uploader.yaml +++ b/config/packages/vich_uploader.yaml @@ -69,3 +69,31 @@ vich_uploader: uri_prefix: /pdf/facture upload_destination: '%kernel.project_dir%/public/pdf/facture' namer: Vich\UploaderBundle\Naming\SmartUniqueNamer + etat_lieux_media: + uri_prefix: /media/etat_lieux/media + upload_destination: '%kernel.project_dir%/public/media/etat_lieux/media' + namer: Vich\UploaderBundle\Naming\SmartUniqueNamer + etat_lieux_unsign: + uri_prefix: /media/etat_lieux/unsign + upload_destination: '%kernel.project_dir%/public/media/etat_lieux/unsign' + namer: Vich\UploaderBundle\Naming\SmartUniqueNamer + etat_lieux_sign: + uri_prefix: /media/etat_lieux/sign + upload_destination: '%kernel.project_dir%/public/media/etat_lieux/sign' + namer: Vich\UploaderBundle\Naming\SmartUniqueNamer + etat_lieux_audit: + uri_prefix: /media/etat_lieux/audit + upload_destination: '%kernel.project_dir%/public/media/etat_lieux/audit' + namer: Vich\UploaderBundle\Naming\SmartUniqueNamer + etat_lieux_unsign_return: + uri_prefix: /media/etat_lieux/unsign_return + upload_destination: '%kernel.project_dir%/public/media/etat_lieux/unsign_return' + namer: Vich\UploaderBundle\Naming\SmartUniqueNamer + etat_lieux_sign_return: + uri_prefix: /media/etat_lieux/sign_return + upload_destination: '%kernel.project_dir%/public/media/etat_lieux/sign_return' + namer: Vich\UploaderBundle\Naming\SmartUniqueNamer + etat_lieux_audit_return: + uri_prefix: /media/etat_lieux/audit_return + upload_destination: '%kernel.project_dir%/public/media/etat_lieux/audit_return' + namer: Vich\UploaderBundle\Naming\SmartUniqueNamer diff --git a/migrations/Version20260206220000.php b/migrations/Version20260206220000.php new file mode 100644 index 0000000..ffeaa21 --- /dev/null +++ b/migrations/Version20260206220000.php @@ -0,0 +1,35 @@ +addSql("CREATE TABLE etat_lieux_file (id SERIAL NOT NULL, etat_lieux_id INT NOT NULL, file_name VARCHAR(255) DEFAULT NULL, file_size INT DEFAULT NULL, mime_type VARCHAR(255) DEFAULT NULL, type VARCHAR(50) NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))"); + $this->addSql("COMMENT ON COLUMN etat_lieux_file.created_at IS '(DC2Type:datetime_immutable)'"); + $this->addSql("CREATE INDEX IDX_5F7E1C87D6F39243 ON etat_lieux_file (etat_lieux_id)"); + $this->addSql('ALTER TABLE etat_lieux_file ADD CONSTRAINT FK_5F7E1C87D6F39243 FOREIGN KEY (etat_lieux_id) REFERENCES etat_lieux (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('ALTER TABLE etat_lieux_file DROP FOREIGN KEY FK_5F7E1C87D6F39243'); + $this->addSql('DROP TABLE etat_lieux_file'); + } +} diff --git a/migrations/Version20260206230000.php b/migrations/Version20260206230000.php new file mode 100644 index 0000000..f12a788 --- /dev/null +++ b/migrations/Version20260206230000.php @@ -0,0 +1,56 @@ +addSql("CREATE TABLE etat_lieux_comment (id SERIAL NOT NULL, etat_lieux_id INT NOT NULL, content TEXT NOT NULL, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))"); + $this->addSql("CREATE INDEX IDX_ETAT_LIEUX_COMMENT ON etat_lieux_comment (etat_lieux_id)"); + $this->addSql("COMMENT ON COLUMN etat_lieux_comment.created_at IS '(DC2Type:datetime_immutable)'"); + $this->addSql('ALTER TABLE etat_lieux_comment ADD CONSTRAINT FK_ETAT_LIEUX_COMMENT FOREIGN KEY (etat_lieux_id) REFERENCES etat_lieux (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + + $this->addSql('ALTER TABLE etat_lieux ADD sign_id_delivery VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE etat_lieux ADD sign_id_customer VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE etat_lieux ADD etat_lieux_unsign_file_name VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE etat_lieux ADD etat_lieux_unsign_file_size INT DEFAULT NULL'); + $this->addSql('ALTER TABLE etat_lieux ADD etat_lieux_sign_file_name VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE etat_lieux ADD etat_lieux_sign_file_size INT DEFAULT NULL'); + $this->addSql('ALTER TABLE etat_lieux ADD etat_lieux_audit_file_name VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE etat_lieux ADD etat_lieux_audit_file_size INT DEFAULT NULL'); + $this->addSql('ALTER TABLE etat_lieux ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL'); + $this->addSql("COMMENT ON COLUMN etat_lieux.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('ALTER TABLE etat_lieux_comment DROP FOREIGN KEY FK_ETAT_LIEUX_COMMENT'); + $this->addSql('DROP TABLE etat_lieux_comment'); + + $this->addSql('ALTER TABLE etat_lieux DROP sign_id_delivery'); + $this->addSql('ALTER TABLE etat_lieux DROP sign_id_customer'); + $this->addSql('ALTER TABLE etat_lieux DROP etat_lieux_unsign_file_name'); + $this->addSql('ALTER TABLE etat_lieux DROP etat_lieux_unsign_file_size'); + $this->addSql('ALTER TABLE etat_lieux DROP etat_lieux_sign_file_name'); + $this->addSql('ALTER TABLE etat_lieux DROP etat_lieux_sign_file_size'); + $this->addSql('ALTER TABLE etat_lieux DROP etat_lieux_audit_file_name'); + $this->addSql('ALTER TABLE etat_lieux DROP etat_lieux_audit_file_size'); + $this->addSql('ALTER TABLE etat_lieux DROP updated_at'); + } +} diff --git a/migrations/Version20260206233000.php b/migrations/Version20260206233000.php new file mode 100644 index 0000000..0844aef --- /dev/null +++ b/migrations/Version20260206233000.php @@ -0,0 +1,45 @@ +addSql('ALTER TABLE etat_lieux ADD sign_id_return VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE etat_lieux ADD sign_id_customer_return VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE etat_lieux ADD etat_lieux_unsign_return_file_name VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE etat_lieux ADD etat_lieux_unsign_return_file_size INT DEFAULT NULL'); + $this->addSql('ALTER TABLE etat_lieux ADD etat_lieux_sign_return_file_name VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE etat_lieux ADD etat_lieux_sign_return_file_size INT DEFAULT NULL'); + $this->addSql('ALTER TABLE etat_lieux ADD etat_lieux_audit_return_file_name VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE etat_lieux ADD etat_lieux_audit_return_file_size INT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE etat_lieux DROP sign_id_return'); + $this->addSql('ALTER TABLE etat_lieux DROP sign_id_customer_return'); + $this->addSql('ALTER TABLE etat_lieux DROP etat_lieux_unsign_return_file_name'); + $this->addSql('ALTER TABLE etat_lieux DROP etat_lieux_unsign_return_file_size'); + $this->addSql('ALTER TABLE etat_lieux DROP etat_lieux_sign_return_file_name'); + $this->addSql('ALTER TABLE etat_lieux DROP etat_lieux_sign_return_file_size'); + $this->addSql('ALTER TABLE etat_lieux DROP etat_lieux_audit_return_file_name'); + $this->addSql('ALTER TABLE etat_lieux DROP etat_lieux_audit_return_file_size'); + } +} diff --git a/public/media/etat_lieux/media/2025-12-30-17-30-12-341-6985dd3cc1213766266067.jpg b/public/media/etat_lieux/media/2025-12-30-17-30-12-341-6985dd3cc1213766266067.jpg new file mode 100644 index 0000000..1425a93 Binary files /dev/null and b/public/media/etat_lieux/media/2025-12-30-17-30-12-341-6985dd3cc1213766266067.jpg differ diff --git a/public/media/etat_lieux/media/audiomass-output-6985d6d370f65831595404.mp4 b/public/media/etat_lieux/media/audiomass-output-6985d6d370f65831595404.mp4 new file mode 100644 index 0000000..d303910 Binary files /dev/null and b/public/media/etat_lieux/media/audiomass-output-6985d6d370f65831595404.mp4 differ diff --git a/public/media/etat_lieux/media/messenger-creation-ae4e4f3a-e0a1-4a86-9cfe-47c5da885ac9-6985d658272fa249814391.jpg b/public/media/etat_lieux/media/messenger-creation-ae4e4f3a-e0a1-4a86-9cfe-47c5da885ac9-6985d658272fa249814391.jpg new file mode 100644 index 0000000..21a4563 Binary files /dev/null and b/public/media/etat_lieux/media/messenger-creation-ae4e4f3a-e0a1-4a86-9cfe-47c5da885ac9-6985d658272fa249814391.jpg differ diff --git a/src/Controller/EtlController.php b/src/Controller/EtlController.php index 9582784..4d489f6 100644 --- a/src/Controller/EtlController.php +++ b/src/Controller/EtlController.php @@ -6,17 +6,22 @@ use App\Entity\Account; use App\Entity\Contrats; use App\Entity\ContratsPayments; use App\Entity\EtatLieux; +use App\Entity\EtatLieuxComment; +use App\Entity\EtatLieuxFile; use App\Entity\Prestaire; use App\Form\PrestairePasswordType; use App\Repository\ContratsRepository; use App\Service\Mailer\Mailer; +use App\Service\Stripe\Client as StripeClient; use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; use KnpU\OAuth2ClientBundle\Client\ClientRegistry; @@ -97,8 +102,42 @@ class EtlController extends AbstractController throw $this->createAccessDeniedException('Vous n\'avez pas accès à cette mission.'); } + // Calculate Totals + $totalHt = 0; + $totalCaution = 0; + $days = ($contrat->getDateAt() && $contrat->getEndAt()) ? ($contrat->getDateAt()->diff($contrat->getEndAt())->days + 1) : 1; + + foreach ($contrat->getContratsLines() as $line) { + $totalHt += $line->getPrice1DayHt() + ($line->getPriceSupDayHt() * max(0, $days - 1)); + $totalCaution += $line->getCaution(); + } + + foreach ($contrat->getContratsOptions() as $option) { + $totalHt += $option->getPrice(); + } + + $dejaPaye = 0; + foreach ($contrat->getContratsPayments() as $p) { + if ($p->getState() === 'complete' && $p->getType() !== 'caution') { + $dejaPaye += $p->getAmount(); + } + } + $solde = $totalHt - $dejaPaye; + + // Check if Caution is already paid + $cautionPaid = false; + foreach ($contrat->getContratsPayments() as $p) { + if ($p->getType() === 'caution' && $p->getState() === 'complete') { + $cautionPaid = true; + break; + } + } + return $this->render('etl/view.twig', [ - 'mission' => $contrat + 'mission' => $contrat, + 'totalCaution' => $totalCaution, + 'solde' => $solde, + 'cautionPaid' => $cautionPaid ]); } @@ -144,6 +183,376 @@ class EtlController extends AbstractController return $this->redirectToRoute('etl_contrat_view', ['id' => $contrat->getId()]); } + #[Route('/etl/mission/{id}/finish', name: 'etl_mission_finish', methods: ['POST'])] + public function eltMissionFinish(Contrats $contrat, EntityManagerInterface $em): Response + { + $user = $this->getUser(); + if (!$user) { + return $this->redirectToRoute('etl_login'); + } + + if ($user instanceof Prestaire && $contrat->getPrestataire() !== $user) { + throw $this->createAccessDeniedException('Vous n\'avez pas accès à cette mission.'); + } + + $etatLieux = $contrat->getEtatLieux(); + if ($etatLieux) { + $etatLieux->setStatus('delivery_done'); + $em->flush(); + $this->addFlash('success', 'Livraison terminée et confirmée.'); + } + + return $this->redirectToRoute('etl_contrat_view', ['id' => $contrat->getId()]); + } + + #[Route('/etl/mission/{id}/caution', name: 'etl_mission_caution', methods: ['POST'])] + public function eltMissionCaution(Contrats $contrat, EntityManagerInterface $em): Response + { + $user = $this->getUser(); + if (!$user) { + return $this->redirectToRoute('etl_login'); + } + + // Check already paid + foreach ($contrat->getContratsPayments() as $p) { + if ($p->getType() === 'caution' && $p->getState() === 'complete') { + $this->addFlash('warning', 'Caution déjà validée.'); + return $this->redirectToRoute('etl_contrat_view', ['id' => $contrat->getId()]); + } + } + + // Calculate Caution Amount + $totalCaution = 0; + $days = ($contrat->getDateAt() && $contrat->getEndAt()) ? ($contrat->getDateAt()->diff($contrat->getEndAt())->days + 1) : 1; + foreach ($contrat->getContratsLines() as $line) { + $totalCaution += $line->getCaution(); + } + + $payment = new ContratsPayments(); + $payment->setContrat($contrat) + ->setType('caution') + ->setAmount($totalCaution) + ->setState('complete') + ->setPaymentAt(new \DateTimeImmutable()) + ->setValidateAt(new \DateTimeImmutable()) + ->setCard(['type' => 'manuel', 'method' => 'Prestataire']) + ->setPaymentId("CAUTION-" . uniqid()); + + $em->persist($payment); + $em->flush(); + + $this->addFlash('success', 'Caution validée.'); + return $this->redirectToRoute('etl_contrat_view', ['id' => $contrat->getId()]); + } + + #[Route('/etl/mission/{id}/confirme', name: 'elt_mission_confirme', methods: ['POST'])] + public function eltMissionConfirme(Contrats $contrat, StripeClient $stripeClient, EntityManagerInterface $em): Response + { + $user = $this->getUser(); + if (!$user) { + return $this->redirectToRoute('etl_login'); + } + + // Calculate Solde + $totalHt = 0; + $days = ($contrat->getDateAt() && $contrat->getEndAt()) ? ($contrat->getDateAt()->diff($contrat->getEndAt())->days + 1) : 1; + foreach ($contrat->getContratsLines() as $line) { + $totalHt += $line->getPrice1DayHt() + ($line->getPriceSupDayHt() * max(0, $days - 1)); + } + foreach ($contrat->getContratsOptions() as $option) { + $totalHt += $option->getPrice(); + } + $dejaPaye = 0; + foreach ($contrat->getContratsPayments() as $p) { + if ($p->getState() === 'complete' && $p->getType() !== 'caution') { + $dejaPaye += $p->getAmount(); + } + } + $solde = $totalHt - $dejaPaye; + + if ($solde <= 0) { + $this->addFlash('info', 'Le solde est déjà réglé.'); + return $this->redirectToRoute('etl_contrat_view', ['id' => $contrat->getId()]); + } + + // Create Payment Intent/Session using StripeClient (Using createPaymentSolde logic but manual or new method) + // Since createPaymentSolde redirects to specific success URL, I might need to adjust or create a generic one. + // Assuming createPaymentSolde can be reused but we might want custom success URL? + // Actually, createPaymentSolde sets success_url to /contrat/payment/success/... which is the customer success page. + // That might be fine, or we want redirection back to ETL? + // If we want redirection back to ETL, we need a new method in StripeClient or pass options. + // For now, I will use getNativeClient to build session manually to control success_url. + + try { + $stripe = $stripeClient->getNativeClient(); + $customer = $contrat->getCustomer(); + + // Ensure Stripe Customer exists (Controller helper or reuse existing logic if possible) + if (!$customer->getCustomerId()) { + $stripeClient->createCustomer($customer); + $em->flush(); + } + + $session = $stripe->checkout->sessions->create([ + 'customer' => $customer->getCustomerId(), + 'line_items' => [[ + 'price_data' => [ + 'currency' => 'eur', + 'product_data' => [ + 'name' => 'Solde Réservation #' . $contrat->getNumReservation(), + 'description' => 'Règlement du solde via interface prestataire', + ], + 'unit_amount' => (int)round($solde * 100), + ], + 'quantity' => 1, + ]], + 'mode' => 'payment', + 'success_url' => $this->generateUrl('etl_contrat_view', ['id' => $contrat->getId()], UrlGeneratorInterface::ABSOLUTE_URL), // Back to ETL view + 'cancel_url' => $this->generateUrl('etl_contrat_view', ['id' => $contrat->getId()], UrlGeneratorInterface::ABSOLUTE_URL), + 'metadata' => [ + 'contrat_id' => $contrat->getId(), + 'type' => 'etl_payment' // Special type to trigger specific webhook logic + ] + ]); + + // Persist pending payment + $payment = new ContratsPayments(); + $payment->setContrat($contrat) + ->setType('etl_payment') // Or 'solde' if we want it standard + ->setAmount($solde) + ->setState('created') + ->setPaymentAt(new \DateTimeImmutable()) + ->setPaymentId($session->id); + + $em->persist($payment); + $em->flush(); + + return new RedirectResponse($session->url); + + } catch (\Exception $e) { + $this->addFlash('error', 'Erreur Stripe: ' . $e->getMessage()); + return $this->redirectToRoute('etl_contrat_view', ['id' => $contrat->getId()]); + } + } + + #[Route('/etl/mission/{id}/manual-pay', name: 'etl_mission_manual_pay', methods: ['POST'])] + public function eltMissionManualPayment(Contrats $contrat, Request $request, EntityManagerInterface $em): Response + { + $user = $this->getUser(); + if (!$user) { + return $this->redirectToRoute('etl_login'); + } + + $method = $request->request->get('method'); + $amount = (float) $request->request->get('amount'); // Optional, or calculate solde + + if ($amount <= 0) { + // Calculate Solde if not provided + $totalHt = 0; + $days = ($contrat->getDateAt() && $contrat->getEndAt()) ? ($contrat->getDateAt()->diff($contrat->getEndAt())->days + 1) : 1; + foreach ($contrat->getContratsLines() as $line) { + $totalHt += $line->getPrice1DayHt() + ($line->getPriceSupDayHt() * max(0, $days - 1)); + } + foreach ($contrat->getContratsOptions() as $option) { + $totalHt += $option->getPrice(); + } + $dejaPaye = 0; + foreach ($contrat->getContratsPayments() as $p) { + if ($p->getState() === 'complete' && $p->getType() !== 'caution') { + $dejaPaye += $p->getAmount(); + } + } + $amount = $totalHt - $dejaPaye; + } + + if ($amount > 0) { + $payment = new ContratsPayments(); + $payment->setContrat($contrat) + ->setType('etl_payment') + ->setAmount($amount) + ->setState('complete') + ->setPaymentAt(new \DateTimeImmutable()) + ->setValidateAt(new \DateTimeImmutable()) + ->setCard(['type' => 'manuel', 'method' => $method]) + ->setPaymentId("MANUAL-" . uniqid()); + + $em->persist($payment); + $em->flush(); + $this->addFlash('success', 'Paiement manuel enregistré.'); + } + + return $this->redirectToRoute('etl_contrat_view', ['id' => $contrat->getId()]); + } + + #[Route('/etl/mission/{id}/edl/start', name: 'etl_mission_edl_start', methods: ['POST'])] + public function eltMissionEdlStart(Contrats $contrat, EntityManagerInterface $em): Response + { + $user = $this->getUser(); + if (!$user) { + return $this->redirectToRoute('etl_login'); + } + + $etatLieux = $contrat->getEtatLieux(); + if ($etatLieux) { + $etatLieux->setStatus('edl_progress'); + $em->flush(); + $this->addFlash('success', 'État des lieux commencé.'); + } + + return $this->redirectToRoute('etl_mission_edl', ['id' => $contrat->getId()]); + } + + #[Route('/etl/mission/{id}/edl', name: 'etl_mission_edl', methods: ['GET'])] + public function eltEdl(Contrats $contrat): Response + { + $user = $this->getUser(); + if (!$user) { + return $this->redirectToRoute('etl_login'); + } + + // Security check + if ($user instanceof Prestaire && $contrat->getPrestataire() !== $user) { + throw $this->createAccessDeniedException('Vous n\'avez pas accès à cette mission.'); + } + + return $this->render('etl/edl.twig', [ + 'mission' => $contrat, + 'etatLieux' => $contrat->getEtatLieux() + ]); + } + + #[Route('/etl/mission/{id}/edl/comment', name: 'etl_edl_add_comment', methods: ['POST'])] + public function eltEdlAddComment(Contrats $contrat, Request $request, EntityManagerInterface $em): Response + { + $user = $this->getUser(); + if (!$user) { + return $this->redirectToRoute('etl_login'); + } + + $content = $request->request->get('content'); + if ($content) { + $etatLieux = $contrat->getEtatLieux(); + $comment = new EtatLieuxComment(); + $comment->setContent($content); + $comment->setEtatLieux($etatLieux); + $em->persist($comment); + $em->flush(); + $this->addFlash('success', 'Commentaire ajouté.'); + } + + return $this->redirectToRoute('etl_mission_edl', ['id' => $contrat->getId()]); + } + + #[Route('/etl/mission/{id}/edl/file', name: 'etl_edl_add_file', methods: ['POST'])] + public function eltEdlAddFile(Contrats $contrat, Request $request, EntityManagerInterface $em): Response + { + $user = $this->getUser(); + if (!$user) { + return $this->redirectToRoute('etl_login'); + } + + $photos = $request->files->get('photos'); + $videos = $request->files->get('videos'); + $etatLieux = $contrat->getEtatLieux(); + $hasFiles = false; + + if ($photos) { + if (!is_array($photos)) $photos = [$photos]; + foreach ($photos as $uploadedFile) { + if ($uploadedFile instanceof UploadedFile) { + $this->compressImage($uploadedFile); + $file = new EtatLieuxFile(); + $file->setFile($uploadedFile); + $file->setType('photo'); + $file->setEtatLieux($etatLieux); + $em->persist($file); + $hasFiles = true; + } + } + } + if ($videos) { + if (!is_array($videos)) $videos = [$videos]; + foreach ($videos as $uploadedFile) { + if ($uploadedFile instanceof UploadedFile) { + $this->compressVideo($uploadedFile); + $file = new EtatLieuxFile(); + $file->setFile($uploadedFile); + $file->setType('video'); + $file->setEtatLieux($etatLieux); + $em->persist($file); + $hasFiles = true; + } + } + } + if ($hasFiles) { + $em->flush(); + $this->addFlash('success', 'Fichiers ajoutés.'); + } + + return $this->redirectToRoute('etl_mission_edl', ['id' => $contrat->getId()]); + + } + + + + #[Route('/etl/mission/{id}/edl/file/{fileId}/delete', name: 'etl_edl_delete_file', methods: ['POST'])] + + public function eltEdlDeleteFile(Contrats $contrat, int $fileId, EntityManagerInterface $em): Response + + { + + $user = $this->getUser(); + + if (!$user) { + + return $this->redirectToRoute('etl_login'); + + } + + + + $etatLieux = $contrat->getEtatLieux(); + + $file = $em->getRepository(EtatLieuxFile::class)->find($fileId); + + + + if ($file && $file->getEtatLieux() === $etatLieux) { + + $em->remove($file); + + $em->flush(); + + $this->addFlash('success', 'Fichier supprimé.'); + + } + + + + return $this->redirectToRoute('etl_mission_edl', ['id' => $contrat->getId()]); + + } + + + + #[Route('/etl/mission/{id}/edl/finish', name: 'etl_edl_finish', methods: ['POST'])] + public function eltEdlFinish(Contrats $contrat, EntityManagerInterface $em): Response + { + $user = $this->getUser(); + if (!$user) { + return $this->redirectToRoute('etl_login'); + } + + $etatLieux = $contrat->getEtatLieux(); + // Here we could update status to 'edl_done' or similar if needed. + // For now, let's assume it stays in 'edl_progress' or we have another step. + // The prompt says "button terminer l'etat des lieux". + // Maybe redirect to view page? + + $this->addFlash('success', 'État des lieux terminé.'); + return $this->redirectToRoute('etl_contrat_view', ['id' => $contrat->getId()]); + } + #[Route('/etl/account', name: 'etl_account', methods: ['GET', 'POST'])] public function eltAccount( Request $request, @@ -214,4 +623,69 @@ class EtlController extends AbstractController { return new Response('Auth check', 200); // Intercepted by authenticator } + + private function compressImage(UploadedFile $file): UploadedFile + { + if (!extension_loaded('gd')) { + return $file; + } + + $mime = $file->getMimeType(); + $path = $file->getPathname(); + + // Simple compression logic + switch ($mime) { + case 'image/jpeg': + $image = @imagecreatefromjpeg($path); + if ($image) { + imagejpeg($image, $path, 75); // Quality 75 + imagedestroy($image); + } + break; + case 'image/png': + $image = @imagecreatefrompng($path); + if ($image) { + // PNG compression 0-9 + imagepng($image, $path, 6); + imagedestroy($image); + } + break; + } + + return $file; + } + + private function compressVideo(UploadedFile $file): UploadedFile + { + // Check if ffmpeg is available + $ffmpegPath = shell_exec('which ffmpeg'); + if (empty($ffmpegPath)) { + return $file; + } + + $inputPath = $file->getPathname(); + $outputPath = $inputPath . '_compressed.mp4'; + + // Compress video using ffmpeg (CRF 28 for reasonable quality/size trade-off) + // -y to overwrite, -vcodec libx264, -crf 28, -preset fast + $command = sprintf( + 'ffmpeg -y -i %s -vcodec libx264 -crf 28 -preset fast %s 2>&1', + escapeshellarg($inputPath), + escapeshellarg($outputPath) + ); + + exec($command, $output, $returnVar); + + if ($returnVar === 0 && file_exists($outputPath)) { + // Replace original file with compressed one + if (rename($outputPath, $inputPath)) { + // Success + } else { + // Fallback cleanup + @unlink($outputPath); + } + } + + return $file; + } } diff --git a/src/Controller/Webhooks.php b/src/Controller/Webhooks.php index 35eafd7..262e0a8 100644 --- a/src/Controller/Webhooks.php +++ b/src/Controller/Webhooks.php @@ -88,6 +88,7 @@ class Webhooks extends AbstractController 'caution' => "[Ludikevent] Confirmation de votre caution - #" . $contrat->getNumReservation(), 'solde_partiel' => "[Ludikevent] Confirmation de votre versement partiel - #" . $contrat->getNumReservation(), 'solde' => "[Ludikevent] Votre réservation est désormais soldée ! - #" . $contrat->getNumReservation(), + 'etl_payment' => "[Ludikevent] Confirmation de paiement (via Prestataire) - #" . $contrat->getNumReservation(), default => "[Ludikevent] Confirmation de paiement - #" . $contrat->getNumReservation(), }; @@ -111,6 +112,7 @@ class Webhooks extends AbstractController 'caution' => "🛡️ CAUTION DÉPOSÉE : " . $customer->getSurname() . " (#" . $contrat->getNumReservation() . ")", 'solde_partiel' => "💰 PAIEMENT PARTIEL : " . $customer->getSurname() . " (#" . $contrat->getNumReservation() . ")", 'solde' => "✅ DOSSIER SOLDÉ : " . $customer->getSurname() . " (#" . $contrat->getNumReservation() . ")", + 'etl_payment' => "📱 PAIEMENT MOBILE (ETL) : " . $customer->getSurname() . " (#" . $contrat->getNumReservation() . ")", default => "💳 Nouveau paiement reçu - #" . $contrat->getNumReservation(), }; diff --git a/src/Entity/EtatLieux.php b/src/Entity/EtatLieux.php index 93f9de4..747ef2f 100644 --- a/src/Entity/EtatLieux.php +++ b/src/Entity/EtatLieux.php @@ -3,9 +3,15 @@ namespace App\Entity; use App\Repository\EtatLieuxRepository; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; 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: EtatLieuxRepository::class)] +#[Uploadable] class EtatLieux { #[ORM\Id] @@ -25,11 +31,132 @@ class EtatLieux #[ORM\Column(length: 50, options: ['default' => 'delivery_ready'])] private string $status = 'delivery_ready'; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: EtatLieuxFile::class, mappedBy: 'etatLieux', cascade: ['persist', 'remove'])] + private Collection $files; + + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: EtatLieuxComment::class, mappedBy: 'etatLieux', cascade: ['persist', 'remove'])] + private Collection $comments; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $signIdDelivery = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $signIdCustomer = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $signIdReturn = null; + + #[ORM\Column(length: 255, nullable: true)] + private ?string $signIdCustomerReturn = null; + + // --- FILES DELIVERY --- + + #[UploadableField(mapping: 'etat_lieux_unsign', fileNameProperty: 'etatLieuxUnsignFileName', size: 'etatLieuxUnsignFileSize')] + private ?File $etatLieuxUnsignFile = null; + + #[ORM\Column(nullable: true)] + private ?string $etatLieuxUnsignFileName = null; + + #[ORM\Column(nullable: true)] + private ?int $etatLieuxUnsignFileSize = null; + + #[UploadableField(mapping: 'etat_lieux_sign', fileNameProperty: 'etatLieuxSignFileName', size: 'etatLieuxSignFileSize')] + private ?File $etatLieuxSignFile = null; + + #[ORM\Column(nullable: true)] + private ?string $etatLieuxSignFileName = null; + + #[ORM\Column(nullable: true)] + private ?int $etatLieuxSignFileSize = null; + + #[UploadableField(mapping: 'etat_lieux_audit', fileNameProperty: 'etatLieuxAuditFileName', size: 'etatLieuxAuditFileSize')] + private ?File $etatLieuxAuditFile = null; + + #[ORM\Column(nullable: true)] + private ?string $etatLieuxAuditFileName = null; + + #[ORM\Column(nullable: true)] + private ?int $etatLieuxAuditFileSize = null; + + // --- FILES RETURN --- + + #[UploadableField(mapping: 'etat_lieux_unsign_return', fileNameProperty: 'etatLieuxUnsignReturnFileName', size: 'etatLieuxUnsignReturnFileSize')] + private ?File $etatLieuxUnsignReturnFile = null; + + #[ORM\Column(nullable: true)] + private ?string $etatLieuxUnsignReturnFileName = null; + + #[ORM\Column(nullable: true)] + private ?int $etatLieuxUnsignReturnFileSize = null; + + #[UploadableField(mapping: 'etat_lieux_sign_return', fileNameProperty: 'etatLieuxSignReturnFileName', size: 'etatLieuxSignReturnFileSize')] + private ?File $etatLieuxSignReturnFile = null; + + #[ORM\Column(nullable: true)] + private ?string $etatLieuxSignReturnFileName = null; + + #[ORM\Column(nullable: true)] + private ?int $etatLieuxSignReturnFileSize = null; + + #[UploadableField(mapping: 'etat_lieux_audit_return', fileNameProperty: 'etatLieuxAuditReturnFileName', size: 'etatLieuxAuditReturnFileSize')] + private ?File $etatLieuxAuditReturnFile = null; + + #[ORM\Column(nullable: true)] + private ?string $etatLieuxAuditReturnFileName = null; + + #[ORM\Column(nullable: true)] + private ?int $etatLieuxAuditReturnFileSize = null; + + #[ORM\Column(nullable: true)] + private ?\DateTimeImmutable $updatedAt = null; + + public function __construct() + { + $this->files = new ArrayCollection(); + $this->comments = new ArrayCollection(); + } + public function getId(): ?int { return $this->id; } + /** + * @return Collection + */ + public function getFiles(): Collection + { + return $this->files; + } + + public function addFile(EtatLieuxFile $file): static + { + if (!$this->files->contains($file)) { + $this->files->add($file); + $file->setEtatLieux($this); + } + + return $this; + } + + public function removeFile(EtatLieuxFile $file): static + { + if ($this->files->removeElement($file)) { + // set the owning side to null (unless already changed) + if ($file->getEtatLieux() === $this) { + $file->setEtatLieux(null); + } + } + + return $this; + } + public function getStatus(): string { return $this->status; @@ -77,4 +204,291 @@ class EtatLieux return $this; } + + /** + * @return Collection + */ + public function getComments(): Collection + { + return $this->comments; + } + + public function addComment(EtatLieuxComment $comment): static + { + if (!$this->comments->contains($comment)) { + $this->comments->add($comment); + $comment->setEtatLieux($this); + } + + return $this; + } + + public function removeComment(EtatLieuxComment $comment): static + { + if ($this->comments->removeElement($comment)) { + if ($comment->getEtatLieux() === $this) { + $comment->setEtatLieux(null); + } + } + + return $this; + } + + public function getSignIdDelivery(): ?string + { + return $this->signIdDelivery; + } + + public function setSignIdDelivery(?string $signIdDelivery): static + { + $this->signIdDelivery = $signIdDelivery; + + return $this; + } + + public function getSignIdCustomer(): ?string + { + return $this->signIdCustomer; + } + + public function setSignIdCustomer(?string $signIdCustomer): static + { + $this->signIdCustomer = $signIdCustomer; + + return $this; + } + + public function getEtatLieuxUnsignFile(): ?File + { + return $this->etatLieuxUnsignFile; + } + + public function setEtatLieuxUnsignFile(?File $etatLieuxUnsignFile): void + { + $this->etatLieuxUnsignFile = $etatLieuxUnsignFile; + if (null !== $etatLieuxUnsignFile) { + $this->updatedAt = new \DateTimeImmutable(); + } + } + + public function getEtatLieuxUnsignFileName(): ?string + { + return $this->etatLieuxUnsignFileName; + } + + public function setEtatLieuxUnsignFileName(?string $etatLieuxUnsignFileName): void + { + $this->etatLieuxUnsignFileName = $etatLieuxUnsignFileName; + } + + public function getEtatLieuxUnsignFileSize(): ?int + { + return $this->etatLieuxUnsignFileSize; + } + + public function setEtatLieuxUnsignFileSize(?int $etatLieuxUnsignFileSize): void + { + $this->etatLieuxUnsignFileSize = $etatLieuxUnsignFileSize; + } + + public function getEtatLieuxSignFile(): ?File + { + return $this->etatLieuxSignFile; + } + + public function setEtatLieuxSignFile(?File $etatLieuxSignFile): void + { + $this->etatLieuxSignFile = $etatLieuxSignFile; + if (null !== $etatLieuxSignFile) { + $this->updatedAt = new \DateTimeImmutable(); + } + } + + public function getEtatLieuxSignFileName(): ?string + { + return $this->etatLieuxSignFileName; + } + + public function setEtatLieuxSignFileName(?string $etatLieuxSignFileName): void + { + $this->etatLieuxSignFileName = $etatLieuxSignFileName; + } + + public function getEtatLieuxSignFileSize(): ?int + { + return $this->etatLieuxSignFileSize; + } + + public function setEtatLieuxSignFileSize(?int $etatLieuxSignFileSize): void + { + $this->etatLieuxSignFileSize = $etatLieuxSignFileSize; + } + + public function getEtatLieuxAuditFile(): ?File + { + return $this->etatLieuxAuditFile; + } + + public function setEtatLieuxAuditFile(?File $etatLieuxAuditFile): void + { + $this->etatLieuxAuditFile = $etatLieuxAuditFile; + if (null !== $etatLieuxAuditFile) { + $this->updatedAt = new \DateTimeImmutable(); + } + } + + public function getEtatLieuxAuditFileName(): ?string + { + return $this->etatLieuxAuditFileName; + } + + public function setEtatLieuxAuditFileName(?string $etatLieuxAuditFileName): void + { + $this->etatLieuxAuditFileName = $etatLieuxAuditFileName; + } + + public function getEtatLieuxAuditFileSize(): ?int + { + return $this->etatLieuxAuditFileSize; + } + + public function setEtatLieuxAuditFileSize(?int $etatLieuxAuditFileSize): void + { + $this->etatLieuxAuditFileSize = $etatLieuxAuditFileSize; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } + + public function setUpdatedAt(?\DateTimeImmutable $updatedAt): static + { + $this->updatedAt = $updatedAt; + + return $this; + } + + public function getSignIdReturn(): ?string + { + return $this->signIdReturn; + } + + public function setSignIdReturn(?string $signIdReturn): static + { + $this->signIdReturn = $signIdReturn; + + return $this; + } + + public function getSignIdCustomerReturn(): ?string + { + return $this->signIdCustomerReturn; + } + + public function setSignIdCustomerReturn(?string $signIdCustomerReturn): static + { + $this->signIdCustomerReturn = $signIdCustomerReturn; + + return $this; + } + + public function getEtatLieuxUnsignReturnFile(): ?File + { + return $this->etatLieuxUnsignReturnFile; + } + + public function setEtatLieuxUnsignReturnFile(?File $etatLieuxUnsignReturnFile): void + { + $this->etatLieuxUnsignReturnFile = $etatLieuxUnsignReturnFile; + if (null !== $etatLieuxUnsignReturnFile) { + $this->updatedAt = new \DateTimeImmutable(); + } + } + + public function getEtatLieuxUnsignReturnFileName(): ?string + { + return $this->etatLieuxUnsignReturnFileName; + } + + public function setEtatLieuxUnsignReturnFileName(?string $etatLieuxUnsignReturnFileName): void + { + $this->etatLieuxUnsignReturnFileName = $etatLieuxUnsignReturnFileName; + } + + public function getEtatLieuxUnsignReturnFileSize(): ?int + { + return $this->etatLieuxUnsignReturnFileSize; + } + + public function setEtatLieuxUnsignReturnFileSize(?int $etatLieuxUnsignReturnFileSize): void + { + $this->etatLieuxUnsignReturnFileSize = $etatLieuxUnsignReturnFileSize; + } + + public function getEtatLieuxSignReturnFile(): ?File + { + return $this->etatLieuxSignReturnFile; + } + + public function setEtatLieuxSignReturnFile(?File $etatLieuxSignReturnFile): void + { + $this->etatLieuxSignReturnFile = $etatLieuxSignReturnFile; + if (null !== $etatLieuxSignReturnFile) { + $this->updatedAt = new \DateTimeImmutable(); + } + } + + public function getEtatLieuxSignReturnFileName(): ?string + { + return $this->etatLieuxSignReturnFileName; + } + + public function setEtatLieuxSignReturnFileName(?string $etatLieuxSignReturnFileName): void + { + $this->etatLieuxSignReturnFileName = $etatLieuxSignReturnFileName; + } + + public function getEtatLieuxSignReturnFileSize(): ?int + { + return $this->etatLieuxSignReturnFileSize; + } + + public function setEtatLieuxSignReturnFileSize(?int $etatLieuxSignReturnFileSize): void + { + $this->etatLieuxSignReturnFileSize = $etatLieuxSignReturnFileSize; + } + + public function getEtatLieuxAuditReturnFile(): ?File + { + return $this->etatLieuxAuditReturnFile; + } + + public function setEtatLieuxAuditReturnFile(?File $etatLieuxAuditReturnFile): void + { + $this->etatLieuxAuditReturnFile = $etatLieuxAuditReturnFile; + if (null !== $etatLieuxAuditReturnFile) { + $this->updatedAt = new \DateTimeImmutable(); + } + } + + public function getEtatLieuxAuditReturnFileName(): ?string + { + return $this->etatLieuxAuditReturnFileName; + } + + public function setEtatLieuxAuditReturnFileName(?string $etatLieuxAuditReturnFileName): void + { + $this->etatLieuxAuditReturnFileName = $etatLieuxAuditReturnFileName; + } + + public function getEtatLieuxAuditReturnFileSize(): ?int + { + return $this->etatLieuxAuditReturnFileSize; + } + + public function setEtatLieuxAuditReturnFileSize(?int $etatLieuxAuditReturnFileSize): void + { + $this->etatLieuxAuditReturnFileSize = $etatLieuxAuditReturnFileSize; + } } diff --git a/src/Entity/EtatLieuxComment.php b/src/Entity/EtatLieuxComment.php new file mode 100644 index 0000000..c935734 --- /dev/null +++ b/src/Entity/EtatLieuxComment.php @@ -0,0 +1,72 @@ +createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(string $content): static + { + $this->content = $content; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getEtatLieux(): ?EtatLieux + { + return $this->etatLieux; + } + + public function setEtatLieux(?EtatLieux $etatLieux): static + { + $this->etatLieux = $etatLieux; + + return $this; + } +} diff --git a/src/Entity/EtatLieuxFile.php b/src/Entity/EtatLieuxFile.php new file mode 100644 index 0000000..16a08c9 --- /dev/null +++ b/src/Entity/EtatLieuxFile.php @@ -0,0 +1,130 @@ +createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getEtatLieux(): ?EtatLieux + { + return $this->etatLieux; + } + + public function setEtatLieux(?EtatLieux $etatLieux): static + { + $this->etatLieux = $etatLieux; + + return $this; + } + + public function getFile(): ?File + { + return $this->file; + } + + public function setFile(?File $file): void + { + $this->file = $file; + if (null !== $file) { + $this->createdAt = new \DateTimeImmutable(); + } + } + + public function getFileName(): ?string + { + return $this->fileName; + } + + public function setFileName(?string $fileName): void + { + $this->fileName = $fileName; + } + + public function getFileSize(): ?int + { + return $this->fileSize; + } + + public function setFileSize(?int $fileSize): void + { + $this->fileSize = $fileSize; + } + + public function getMimeType(): ?string + { + return $this->mimeType; + } + + public function setMimeType(?string $mimeType): void + { + $this->mimeType = $mimeType; + } + + public function getType(): ?string + { + return $this->type; + } + + public function setType(string $type): static + { + $this->type = $type; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } +} diff --git a/src/Repository/EtatLieuxCommentRepository.php b/src/Repository/EtatLieuxCommentRepository.php new file mode 100644 index 0000000..94bab66 --- /dev/null +++ b/src/Repository/EtatLieuxCommentRepository.php @@ -0,0 +1,23 @@ + + * + * @method EtatLieuxComment|null find($id, $lockMode = null, $lockVersion = null) + * @method EtatLieuxComment|null findOneBy(array $criteria, array $orderBy = null) + * @method EtatLieuxComment[] findAll() + * @method EtatLieuxComment[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class EtatLieuxCommentRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, EtatLieuxComment::class); + } +} diff --git a/src/Repository/EtatLieuxFileRepository.php b/src/Repository/EtatLieuxFileRepository.php new file mode 100644 index 0000000..aebf5d8 --- /dev/null +++ b/src/Repository/EtatLieuxFileRepository.php @@ -0,0 +1,23 @@ + + * + * @method EtatLieuxFile|null find($id, $lockMode = null, $lockVersion = null) + * @method EtatLieuxFile|null findOneBy(array $criteria, array $orderBy = null) + * @method EtatLieuxFile[] findAll() + * @method EtatLieuxFile[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + */ +class EtatLieuxFileRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, EtatLieuxFile::class); + } +} diff --git a/templates/etl/edl.twig b/templates/etl/edl.twig new file mode 100644 index 0000000..f9f679e --- /dev/null +++ b/templates/etl/edl.twig @@ -0,0 +1,111 @@ +{% extends 'etl/base.twig' %} + +{% block title %}État des Lieux - #{{ mission.numReservation }}{% endblock %} + +{% block body %} +
+ + {# HEADER #} +
+ + + +
+

État des Lieux

+

Réf: #{{ mission.numReservation }}

+
+
+ + {# PHOTOS / VIDEOS #} +
+

Photos & Vidéos

+ +
+ {% for file in etatLieux.files %} +
+ {% if file.type == 'photo' %} + Photo + {% else %} + + {% endif %} + +
+ +
+
+ {% else %} +
Aucun média ajouté.
+ {% endfor %} +
+ + {# LIGHTBOX MODAL #} + + +
+
+ + +
+ + +
+
+ + {# COMMENTAIRES #} +
+

Commentaires

+ +
+ {% for comment in etatLieux.comments %} +
+

{{ comment.createdAt|date('d/m H:i') }}

+

{{ comment.content }}

+
+ {% else %} +

Aucun commentaire.

+ {% endfor %} +
+ +
+ + +
+
+ + {# ACTION TERMINER #} +
+ +
+ +
+{% endblock %} diff --git a/templates/etl/view.twig b/templates/etl/view.twig index f84a840..ada64d7 100644 --- a/templates/etl/view.twig +++ b/templates/etl/view.twig @@ -4,7 +4,7 @@ {% block body %}