feat(etl/mission): Implémente la gestion des missions avec dashboard, liste et démarrage

This commit is contained in:
Serreau Jovann
2026-02-06 13:46:35 +01:00
parent d92642d1d7
commit 3494d627dc
17 changed files with 1564 additions and 11 deletions

View File

@@ -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();
}
});
});

View File

@@ -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

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260206220000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create etat_lieux_file table';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260206230000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create etat_lieux_comment table and add fields to etat_lieux';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260206233000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add return fields to etat_lieux';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

View File

@@ -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;
}
}

View File

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

View File

@@ -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<int, EtatLieuxFile>
*/
#[ORM\OneToMany(targetEntity: EtatLieuxFile::class, mappedBy: 'etatLieux', cascade: ['persist', 'remove'])]
private Collection $files;
/**
* @var Collection<int, EtatLieuxComment>
*/
#[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<int, EtatLieuxFile>
*/
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<int, EtatLieuxComment>
*/
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;
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Entity;
use App\Repository\EtatLieuxCommentRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: EtatLieuxCommentRepository::class)]
class EtatLieuxComment
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $content = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\ManyToOne(inversedBy: 'comments')]
#[ORM\JoinColumn(nullable: false)]
private ?EtatLieux $etatLieux = null;
public function __construct()
{
$this->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;
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace App\Entity;
use App\Repository\EtatLieuxFileRepository;
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: EtatLieuxFileRepository::class)]
#[Uploadable]
class EtatLieuxFile
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'files')]
#[ORM\JoinColumn(nullable: false)]
private ?EtatLieux $etatLieux = null;
#[UploadableField(mapping: 'etat_lieux_media', fileNameProperty: 'fileName', size: 'fileSize', mimeType: 'mimeType')]
private ?File $file = null;
#[ORM\Column(nullable: true)]
private ?string $fileName = null;
#[ORM\Column(nullable: true)]
private ?int $fileSize = null;
#[ORM\Column(length: 255, nullable: true)]
private ?string $mimeType = null;
#[ORM\Column(length: 50)]
private ?string $type = null; // 'photo' or 'video'
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
public function __construct()
{
$this->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;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Repository;
use App\Entity\EtatLieuxComment;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EtatLieuxComment>
*
* @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);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Repository;
use App\Entity\EtatLieuxFile;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<EtatLieuxFile>
*
* @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);
}
}

111
templates/etl/edl.twig Normal file
View File

@@ -0,0 +1,111 @@
{% extends 'etl/base.twig' %}
{% block title %}État des Lieux - #{{ mission.numReservation }}{% endblock %}
{% block body %}
<div class="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
{# HEADER #}
<div class="flex items-center gap-4">
<a href="{{ path('etl_contrat_view', {id: mission.id}) }}" class="w-10 h-10 bg-white rounded-xl border border-slate-100 flex items-center justify-center text-slate-400 hover:text-blue-600 transition-all shadow-sm">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" /></svg>
</a>
<div>
<h1 class="text-xl font-black text-slate-900 tracking-tight">État des Lieux</h1>
<p class="text-[10px] font-bold text-slate-400 uppercase tracking-widest">Réf: #{{ mission.numReservation }}</p>
</div>
</div>
{# PHOTOS / VIDEOS #}
<div class="bg-white rounded-[2rem] p-6 border border-slate-100 shadow-sm">
<h3 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-4">Photos & Vidéos</h3>
<div class="grid grid-cols-3 gap-2 mb-4">
{% for file in etatLieux.files %}
<div class="aspect-square bg-slate-100 rounded-xl overflow-hidden relative group">
{% if file.type == 'photo' %}
<img src="{{ vich_uploader_asset(file, 'file') }}" alt="Photo" class="w-full h-full object-cover cursor-pointer lightbox-trigger" data-type="photo" data-src="{{ vich_uploader_asset(file, 'file') }}">
{% else %}
<div class="w-full h-full relative cursor-pointer lightbox-trigger" data-type="video" data-src="{{ vich_uploader_asset(file, 'file') }}">
<video src="{{ vich_uploader_asset(file, 'file') }}" class="w-full h-full object-cover pointer-events-none"></video>
<div class="absolute inset-0 flex items-center justify-center bg-black/20 text-white pointer-events-none">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
</div>
{% endif %}
<form action="{{ path('etl_edl_delete_file', {id: mission.id, fileId: file.id}) }}" method="post" class="absolute top-1 right-1 z-10">
<button type="submit" class="bg-red-500/80 text-white p-1 rounded-full hover:bg-red-600 transition-colors shadow-sm" onclick="return confirm('Supprimer ce fichier ?')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</form>
</div>
{% else %}
<div class="col-span-3 text-center py-4 text-slate-400 text-xs italic">Aucun média ajouté.</div>
{% endfor %}
</div>
{# LIGHTBOX MODAL #}
<div id="lightbox-modal" class="fixed inset-0 z-[100] bg-black/90 hidden items-center justify-center p-2 backdrop-blur-sm transition-opacity duration-300 opacity-0 pointer-events-none">
<button id="lightbox-close" class="absolute top-4 right-4 text-white/80 hover:text-white z-50 p-2 bg-black/50 rounded-full">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
<div class="w-full h-full flex items-center justify-center relative">
<img id="lightbox-img" src="" class="max-w-full max-h-full object-contain rounded-lg shadow-2xl hidden" alt="Full view">
<video id="lightbox-video" src="" controls class="max-w-full max-h-full rounded-lg shadow-2xl hidden"></video>
</div>
</div>
<form action="{{ path('etl_edl_add_file', {id: mission.id}) }}" method="post" enctype="multipart/form-data" class="space-y-4">
<div class="flex gap-2">
<label class="flex-1 cursor-pointer bg-blue-50 text-blue-600 rounded-xl border border-blue-100 py-3 flex items-center justify-center gap-2 text-xs font-bold uppercase tracking-wide hover:bg-blue-100 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
Sélectionner Photos
<input type="file" name="photos[]" accept="image/*" multiple class="hidden">
</label>
<label class="flex-1 cursor-pointer bg-purple-50 text-purple-600 rounded-xl border border-purple-100 py-3 flex items-center justify-center gap-2 text-xs font-bold uppercase tracking-wide hover:bg-purple-100 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
Sélectionner Vidéos
<input type="file" name="videos[]" accept="video/*" multiple class="hidden">
</label>
</div>
<button type="submit" class="w-full py-3 bg-slate-900 text-white rounded-xl text-xs font-bold uppercase tracking-wide hover:bg-slate-800 transition-colors shadow-lg">
Envoyer les fichiers
</button>
</form>
</div>
{# COMMENTAIRES #}
<div class="bg-white rounded-[2rem] p-6 border border-slate-100 shadow-sm">
<h3 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-4">Commentaires</h3>
<div class="space-y-3 mb-4">
{% for comment in etatLieux.comments %}
<div class="bg-slate-50 p-3 rounded-xl">
<p class="text-[10px] font-bold text-slate-400 mb-1">{{ comment.createdAt|date('d/m H:i') }}</p>
<p class="text-sm text-slate-700">{{ comment.content }}</p>
</div>
{% else %}
<p class="text-center py-2 text-slate-400 text-xs italic">Aucun commentaire.</p>
{% endfor %}
</div>
<form action="{{ path('etl_edl_add_comment', {id: mission.id}) }}" method="post" class="flex gap-2">
<input type="text" name="content" placeholder="Votre commentaire..." class="flex-1 bg-slate-50 border border-slate-200 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-blue-500 transition-colors" required>
<button type="submit" class="bg-slate-900 text-white p-3 rounded-xl hover:bg-slate-700 transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" /></svg>
</button>
</form>
</div>
{# ACTION TERMINER #}
<form action="{{ path('etl_edl_finish', {id: mission.id}) }}" method="post">
<button type="submit" class="w-full py-4 bg-emerald-500 hover:bg-emerald-600 text-white rounded-2xl font-black uppercase text-sm tracking-widest shadow-lg shadow-emerald-500/30 transition-all active:scale-95 flex items-center justify-center gap-3">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
Terminer l'état des lieux
</button>
</form>
</div>
{% endblock %}

View File

@@ -25,13 +25,93 @@
</button>
</form>
{% elseif mission.etatLieux.status == 'delivery_progress' %}
<div class="w-full py-4 bg-amber-500/10 border border-amber-500/20 text-amber-500 rounded-2xl font-black uppercase text-sm tracking-widest flex items-center justify-center gap-3">
<span class="relative flex h-3 w-3">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-amber-500"></span>
</span>
Livraison en cours
<form action="{{ path('etl_mission_finish', {id: mission.id}) }}" method="post">
<button type="submit" class="w-full py-4 bg-amber-500 hover:bg-amber-600 text-white rounded-2xl font-black uppercase text-sm tracking-widest shadow-lg shadow-amber-500/30 transition-all active:scale-95 flex items-center justify-center gap-3">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
Confirmer la livraison
</button>
</form>
{% elseif mission.etatLieux.status == 'delivery_done' %}
<div class="w-full py-4 bg-emerald-500/10 border border-emerald-500/20 text-emerald-600 rounded-2xl font-black uppercase text-sm tracking-widest flex items-center justify-center gap-3 mb-6">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
Livraison Terminée
</div>
{% set is_chorus = (mission.devis and 'Chorus' in mission.devis.paymentMethod) %}
{% if not is_chorus %}
{# CAUTION #}
<div class="bg-white rounded-[2rem] p-6 border border-slate-100 shadow-sm mb-4">
<h3 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-4">Caution</h3>
<div class="flex items-center justify-between">
<span class="text-2xl font-black text-slate-900">{{ totalCaution|number_format(2, ',', ' ') }}€</span>
{% if cautionPaid %}
<span class="px-3 py-1 bg-emerald-100 text-emerald-600 rounded-lg text-[10px] font-black uppercase">Reçue</span>
{% else %}
<form action="{{ path('etl_mission_caution', {id: mission.id}) }}" method="post">
<button type="submit" class="px-4 py-2 bg-slate-900 text-white rounded-xl text-[10px] font-black uppercase tracking-wide hover:bg-blue-600 transition-colors">
Valider Réception
</button>
</form>
{% endif %}
</div>
</div>
{# SOLDE #}
{% if solde > 0 %}
<div class="bg-slate-900 rounded-[2rem] p-6 text-white shadow-xl mb-4">
<div class="flex justify-between items-start mb-4">
<div>
<p class="text-[10px] font-black text-blue-400 uppercase tracking-widest mb-1">Reste à payer</p>
<p class="text-3xl font-black">{{ solde|number_format(2, ',', ' ') }}€</p>
</div>
</div>
{# Paiement en ligne #}
<form action="{{ path('elt_mission_confirme', {id: mission.id}) }}" method="post" class="mb-4">
<button type="submit" class="w-full py-3 bg-blue-600 hover:bg-blue-500 text-white rounded-xl font-bold text-xs uppercase tracking-wider shadow-lg shadow-blue-900/50 transition-all active:scale-95 flex items-center justify-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z"/></svg>
Paiement Carte Bancaire
</button>
</form>
{# Paiements Manuels #}
<p class="text-[9px] font-bold text-slate-500 uppercase tracking-widest mb-2 text-center">Encaissement direct</p>
<div class="grid grid-cols-3 gap-2">
{% for method in ['Chèque', 'Espèces', 'Autre'] %}
<form action="{{ path('etl_mission_manual_pay', {id: mission.id}) }}" method="post">
<input type="hidden" name="method" value="{{ method }}">
<button type="submit" class="w-full py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white rounded-xl font-bold text-[10px] uppercase tracking-wide transition-all active:scale-95 border border-slate-700">
{{ method }}
</button>
</form>
{% endfor %}
</div>
</div>
{% else %}
<div class="bg-emerald-500/10 border border-emerald-500/20 text-emerald-500 rounded-[2rem] p-6 text-center mb-4">
<p class="text-xs font-black uppercase tracking-widest">Solde Réglé</p>
</div>
{% endif %}
{% endif %}
{# ACTION EDL #}
{% if is_chorus or solde <= 0 %}
<form action="{{ path('etl_mission_edl_start', {id: mission.id}) }}" method="post" class="mt-6">
<button type="submit" class="w-full py-4 bg-indigo-600 hover:bg-indigo-500 text-white rounded-2xl font-black uppercase text-sm tracking-widest shadow-lg shadow-indigo-600/30 transition-all active:scale-95 flex items-center justify-center gap-3">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg>
Commencer l'état des lieux
</button>
</form>
{% endif %}
{% elseif mission.etatLieux.status == 'edl_progress' %}
<a href="{{ path('etl_mission_edl', {id: mission.id}) }}" class="w-full py-4 bg-indigo-600 hover:bg-indigo-500 text-white rounded-2xl font-black uppercase text-sm tracking-widest shadow-lg shadow-indigo-600/30 transition-all active:scale-95 flex items-center justify-center gap-3 mb-6">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
Reprendre l'état des lieux
</a>
{% endif %}
{# DATES #}