feat(EtatLieux): Implémente la gestion des points de contrôle et améliore le PDF/l'intégration DocuSeal.

This commit is contained in:
Serreau Jovann
2026-02-06 16:34:44 +01:00
parent a88a143fa5
commit d5fcb788b4
6 changed files with 333 additions and 70 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

View File

@@ -8,6 +8,8 @@ use App\Entity\ContratsPayments;
use App\Entity\EtatLieux; use App\Entity\EtatLieux;
use App\Entity\EtatLieuxComment; use App\Entity\EtatLieuxComment;
use App\Entity\EtatLieuxFile; use App\Entity\EtatLieuxFile;
use App\Entity\EtatLieuxPointControl;
use App\Entity\ProductPointControll;
use App\Entity\Prestaire; use App\Entity\Prestaire;
use App\Form\PrestairePasswordType; use App\Form\PrestairePasswordType;
use App\Repository\ContratsRepository; use App\Repository\ContratsRepository;
@@ -29,6 +31,8 @@ use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry; use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Vich\UploaderBundle\Templating\Helper\UploaderHelper;
use Vich\UploaderBundle\Storage\StorageInterface;
class EtlController extends AbstractController class EtlController extends AbstractController
{ {
@@ -448,6 +452,54 @@ class EtlController extends AbstractController
return $this->redirectToRoute('etl_mission_edl', ['id' => $contrat->getId()]); return $this->redirectToRoute('etl_mission_edl', ['id' => $contrat->getId()]);
} }
#[Route('/etl/mission/{id}/edl/save-points', name: 'etl_edl_save_points', methods: ['POST'])]
public function eltEdlSavePoints(Contrats $contrat, Request $request, EntityManagerInterface $em): Response
{
$user = $this->getUser();
if (!$user) {
return $this->redirectToRoute('etl_login');
}
$etatLieux = $contrat->getEtatLieux();
if (!$etatLieux) {
return $this->redirectToRoute('etl_mission_edl', ['id' => $contrat->getId()]);
}
$data = $request->request->all('points');
if ($data) {
foreach ($data as $productId => $points) {
foreach ($points as $pointId => $values) {
$productPoint = $em->getRepository(ProductPointControll::class)->find($pointId);
if ($productPoint) {
$existing = null;
foreach ($etatLieux->getPointControls() as $ep) {
if ($ep->getName() === $productPoint->getName()) {
$existing = $ep;
break;
}
}
if (!$existing) {
$existing = new EtatLieuxPointControl();
$existing->setName($productPoint->getName());
$existing->setEtatLieux($etatLieux);
$em->persist($existing);
}
$existing->setStatus(isset($values['status']));
$existing->setDetails($values['details'] ?? null);
}
}
}
$em->flush();
$this->addFlash('success', 'Points de contrôle enregistrés.');
}
return $this->redirectToRoute('etl_mission_edl', ['id' => $contrat->getId()]);
}
#[Route('/etl/mission/{id}/edl/file', name: 'etl_edl_add_file', methods: ['POST'])] #[Route('/etl/mission/{id}/edl/file', name: 'etl_edl_add_file', methods: ['POST'])]
public function eltEdlAddFile(Contrats $contrat, Request $request, EntityManagerInterface $em): Response public function eltEdlAddFile(Contrats $contrat, Request $request, EntityManagerInterface $em): Response
{ {
@@ -551,6 +603,33 @@ class EtlController extends AbstractController
$etatLieux = $contrat->getEtatLieux(); $etatLieux = $contrat->getEtatLieux();
if ($etatLieux) { if ($etatLieux) {
$etatLieux->setStatus('edl_done'); $etatLieux->setStatus('edl_done');
$this->generateAndSendToDocuSeal($contrat, $em, $kernel, $signatureClient);
$this->addFlash('success', 'État des lieux terminé et PDF généré.');
return $this->redirectToRoute('etl_mission_signed_entry_state', ['id' => $contrat->getId()]);
}
return $this->redirectToRoute('etl_contrat_view', ['id' => $contrat->getId()]);
}
#[Route('/etl/mission/{id}/edl/regenerate-view', name: 'etl_edl_regenerate_view', methods: ['GET'])]
public function eltEdlRegenerateAndView(Contrats $contrat, EntityManagerInterface $em, KernelInterface $kernel, SignatureClient $signatureClient, UploaderHelper $uploaderHelper): Response
{
$user = $this->getUser();
if (!$user) {
return $this->redirectToRoute('etl_login');
}
$this->generateAndSendToDocuSeal($contrat, $em, $kernel, $signatureClient);
$etatLieux = $contrat->getEtatLieux();
$path = $uploaderHelper->asset($etatLieux, 'etatLieuxUnsignFile');
return new RedirectResponse($path);
}
private function generateAndSendToDocuSeal(Contrats $contrat, EntityManagerInterface $em, KernelInterface $kernel, SignatureClient $signatureClient): void
{
$etatLieux = $contrat->getEtatLieux();
// Generate PDF // Generate PDF
$pdfService = new EtatLieuxPdfService($kernel, $contrat); $pdfService = new EtatLieuxPdfService($kernel, $contrat);
@@ -565,23 +644,15 @@ class EtlController extends AbstractController
$etatLieux->setEtatLieuxUnsignFile($file); $etatLieux->setEtatLieuxUnsignFile($file);
$etatLieux->setUpdatedAt(new \DateTimeImmutable()); $etatLieux->setUpdatedAt(new \DateTimeImmutable());
$em->persist($etatLieux);
$em->flush(); // Save file $em->flush(); // Save file
// Send to DocuSeal (Assuming method exists or similar logic) // Send to DocuSeal
// If createSubmissionEtatLieux doesn't exist, this might fail.
// But based on prompt "send docuseal", I assume integration is ready or I follow pattern.
// I'll call createSubmissionEtatLieux.
try { try {
$signatureClient->createSubmissionEtatLieux($etatLieux); $signatureClient->createSubmissionEtatLieux($etatLieux);
} catch (\Exception $e) { } catch (\Exception $e) {
// Fallback or log if method missing, but proceeding // Fallback
} }
$this->addFlash('success', 'État des lieux terminé et PDF généré.');
return $this->redirectToRoute('etl_mission_signed_entry_state', ['id' => $contrat->getId()]);
}
return $this->redirectToRoute('etl_contrat_view', ['id' => $contrat->getId()]);
} }
#[Route('/etl/mission/{id}/signed-entry-state', name: 'etl_mission_signed_entry_state', methods: ['GET'])] #[Route('/etl/mission/{id}/signed-entry-state', name: 'etl_mission_signed_entry_state', methods: ['GET'])]
@@ -644,7 +715,7 @@ class EtlController extends AbstractController
} }
#[Route('/etl/mission/{id}/edl/close', name: 'etl_edl_close', methods: ['POST'])] #[Route('/etl/mission/{id}/edl/close', name: 'etl_edl_close', methods: ['POST'])]
public function eltMissionCloseEdl(Contrats $contrat, SignatureClient $signatureClient, EntityManagerInterface $em, Mailer $mailer): Response public function eltMissionCloseEdl(Contrats $contrat, SignatureClient $signatureClient, EntityManagerInterface $em, Mailer $mailer, StorageInterface $storage): Response
{ {
$user = $this->getUser(); $user = $this->getUser();
if (!$user) { if (!$user) {
@@ -663,7 +734,7 @@ class EtlController extends AbstractController
$submission = $signatureClient->getSubmition($submissionId); $submission = $signatureClient->getSubmition($submissionId);
$signedPdfUrl = $submission['documents'][0]['url'] ?? null; $signedPdfUrl = $submission['documents'][0]['url'] ?? null;
$auditUrl = $submission['audit_log_url'] ?? null; // Assuming DocuSeal API returns this or similar $auditUrl = $submission['audit_log_url'] ?? null;
if ($signedPdfUrl) { if ($signedPdfUrl) {
$tmpPath = sys_get_temp_dir() . '/edl_signed_' . $contrat->getId() . '.pdf'; $tmpPath = sys_get_temp_dir() . '/edl_signed_' . $contrat->getId() . '.pdf';
@@ -671,12 +742,15 @@ class EtlController extends AbstractController
$file = new UploadedFile($tmpPath, 'edl_entrant_signed.pdf', 'application/pdf', null, true); $file = new UploadedFile($tmpPath, 'edl_entrant_signed.pdf', 'application/pdf', null, true);
$etatLieux->setEtatLieuxSignFile($file); $etatLieux->setEtatLieuxSignFile($file);
} }
if ($auditUrl) {
$tmpPathAudit = sys_get_temp_dir() . '/edl_audit_signed_' . $contrat->getId() . '.pdf';
file_put_contents($tmpPathAudit, file_get_contents($auditUrl));
$file = new UploadedFile($tmpPathAudit, 'edl_audit_signed.pdf', 'application/pdf', null, true);
$etatLieux->setEtatLieuxAuditFile($file);
}
$etatLieux->setUpdatedAt(new \DateTimeImmutable());
// Audit log URL might not be directly exposed or requires different call. $etatLieux->setStatus('edl_validated');
// If not available easily, we skip or try constructing it.
// Assuming simple download for now if URL exists.
$etatLieux->setStatus('edl_validated'); // Final state
$contrat->setReservationState('progress'); $contrat->setReservationState('progress');
$em->flush(); $em->flush();
@@ -690,10 +764,24 @@ class EtlController extends AbstractController
} }
$attachments = []; $attachments = [];
if (isset($tmpPath) && file_exists($tmpPath)) {
// Try resolve path from Vich
$signPath = $storage->resolvePath($etatLieux, 'etatLieuxSignFile');
// If resolvePath returns null (e.g. no mapping or file not found yet?), check manual path
// But flush() should have moved it. resolvePath usually returns absolute path.
if ($signPath && file_exists($signPath)) {
$attachments[] = DataPart::fromPath($signPath, 'Etat_des_lieux_signe.pdf');
} elseif (isset($tmpPath) && file_exists($tmpPath)) {
$attachments[] = DataPart::fromPath($tmpPath, 'Etat_des_lieux_signe.pdf'); $attachments[] = DataPart::fromPath($tmpPath, 'Etat_des_lieux_signe.pdf');
} }
$auditPath = $storage->resolvePath($etatLieux, 'etatLieuxAuditFile');
if ($auditPath && file_exists($auditPath)) {
$attachments[] = DataPart::fromPath($auditPath, 'Audit_Etat_des_lieux_signe.pdf');
} elseif (isset($tmpPathAudit) && file_exists($tmpPathAudit)) {
$attachments[] = DataPart::fromPath($tmpPathAudit, 'Audit_Etat_des_lieux_signe.pdf');
}
foreach (array_unique($recipients) as $email) { foreach (array_unique($recipients) as $email) {
$mailer->send( $mailer->send(
$email, $email,

View File

@@ -27,20 +27,53 @@ class EtatLieuxPdfService extends Fpdf
$this->SetAutoPageBreak(true, 35); $this->SetAutoPageBreak(true, 35);
} }
/**
* Génère le PDF de l'état des lieux entrant
*/
public function generate(): string public function generate(): string
{ {
$this->AddPage(); $this->AddPage();
$this->renderEtatLieuxEntrant(); $this->renderEtatLieuxEntrant();
// On peut ajouter une page de signature si nécessaire, $this->renderSignaturePage();
// ou laisser la signature se faire sur ce document via DocuSeal
return $this->Output('S'); return $this->Output('S');
} }
private function renderSignaturePage(): void
{
$this->AddPage();
$this->SetFont('Arial', 'B', 14);
$this->SetTextColor(37, 99, 235);
$this->Cell(0, 10, $this->clean("SIGNATURES"), 0, 1, 'C');
$this->Ln(10);
$this->SetFont('Arial', '', 10);
$this->SetTextColor(0, 0, 0);
$this->MultiCell(0, 5, $this->clean("En signant ce document, les parties valident l'état des lieux d'installation ci-dessus."), 0, 'C');
$this->Ln(20);
// --- SIGNATURES ---
$ySign = $this->GetY();
$this->SetFont('Arial', 'B', 10);
$this->Cell(95, 8, $this->clean("Le Prestataire"), 0, 0, 'C');
$this->Cell(95, 8, $this->clean("Le Client (Bon pour accord)"), 0, 1, 'C');
$this->Ln(8);
$this->Cell(95, 40, "", 1, 0);
$this->Cell(95, 40, "", 1, 1);
// DocuSeal tags
$this->SetXY(20, $ySign + 35);
$this->SetFont('Arial', '', 8);
$this->SetTextColor(255, 255, 255);
$this->Cell(50, 5, '{{Sign;type=signature;role=Ludikevent}}', 0, 0);
$this->SetXY(115, $ySign + 35);
$this->Cell(50, 5, '{{Sign;type=signature;role=Client}}', 0, 0);
$this->SetTextColor(0, 0, 0);
}
private function renderEtatLieuxEntrant(): void private function renderEtatLieuxEntrant(): void
{ {
$this->SetY(50); $this->SetY(50);
@@ -57,7 +90,66 @@ class EtatLieuxPdfService extends Fpdf
$this->SetFont('Arial', '', 10); $this->SetFont('Arial', '', 10);
$this->MultiCell(0, 5, $this->clean("Le locataire reconnaît avoir reçu le matériel ci-dessous en bon état de fonctionnement, propre et conforme à la commande."), 0, 'C'); $this->MultiCell(0, 5, $this->clean("Le locataire reconnaît avoir reçu le matériel ci-dessous en bon état de fonctionnement, propre et conforme à la commande."), 0, 'C');
$this->Ln(10); $this->Ln(5);
// --- INFO PARTIES (2 BLOCS SÉPARÉS) ---
// Detect Delivery/Installation
$hasDelivery = false;
foreach ($this->contrats->getContratsLines() as $line) {
if (stripos($line->getName(), 'livraison') !== false || stripos($line->getName(), 'installation') !== false) {
$hasDelivery = true; break;
}
}
if (!$hasDelivery) {
foreach ($this->contrats->getContratsOptions() as $opt) {
if (stripos($opt->getName(), 'livraison') !== false || stripos($opt->getName(), 'installation') !== false) {
$hasDelivery = true; break;
}
}
}
$prestataireTitle = " PRESTATAIRE";
if ($hasDelivery) {
$prestataireTitle .= " (Livreur / Installateur)";
}
$colWidth = 90;
$gap = 10;
$xStart = 10;
$this->SetFillColor(245, 245, 245);
$this->SetFont('Arial', 'B', 9);
// Header Block 1
$this->Cell($colWidth, 6, $this->clean($prestataireTitle), 1, 0, 'L', true);
// Header Block 2
$this->SetX($xStart + $colWidth + $gap);
$this->Cell($colWidth, 6, $this->clean(" CLIENT / LIEU"), 1, 1, 'L', true);
$this->SetFont('Arial', '', 8);
$yContent = $this->GetY();
// Content Block 1 (Prestataire)
$this->SetXY($xStart, $yContent);
$prestataire = $this->contrats->getPrestataire();
$prestataireTxt = $prestataire ? ($prestataire->getName() . "\n" . $prestataire->getEmail()) : "Ludikevent (Admin)\ncontact@ludikevent.fr";
$this->MultiCell($colWidth, 5, $this->clean($prestataireTxt), 'LRB', 'L');
$h1 = $this->GetY() - $yContent;
// Content Block 2 (Client)
$this->SetXY($xStart + $colWidth + $gap, $yContent);
$customer = $this->contrats->getCustomer();
$clientTxt = $customer->getName() . " " . $customer->getSurname() . "\n" .
$customer->getEmail() . "\n" .
$customer->getPhone() . "\n" .
"Lieu: " . $this->contrats->getAddressEvent() . " " . $this->contrats->getZipCodeEvent() . " " . $this->contrats->getTownEvent();
$this->MultiCell($colWidth, 5, $this->clean($clientTxt), 'LRB', 'L');
$h2 = $this->GetY() - $yContent;
// Reset Y to max height
$this->SetY($yContent + max($h1, $h2) + 5);
// --- LISTE DU MATÉRIEL --- // --- LISTE DU MATÉRIEL ---
$this->SetFont('Arial', 'B', 10); $this->SetFont('Arial', 'B', 10);
@@ -74,7 +166,7 @@ class EtatLieuxPdfService extends Fpdf
} }
// Affichage simple avec case à cocher // Affichage simple avec case à cocher
$this->MultiCell(190, 8, $this->clean("[ ] " . $line->getName()), 1, 'L'); $this->MultiCell(190, 8, $this->clean($line->getName()), 1, 'L');
} }
foreach ($this->contrats->getContratsOptions() as $opt) { foreach ($this->contrats->getContratsOptions() as $opt) {
@@ -83,10 +175,62 @@ class EtatLieuxPdfService extends Fpdf
continue; continue;
} }
$this->MultiCell(190, 8, $this->clean("[ ] [Option] " . $opt->getName()), 1, 'L'); $this->MultiCell(190, 8, $this->clean("[Option] " . $opt->getName()), 1, 'L');
} }
$this->Ln(10); $this->Ln(5);
// --- POINTS DE CONTRÔLE ---
$etatLieux = $this->contrats->getEtatLieux();
if ($etatLieux) {
$this->SetFont('Arial', 'B', 10);
$this->SetFillColor(240, 240, 240);
$this->Cell(190, 8, $this->clean(" POINTS DE CONTRÔLE"), 1, 1, 'L', true);
$this->Ln(3);
foreach ($this->contrats->getProductReserves() as $reserve) {
$product = $reserve->getProduct();
if ($product && count($product->getProductPointControlls()) > 0) {
$this->SetFillColor(230, 240, 255);
$this->SetFont('Arial', 'B', 9);
$this->Cell(190, 6, $this->clean(" " . $product->getName()), 1, 1, 'L', true);
$this->SetFillColor(250, 250, 250);
$this->SetFont('Arial', 'B', 8);
$this->Cell(95, 5, $this->clean(" Nom du contrôle"), 1, 0, 'L', true);
$this->Cell(95, 5, $this->clean(" Commentaire / État"), 1, 1, 'L', true);
$this->SetFont('Arial', '', 8);
foreach ($product->getProductPointControlls() as $pPoint) {
$details = '';
foreach ($etatLieux->getPointControls() as $ep) {
if ($ep->getName() === $pPoint->getName()) {
$details = $ep->getDetails();
break;
}
}
$y = $this->GetY();
$x = $this->GetX();
$this->MultiCell(95, 5, $this->clean($pPoint->getName()), 1, 'L');
$h1 = $this->GetY() - $y;
$this->SetXY($x + 95, $y);
$this->MultiCell(95, 5, $this->clean($details), 1, 'L');
$h2 = $this->GetY() - $y;
$this->SetY($y + max($h1, $h2));
$this->SetX(10);
}
$this->Ln(3);
}
}
$this->Ln(3);
}
$this->Ln(5);
// --- COMMENTAIRES --- // --- COMMENTAIRES ---
$this->SetFont('Arial', 'B', 10); $this->SetFont('Arial', 'B', 10);
@@ -118,27 +262,6 @@ class EtatLieuxPdfService extends Fpdf
$endY = $this->GetY(); $endY = $this->GetY();
// Box removed as requested // Box removed as requested
$this->Ln(30); $this->Ln(30);
// --- SIGNATURES ---
$ySign = $this->GetY();
$this->SetFont('Arial', 'B', 10);
$this->Cell(95, 8, $this->clean("Le Prestataire"), 0, 0, 'C');
$this->Cell(95, 8, $this->clean("Le Client (Bon pour accord)"), 0, 1, 'C');
$this->Cell(95, 40, "", 1, 0);
$this->Cell(95, 40, "", 1, 1);
// DocuSeal tags invisible (si besoin d'intégration automatique plus tard)
$this->SetXY(20, $ySign + 35);
$this->SetFont('Arial', '', 8);
$this->SetTextColor(255, 255, 255);
$this->Cell(50, 5, '{{Sign;type=signature;role=Ludikevent}}', 0, 0);
$this->SetXY(115, $ySign + 35);
$this->Cell(50, 5, '{{Sign;type=signature;role=Client}}', 0, 0);
$this->SetTextColor(0, 0, 0);
} }
// --- HELPER METHODS DUPLICATED FROM ContratPdfService --- // --- HELPER METHODS DUPLICATED FROM ContratPdfService ---

View File

@@ -326,10 +326,6 @@ class Client
public function createSubmissionEtatLieux(EtatLieux $etatLieux): void public function createSubmissionEtatLieux(EtatLieux $etatLieux): void
{ {
// Si déjà initié, on arrête (ou on pourrait retourner les liens existants)
if ($etatLieux->getSignIdCustomer()) {
return;
}
$contrat = $etatLieux->getContrat(); $contrat = $etatLieux->getContrat();
$customer = $contrat->getCustomer(); $customer = $contrat->getCustomer();

View File

@@ -76,6 +76,57 @@
</form> </form>
</div> </div>
{# POINTS DE CONTROLE #}
<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">Points de Contrôle</h3>
<form action="{{ path('etl_edl_save_points', {id: mission.id}) }}" method="post" id="form-points">
{% for reserve in mission.productReserves %}
{% set product = reserve.product %}
{% if product.productPointControlls|length > 0 %}
<div class="mb-6 last:mb-0">
<h4 class="font-bold text-slate-900 mb-3 flex items-center gap-2">
<span class="w-1 h-4 bg-blue-500 rounded-full"></span>
{{ product.name }}
</h4>
<div class="space-y-3 pl-3 border-l border-slate-100 ml-0.5">
{% for point in product.productPointControlls %}
{# Try to find existing status/comment in etatLieux.pointControls #}
{% set existingPoint = null %}
{% for ep in etatLieux.pointControls %}
{% if ep.name == point.name %}
{% set existingPoint = ep %}
{% endif %}
{% endfor %}
<div class="bg-slate-50 p-3 rounded-xl">
<div class="flex items-start gap-3">
<div class="pt-1">
<input type="checkbox" name="points[{{ product.id }}][{{ point.id }}][status]" value="1"
class="w-5 h-5 rounded-md border-slate-300 text-blue-600 focus:ring-blue-500"
{{ existingPoint and existingPoint.status ? 'checked' : '' }}>
</div>
<div class="flex-1">
<p class="text-sm font-bold text-slate-700 mb-1">{{ point.name }}</p>
<input type="text" name="points[{{ product.id }}][{{ point.id }}][details]"
value="{{ existingPoint ? existingPoint.details : '' }}"
placeholder="Commentaire (optionnel)..."
class="w-full bg-white border border-slate-200 rounded-lg px-3 py-2 text-xs focus:outline-none focus:border-blue-500 transition-colors">
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
<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 mt-4">
Enregistrer les contrôles
</button>
</form>
</div>
{# COMMENTAIRES #} {# COMMENTAIRES #}
<div class="bg-white rounded-[2rem] p-6 border border-slate-100 shadow-sm"> <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> <h3 class="text-xs font-black text-slate-400 uppercase tracking-widest mb-4">Commentaires</h3>

View File

@@ -19,7 +19,12 @@
<div class="bg-blue-600 rounded-[2rem] p-8 text-white shadow-xl shadow-blue-600/20 text-center relative overflow-hidden"> <div class="bg-blue-600 rounded-[2rem] p-8 text-white shadow-xl shadow-blue-600/20 text-center relative overflow-hidden">
<div class="absolute top-0 right-0 -mr-8 -mt-8 w-32 h-32 bg-white/10 rounded-full blur-2xl"></div> <div class="absolute top-0 right-0 -mr-8 -mt-8 w-32 h-32 bg-white/10 rounded-full blur-2xl"></div>
<h2 class="text-2xl font-black mb-2">État des Lieux Terminé</h2> <h2 class="text-2xl font-black mb-2">État des Lieux Terminé</h2>
<p class="text-sm font-medium opacity-90">Veuillez procéder à la signature du document.</p> <p class="text-sm font-medium opacity-90 mb-6">Veuillez procéder à la signature du document.</p>
<a href="{{ path('etl_edl_regenerate_view', {id: mission.id}) }}" target="_blank" class="inline-flex items-center px-6 py-3 bg-white/20 hover:bg-white/30 text-white rounded-xl text-xs font-bold uppercase tracking-widest backdrop-blur-sm transition-all border border-white/30">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
Voir le PDF (Actualiser)
</a>
</div> </div>
{# ACTIONS SIGNATURE #} {# ACTIONS SIGNATURE #}