This commit is contained in:
Serreau Jovann
2026-01-30 11:47:43 +01:00
parent e1227c5d14
commit a407aed342
9 changed files with 1329 additions and 1314 deletions

View File

@@ -16,6 +16,7 @@ use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
@@ -24,38 +25,37 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface;
use Symfony\Component\Uid\Uuid;
#[Route('/crm/administrateur')]
class AccountController extends AbstractController
{
/**
* Liste des administrateurs
*/
#[Route(path: '/crm/administrateur', name: 'app_crm_administrateur', options: ['sitemap' => false], methods: ['GET'])]
public function administrateur(AccountRepository $accountRepository, AppLogger $appLogger): Response
public function __construct(
private readonly EntityManagerInterface $em,
private readonly AppLogger $appLogger,
private readonly Mailer $mailer,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly TokenGeneratorInterface $tokenGenerator,
private readonly UrlGeneratorInterface $urlGenerator
) {
}
#[Route('', name: 'app_crm_administrateur', options: ['sitemap' => false], methods: ['GET'])]
public function index(AccountRepository $accountRepository): Response
{
$appLogger->record('VIEW', 'Consultation de la liste des Administrateurs');
$this->appLogger->record('VIEW', 'Consultation de la liste des Administrateurs');
return $this->render('dashboard/administrateur.twig', [
'admins' => $accountRepository->findAdmin(),
]);
}
/**
* Ajouter un administrateur
*/
#[Route(path: '/crm/administrateur/add', name: 'app_crm_administrateur_add', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function administrateurAdd(
Request $request,
EntityManagerInterface $entityManager,
UserPasswordHasherInterface $passwordHasher,
AppLogger $appLogger,
EventDispatcherInterface $eventDispatcher
): Response
#[Route('/add', name: 'app_crm_administrateur_add', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function add(Request $request, UserPasswordHasherInterface $passwordHasher): Response
{
$account = new Account();
$account->setIsFirstLogin(true);
$account->setIsActif(false);
$account->setUuid(Uuid::v4()->toRfc4122());
$account->setRoles(['ROLE_ADMIN']);
$account->setIsFirstLogin(true)
->setIsActif(false)
->setUuid(Uuid::v4()->toRfc4122())
->setRoles(['ROLE_ADMIN']);
$form = $this->createForm(AccountType::class, $account);
$form->handleRequest($request);
@@ -64,18 +64,16 @@ class AccountController extends AbstractController
$tempPassword = bin2hex(random_bytes(20));
$account->setPassword($passwordHasher->hashPassword($account, $tempPassword));
$entityManager->persist($account);
$entityManager->flush();
$this->em->persist($account);
$this->em->flush();
$appLogger->record(
'CREATE',
sprintf("Création de l'Administrateur : %s %s (%s)",
$account->getFirstName(), $account->getName(), $account->getEmail()
)
);
$this->appLogger->record('CREATE', sprintf(
"Création de l'Administrateur : %s %s (%s)",
$account->getFirstName(), $account->getName(), $account->getEmail()
));
$eventDispatcher->dispatch(new EventAdminCreate($account, $this->getUser()));
$this->addFlash('success', "L'Administrateur " . $account->getFirstName() . " a été créé avec succès.");
$this->eventDispatcher->dispatch(new EventAdminCreate($account, $this->getUser()));
$this->addFlash('success', "L'Administrateur {$account->getFirstName()} a été créé avec succès.");
return $this->redirectToRoute('app_crm_administrateur');
}
@@ -83,142 +81,179 @@ class AccountController extends AbstractController
return $this->render('dashboard/administrateur/add.twig', ['form' => $form->createView()]);
}
#[Route(path: '/crm/administrateur/{id}', name: 'app_crm_administrateur_view', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function administrateurView(
#[Route('/{id}', name: 'app_crm_administrateur_view', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function view(
?Account $account,
PaginatorInterface $paginator,
Request $request,
AppLogger $appLogger,
Mailer $mailer,
AccountLoginRegisterRepository $accountLoginRegisterRepository,
EntityManagerInterface $em,
AuditLogRepository $auditLogRepository,
UserPasswordHasherInterface $passwordHasher,
TokenGeneratorInterface $tokenGenerator
PaginatorInterface $paginator,
AccountLoginRegisterRepository $loginRepo,
AuditLogRepository $auditRepo,
UserPasswordHasherInterface $passwordHasher
): Response {
if (!$account) {
$this->addFlash('error', 'Administrateur introuvable.');
return $this->redirectToRoute('app_crm_administrateur');
}
// --- SÉCURITÉ : PROTECTION ROOT ---
if (in_array('ROLE_ROOT', $account->getRoles())) {
$title = "Tentative d'accès non autorisée";
$details = sprintf("L'utilisateur %s a tenté de consulter l'Administrateur ROOT protégé : %s %s (ID: %d)",
$this->getUser()->getUserIdentifier(), $account->getFirstName(), $account->getName(), $account->getId()
);
$this->sendSecurityEmail($mailer, $title, $details);
$appLogger->record('SECURITY_ALERT', $details);
$this->addFlash('error', 'Sécurité : Le compte est protégé');
return $this->redirectToRoute('app_crm_administrateur');
}
if ($request->query->get('act') === 'disable2fa') {
if($account->isGoogleAuthenticatorEnabled()) {
$account->setGoogleAuthenticatorSecret(null);
$em->flush();
// Récupération de l'admin qui fait l'action
$currentUser = $this->getUser();
$mailer->send(
$account->getEmail(),
$account->getFirstName() . " " . $account->getName(),
"[Alerte Sécurité] Désactivation de votre double authentification",
"mails/account/2fa-disable.twig",
[
'account' => $account,
'who' => $currentUser // On passe l'objet admin connecté
]
);
$logMessage = sprintf(
"2FA désactivée pour %s %s par %s %s (ID: %d)",
$account->getFirstName(), $account->getName(),
$currentUser->getFirstName(), $currentUser->getName(),
$account->getId()
);
$appLogger->record('2FA_DISABLED', $logMessage);
$this->addFlash('warning', "La protection 2FA de " . $account->getFirstName() . " a été retirée.");
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
}
// 1. Vérification Sécurité ROOT
if ($redirect = $this->checkRootProtection($account)) {
return $redirect;
}
if ($request->query->get('act') === 'sendLink2faenable') {
$currentUser = $this->getUser();
// 2. Gestion des Actions Rapides (Query Params)
if ($action = $request->query->get('act')) {
return match ($action) {
'disable2fa' => $this->handleDisable2FA($account),
'sendLink2faenable' => $this->handleInvite2FA($account),
'updateStatut' => $this->handleUpdateStatus($account, $request->query->get('status') === 'true'),
'updateRoles' => $this->handleUpdateRoles($account, $request),
default => $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()])
};
}
$account->setConfirmationTokenName($tokenGenerator->generateToken());
$setupUrl = $this->generateUrl('app_2fa_setup_confirm', [
'id' => $account->getId(),
'token' => $account->getConfirmationToken()
], UrlGeneratorInterface::ABSOLUTE_URL);
$em->persist($account);
$em->flush();
// 3. Formulaire Mot de passe
$passwordForm = $this->createForm(AccountPasswordType::class, $account);
$passwordForm->handleRequest($request);
$mailer->send(
if ($passwordForm->isSubmitted() && $passwordForm->isValid()) {
$plainPassword = $passwordForm->get('password')->getData();
$account->setPassword($passwordHasher->hashPassword($account, $plainPassword));
$this->em->flush();
$this->mailer->send(
$account->getEmail(),
$account->getFirstName() . " " . $account->getName(),
"[Sécurité] Configuration de votre double authentification (2FA)",
"mails/account/2fa-invite.twig",
[
'account' => $account,
'setup_url' => $setupUrl,
'who' => $currentUser, // On passe l'objet admin connecté
'expires_at' => (new \DateTime('+1 hour'))->format('H:i')
]
"{$account->getName()} {$account->getFirstName()}",
"[Intranet Ludikevent] - Modification de votre mot de passe",
"mails/account/password-modify.twig",
['who' => $this->getUser(), 'account' => $account, 'password' => $plainPassword]
);
$logMessage = sprintf(
"Invitation 2FA envoyée à %s %s par %s %s",
$account->getFirstName(), $account->getName(),
$currentUser->getFirstName(), $currentUser->getName()
);
$appLogger->record('2FA_INVITE', $logMessage);
$this->addFlash('success', "Lien envoyé à " . $account->getFirstName());
$this->appLogger->record('UPDATE_PASSWORD', "Mot de passe modifié pour l'admin {$account->getId()}");
$this->addFlash('success', 'Le mot de passe a été mis à jour et envoyé à l\'utilisateur.');
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
}
if ($request->query->get('act') === 'updateStatut') {
$statusParam = $request->query->get('status');
$newStatus = ($statusParam === 'true');
// On définit un sujet dynamique pour plus de clarté
$subject = $newStatus
? "[Intranet Ludikevent] - Activation de votre accès"
: "[Intranet Ludikevent] - Suspension de votre accès";
// Envoi du mail de notification
$mailer->send(
$account->getEmail(),
$account->getFirstName() . " " . $account->getName(),
$subject,
"mails/account/status-update.twig",
[
'who' => $this->getUser(),
'account' => $account,
'stats' => $newStatus,
],
[]
);
// Mise à jour en base de données
// Vérifiez si votre entité utilise setActif() ou setIsActif()
$account->setIsActif($newStatus);
$em->flush();
$label = $newStatus ? 'activé' : 'désactivé';
// Log et Message Flash
$appLogger->record('UPDATE_STATUS', sprintf("%s du compte pour l'admin %d", ucfirst($label), $account->getId()));
$this->addFlash('success', "Le compte de " . $account->getFirstName() . " a été $label avec succès.");
// 4. Formulaire Identité
$form = $this->createForm(AccountType::class, $account);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->em->flush();
$this->appLogger->record('UPDATE_IDENTITY', "Mise à jour identité : {$account->getFirstName()} {$account->getName()}");
$this->addFlash('success', 'Informations personnelles mises à jour.');
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
}
// --- 2. LOGIQUE DE MISE À JOUR DES RÔLES (POST) ---
if ($request->isMethod('POST') && $request->query->get('act') === 'updateRoles') {
// 5. Affichage
$this->appLogger->record('VIEW', "Consultation : {$account->getFirstName()} {$account->getName()}");
return $this->render('dashboard/administrateur/view.twig', [
'admin' => $account,
'form' => $form->createView(),
'passwordForm' => $passwordForm->createView(),
'loginRegisters' => $paginator->paginate($loginRepo->findBy(['account' => $account], ['loginAt' => 'DESC']), $request->query->getInt('page', 1), 10),
'auditLogs' => $paginator->paginate($auditRepo->findBy(['account' => $account], ['actionAt' => 'DESC']), $request->query->getInt('audit_page', 1), 10, ['pageParameterName' => 'audit_page'])
]);
}
#[Route('/delete/{id}', name: 'app_crm_administrateur_delete', options: ['sitemap' => false], methods: ['POST'])]
public function delete(Account $account, Request $request): Response
{
if ($redirect = $this->checkRootProtection($account)) {
return $redirect;
}
if ($this->isCsrfTokenValid('delete' . $account->getId(), $request->request->get('_token'))) {
$name = "{$account->getFirstName()} {$account->getName()}";
$this->eventDispatcher->dispatch(new EventAdminDeleted($account, $this->getUser()));
$this->em->remove($account);
$this->em->flush();
$this->appLogger->record('DELETE', "Suppression définitive : $name");
$this->addFlash('success', "L'Administrateur $name a été supprimé.");
} else {
$this->addFlash('error', 'Jeton de sécurité invalide.');
}
return $this->redirectToRoute('app_crm_administrateur');
}
// --- PRIVATE HELPERS ---
private function handleDisable2FA(Account $account): RedirectResponse
{
if ($account->isGoogleAuthenticatorEnabled()) {
$account->setGoogleAuthenticatorSecret(null);
$this->em->flush();
$this->mailer->send(
$account->getEmail(),
"{$account->getFirstName()} {$account->getName()}",
"[Alerte Sécurité] Désactivation de votre double authentification",
"mails/account/2fa-disable.twig",
['account' => $account, 'who' => $this->getUser()]
);
$this->appLogger->record('2FA_DISABLED', "2FA désactivée pour {$account->getId()}");
$this->addFlash('warning', "La protection 2FA de {$account->getFirstName()} a été retirée.");
}
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
}
private function handleInvite2FA(Account $account): RedirectResponse
{
$account->setConfirmationTokenName($this->tokenGenerator->generateToken());
$this->em->flush();
$setupUrl = $this->urlGenerator->generate('app_2fa_setup_confirm', [
'id' => $account->getId(),
'token' => $account->getConfirmationToken()
], UrlGeneratorInterface::ABSOLUTE_URL);
$this->mailer->send(
$account->getEmail(),
"{$account->getFirstName()} {$account->getName()}",
"[Sécurité] Configuration de votre double authentification (2FA)",
"mails/account/2fa-invite.twig",
[
'account' => $account,
'setup_url' => $setupUrl,
'who' => $this->getUser(),
'expires_at' => (new \DateTime('+1 hour'))->format('H:i')
]
);
$this->appLogger->record('2FA_INVITE', "Invitation 2FA envoyée à {$account->getId()}");
$this->addFlash('success', "Lien 2FA envoyé à {$account->getFirstName()}");
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
}
private function handleUpdateStatus(Account $account, bool $newStatus): RedirectResponse
{
$account->setIsActif($newStatus);
$this->em->flush();
$this->mailer->send(
$account->getEmail(),
"{$account->getFirstName()} {$account->getName()}",
$newStatus ? "[Intranet Ludikevent] - Activation de votre accès" : "[Intranet Ludikevent] - Suspension de votre accès",
"mails/account/status-update.twig",
['who' => $this->getUser(), 'account' => $account, 'stats' => $newStatus]
);
$label = $newStatus ? 'activé' : 'désactivé';
$this->appLogger->record('UPDATE_STATUS', "Compte $label pour l'admin {$account->getId()}");
$this->addFlash('success', "Le compte de {$account->getFirstName()} a été $label.");
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
}
private function handleUpdateRoles(Account $account, Request $request): RedirectResponse
{
if ($request->isMethod('POST')) {
$submittedRoles = $request->request->all('roles');
$mandatoryRoles = ['ROLE_USER', 'ROLE_ADMIN'];
@@ -234,156 +269,38 @@ class AccountController extends AbstractController
}
$account->setRoles($submittedRoles);
$em->flush();
$this->em->flush();
$appLogger->record('UPDATE_ROLES', sprintf("Mise à jour des rôles pour l'admin %d", $account->getId()));
$this->addFlash('success', 'Les permissions ont été mises à jour.');
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
$this->appLogger->record('UPDATE_ROLES', "Mise à jour rôles admin {$account->getId()}");
$this->addFlash('success', 'Permissions mises à jour.');
}
// --- 3. LOGIQUE CHANGEMENT DE MOT DE PASSE ---
$passwordForm = $this->createForm(AccountPasswordType::class, $account);
$passwordForm->handleRequest($request);
if ($passwordForm->isSubmitted() && $passwordForm->isValid()) {
// 1. On récupère le mot de passe en clair
$plainPassword = $passwordForm->get('password')->getData();
// 2. On hache pour la base de données
$hashedPassword = $passwordHasher->hashPassword($account, $plainPassword);
$account->setPassword($hashedPassword);
$em->flush();
// 3. Envoi du mail avec les bons paramètres
$mailer->send(
$account->getEmail(),
$account->getName() . " " . $account->getFirstName(),
"[Intranet Ludikevent] - Modification de votre mot de passe",
"mails/account/password-modify.twig", // Correction orthographe 'account'
[
'who' => $this->getUser(),
'account' => $account,
'password' => $plainPassword // On envoie le mot de passe en clair ici
],
[]
);
$appLogger->record('UPDATE_PASSWORD', sprintf("Mot de passe modifié et envoyé pour l'admin %d", $account->getId()));
$this->addFlash('success', 'Le mot de passe a été mis à jour et envoyé à l\'utilisateur.');
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
}
// --- 4. LOGIQUE DU FORMULAIRE D'IDENTITÉ ---
$form = $this->createForm(AccountType::class, $account);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$em->flush();
$appLogger->record('UPDATE_IDENTITY', sprintf("Mise à jour identité : %s %s", $account->getFirstName(), $account->getName()));
$this->addFlash('success', 'Informations personnelles mises à jour.');
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
}
// --- 5. AFFICHAGE & PAGINATION ---
$appLogger->record('VIEW', sprintf("Consultation : %s %s", $account->getFirstName(), $account->getName()));
$loginPagination = $paginator->paginate(
$accountLoginRegisterRepository->findBy(['account' => $account], ['loginAt' => 'DESC']),
$request->query->getInt('page', 1),
10,
['pageParameterName' => 'page']
);
$auditPagination = $paginator->paginate(
$auditLogRepository->findBy(['account' => $account], ['actionAt' => 'DESC']),
$request->query->getInt('audit_page', 1),
10,
['pageParameterName' => 'audit_page']
);
return $this->render('dashboard/administrateur/view.twig', [
'admin' => $account,
'form' => $form->createView(),
'passwordForm' => $passwordForm->createView(),
'loginRegisters' => $loginPagination,
'auditLogs' => $auditPagination
]);
return $this->redirectToRoute('app_crm_administrateur_view', ['id' => $account->getId()]);
}
/**
* Modifier un administrateur
*/
/**
* Supprimer un administrateur
*/
#[Route(path: '/crm/administrateur/delete/{id}', name: 'app_crm_administrateur_delete', options: ['sitemap' => false], methods: ['POST'])]
public function administrateurDelete(
EventDispatcherInterface $eventDispatcher,
?Account $account,
Request $request,
AppLogger $appLogger,
EntityManagerInterface $entityManager,
Mailer $mailer
): Response
private function checkRootProtection(Account $account): ?RedirectResponse
{
if (!$account) {
$this->addFlash('error', 'Administrateur introuvable.');
return $this->redirectToRoute('app_crm_administrateur');
}
// --- SÉCURITÉ : Interdiction suppression ROOT ou CLIENT_MAIN ---
// On vérifie si l'un des rôles protégés est présent dans le tableau des rôles du compte
$protectedRoles = ['ROLE_ROOT'];
$accountRoles = $account->getRoles();
// Si l'intersection entre les rôles du compte et les rôles protégés n'est pas vide
if (array_intersect($protectedRoles, $accountRoles)) {
$title = "Blocage suppression compte HAUTE-HIÉRARCHIE";
$details = sprintf("Tentative de suppression de l'Administrateur protégé %s (Rôles: %s) par l'utilisateur %s",
$account->getEmail(),
implode(', ', $accountRoles),
$this->getUser()->getUserIdentifier()
if (in_array('ROLE_ROOT', $account->getRoles())) {
$title = "Tentative d'accès non autorisée";
$details = sprintf("Tentative de modification/suppression de l'Admin ROOT protégé : %s (ID: %d) par %s",
$account->getEmail(), $account->getId(), $this->getUser()->getUserIdentifier()
);
$appLogger->record('SECURITY_CRITICAL', $details);
$this->sendSecurityEmail($mailer, $title, $details);
$this->appLogger->record('SECURITY_ALERT', $details);
$this->sendSecurityEmail($title, $details);
$this->addFlash('error', 'Sécurité : Le compte est protégé');
$this->addFlash('error', 'Sécurité : Ce compte est protégé et ne peut pas être supprimé.');
return $this->redirectToRoute('app_crm_administrateur');
}
$token = $request->request->get('_token') ?? $request->query->get('_token');
if ($this->isCsrfTokenValid('delete' . $account->getId(), $token)) {
$name = $account->getFirstName() . ' ' . $account->getName();
$email = $account->getEmail();
$appLogger->record(
'DELETE',
sprintf("Suppression définitive de l'Administrateur : %s (%s)", $name, $email)
);
$eventDispatcher->dispatch(new EventAdminDeleted($account, $this->getUser()));
$entityManager->remove($account);
$entityManager->flush();
$this->addFlash('success', "L'Administrateur $name a été supprimé.");
} else {
$this->addFlash('error', 'Jeton de sécurité invalide.');
}
return $this->redirectToRoute('app_crm_administrateur');
return null;
}
private function sendSecurityEmail(Mailer $mailer, string $message, string $content): void
private function sendSecurityEmail(string $message, string $content): void
{
$mailer->send(
$this->mailer->send(
'notification@siteconseil.fr',
"Notification Intranet Ludikevent",
"[Intranet Ludikevent] - " . $message,
"[Intranet Ludikevent] - $message",
"mails/notification/security_violation.twig",
[
'message' => $message,

View File

@@ -8,48 +8,47 @@ use App\Repository\BackupRepository;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/crm/sauvegarde')]
class BackupController extends AbstractController
{
public function __construct(
private readonly AppLogger $appLogger,
private readonly EntityManagerInterface $entityManager
) {}
private readonly EntityManagerInterface $em
) {
}
#[Route(path: '/crm/sauvegarde', name: 'app_crm_backup', methods: ['GET'])]
public function crmSauvegarde(BackupRepository $backupRepository): Response
#[Route('', name: 'app_crm_backup', options: ['sitemap' => false], methods: ['GET'])]
public function index(BackupRepository $backupRepository): Response
{
$this->appLogger->record('VIEW', 'Consultation de la liste des sauvegardes système');
return $this->render('dashboard/backup.twig', [
'backups' => $backupRepository->findBy([], ['createdAt' => 'DESC']),
]);
}
#[Route(path: '/crm/sauvegarde/download/{id}', name: 'app_crm_backup_download', methods: ['GET'])]
#[Route('/download/{id}', name: 'app_crm_backup_download', options: ['sitemap' => false], methods: ['GET'])]
public function download(Backup $backup): Response
{
$projectDir = $this->getParameter('kernel.project_dir');
$fileName = sprintf('db_backup_%s.zip', $backup->getCreatedAt()->format('Y-m-d_H-i'));
$filePath = $projectDir . '/sauvegarde/' . $fileName;
$filePath = $this->getBackupFilePath($backup);
$fileName = basename($filePath);
if (!file_exists($filePath)) {
// Utilisation de record(string, string)
$this->appLogger->record(
'SECURITY_ALERT',
"Fichier de sauvegarde introuvable : $fileName (ID: {$backup->getId()})"
sprintf("Fichier de sauvegarde introuvable : %s (ID: %d)", $fileName, $backup->getId())
);
$this->addFlash('error', "Le fichier physique est introuvable sur le serveur.");
return $this->redirectToRoute('app_crm_backup');
}
$this->appLogger->record(
'INFO',
"Téléchargement de la sauvegarde : $fileName"
);
$this->appLogger->record('INFO', "Téléchargement de la sauvegarde : $fileName");
$response = new BinaryFileResponse($filePath);
$response->setContentDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $fileName);
@@ -57,8 +56,8 @@ class BackupController extends AbstractController
return $response;
}
#[Route(path: '/crm/sauvegarde/delete/{id}', name: 'app_crm_backup_delete', methods: ['GET'])]
public function delete(Backup $backup): Response
#[Route('/delete/{id}', name: 'app_crm_backup_delete', options: ['sitemap' => false], methods: ['GET'])]
public function delete(Backup $backup): RedirectResponse
{
if (!$this->isGranted('ROLE_ROOT')) {
$this->appLogger->record(
@@ -70,9 +69,8 @@ class BackupController extends AbstractController
return $this->redirectToRoute('app_crm_backup');
}
$projectDir = $this->getParameter('kernel.project_dir');
$fileName = sprintf('db_backup_%s.zip', $backup->getCreatedAt()->format('Y-m-d_H-i'));
$filePath = $projectDir . '/sauvegarde/' . $fileName;
$filePath = $this->getBackupFilePath($backup);
$fileName = basename($filePath);
try {
if (file_exists($filePath)) {
@@ -80,8 +78,8 @@ class BackupController extends AbstractController
}
$backupId = $backup->getId();
$this->entityManager->remove($backup);
$this->entityManager->flush();
$this->em->remove($backup);
$this->em->flush();
$this->appLogger->record(
'SECURITY_ALERT',
@@ -100,4 +98,15 @@ class BackupController extends AbstractController
return $this->redirectToRoute('app_crm_backup');
}
/**
* Génère le chemin absolu vers le fichier de sauvegarde
*/
private function getBackupFilePath(Backup $backup): string
{
$projectDir = $this->getParameter('kernel.project_dir');
$fileName = sprintf('db_backup_%s.zip', $backup->getCreatedAt()->format('Y-m-d_H-i'));
return $projectDir . '/sauvegarde/' . $fileName;
}
}

View File

@@ -7,12 +7,11 @@ use App\Entity\ContratsLine;
use App\Entity\ContratsOption;
use App\Entity\ContratsPayments;
use App\Entity\Devis;
use App\Entity\Product;
use App\Event\Signature\ContratEvent;
use App\Form\Type\ContratsType;
use App\Logger\AppLogger;
use App\Repository\DevisRepository;
use App\Repository\ContratsRepository;
use App\Repository\DevisRepository;
use App\Service\Mailer\Mailer;
use App\Service\Pdf\ContratPdfService;
use App\Service\Pdf\PlPdf;
@@ -22,76 +21,70 @@ use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
#[Route('/crm/contrats')]
class ContratsController extends AbstractController
{
/**
* Liste des contrats avec pagination et renvoi de mail
*/
#[Route(path: '/crm/contrats', name: 'app_crm_contrats', options: ['sitemap' => false], methods: ['GET'])]
public function contrats(
PaginatorInterface $paginator,
AppLogger $appLogger,
EventDispatcherInterface $eventDispatcher,
public function __construct(
private readonly EntityManagerInterface $em,
private readonly AppLogger $appLogger,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly Client $signatureClient,
private readonly Mailer $mailer,
private readonly KernelInterface $kernel,
) {
}
#[Route('', name: 'app_crm_contrats', options: ['sitemap' => false], methods: ['GET'])]
public function index(
ContratsRepository $contratsRepository,
PaginatorInterface $paginator,
Request $request
): Response {
if ($request->query->has('idSend')) {
$contrat = $contratsRepository->find($request->query->get('idSend'));
if (!$contrat) {
$this->addFlash("danger", "Contrat introuvable.");
return $this->redirectToRoute('app_crm_contrats');
}
$event = new ContratEvent($contrat);
$eventDispatcher->dispatch($event);
$this->addFlash("success", "Le contrat a bien été envoyé à " . $contrat->getCustomer()->getEmail());
$appLogger->record('RESEND', "Renvoi du contrat N°" . $contrat->getNumReservation());
return $this->redirectToRoute('app_crm_contrats');
// Gestion du renvoi de contrat (Action rapide)
if ($idSend = $request->query->get('idSend')) {
return $this->handleResendContract($idSend, $contratsRepository);
}
$appLogger->record('VIEW', 'Consultation de la liste des contrats');
$this->appLogger->record('VIEW', 'Consultation de la liste des contrats');
$query = $contratsRepository->findBy([], ['createAt' => 'DESC']);
$pagination = $paginator->paginate($query, $request->query->getInt('page', 1), 10);
$pagination = $paginator->paginate(
$contratsRepository->findBy([], ['createAt' => 'DESC']),
$request->query->getInt('page', 1),
10
);
return $this->render('dashboard/contrats/list.twig', [
'contrats' => $pagination,
]);
}
/**
* Vue détaillée et gestion des paiements/actions
*/
#[Route(path: '/crm/contrats/view/{id}', name: 'app_crm_contrats_view', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function contratsView(
?Contrats $contrat,
EntityManagerInterface $entityManager,
Request $request,
Client $client,
AppLogger $appLogger,
KernelInterface $kernel,
Mailer $mailer,
): Response {
if (!$contrat) {
throw $this->createNotFoundException('Contrat non trouvé.');
#[Route('/view/{id}', name: 'app_crm_contrats_view', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function view(Contrats $contrat, Request $request): Response
{
// 1. Actions sur la Caution (Libérer / Encaisser)
if ($action = $request->query->get('action')) {
return $this->handleCautionAction($contrat, $action);
}
// --- CALCULS DES MONTANTS ---
// 2. Paiement Manuel (Accompte / Solde / Caution)
if ($type = $request->query->get('type')) {
return $this->handleManualPayment($contrat, $type);
}
// 3. Calculs financiers pour l'affichage
$totalHt = 0;
$totalCaution = 0;
$days = $contrat->getDateAt()->diff($contrat->getEndAt())->days + 1;
foreach ($contrat->getContratsLines() as $line) {
$priceLine = $line->getPrice1DayHt() + ($line->getPriceSupDayHt() * ($days - 1));
$totalHt += $priceLine;
$totalHt += $line->getPrice1DayHt() + ($line->getPriceSupDayHt() * max(0, $days - 1));
$totalCaution += $line->getCaution();
}
@@ -105,185 +98,253 @@ class ContratsController extends AbstractController
$dejaPaye += $p->getAmount();
}
}
$solde = $totalHt - $dejaPaye;
$customer = $contrat->getCustomer();
$customerName = $customer->getSurname() . ' ' . $customer->getName();
// --- TRAITEMENT : ACTIONS SUR CAUTION (Libérer / Encaisser) ---
if ($request->query->has('action')) {
$action = $request->query->get('action');
$paymentCaution = $entityManager->getRepository(ContratsPayments::class)->findOneBy([
'contrat' => $contrat,
'type' => 'caution',
]);
if ($paymentCaution) {
if ($action === 'liberer') {
$paymentCaution->setState("release");
$subject = "[Ludikevent] Votre caution a été libérée - #" . $contrat->getNumReservation();
$template = "mails/customer/caution_release.twig";
$logAction = "Libération caution";
} else {
$paymentCaution->setState("recup");
$subject = "[Ludikevent] Votre caution a été encaissée - #" . $contrat->getNumReservation();
$template = "mails/customer/caution_encaissement.twig";
$logAction = "Encaissement caution";
}
$entityManager->flush();
$mailer->send($customer->getEmail(), $customerName, $subject, $template, [
'datas' => [
'contrat' => $contrat,
'payment' => $paymentCaution,
'customer' => $customer,
'reservationLink' => "https://reservation.ludikevent.fr" . $this->generateUrl('gestion_contrat_view', ['num' => $contrat->getNumReservation()])
]
]);
$appLogger->record('PAYMENT', "$logAction pour #" . $contrat->getNumReservation());
$this->addFlash("success", "Action effectuée avec succès.");
return $this->redirectToRoute('app_crm_contrats_view', ['id' => $contrat->getId()]);
}
}
// --- TRAITEMENT : ENREGISTREMENT PAIEMENT MANUEL ---
if ($request->query->has('type')) {
$type = $request->query->get('type');
$amount = match($type) {
'accompte' => $totalHt * 0.25,
'caution' => $totalCaution,
'solde' => $solde,
default => 0
};
$payment = $entityManager->getRepository(ContratsPayments::class)->findOneBy([
'contrat' => $contrat,
'type' => $type,
]) ?? new ContratsPayments();
$payment->setContrat($contrat);
$payment->setType($type);
$payment->setAmount($amount);
$payment->setState("complete");
$payment->setPaymentAt(new \DateTimeImmutable());
$payment->setValidateAt(new \DateTimeImmutable());
$payment->setUpdateAt(new \DateTimeImmutable());
$payment->setCard(['type' => 'manuel']);
$payment->setPaymentId("MANUAL-" . uniqid());
// Génération PDF et signature
$pdf = new PlPdf($kernel, $payment, $contrat);
$pdf->generate();
$tmpPath = sys_get_temp_dir() . '/pay_' . uniqid() . '.pdf';
file_put_contents($tmpPath, $pdf->Output('S'));
$payment->setPaymentFile(new UploadedFile($tmpPath, "recu-" . $type . ".pdf", "application/pdf", null, true));
$entityManager->persist($payment);
$entityManager->flush();
$signedUrl = $client->autoSignConfirmedPayment($payment);
$tmpSigned = sys_get_temp_dir() . '/signed_' . uniqid() . '.pdf';
file_put_contents($tmpSigned, file_get_contents($signedUrl));
$payment->setPaymentSignedFile(new UploadedFile($tmpSigned, "sign-" . $type . ".pdf", "application/pdf", null, true));
$entityManager->flush();
$mailer->send($customer->getEmail(), $customerName, "[Ludikevent] Confirmation de paiement - " . $type, "mails/customer/accompte_confirmation.twig", [
'datas' => [
'contrat' => $contrat,
'payment' => $payment,
'customer' => $customer,
'reservationLink' => "https://reservation.ludikevent.fr" . $this->generateUrl('gestion_contrat_view', ['num' => $contrat->getNumReservation()])
]
]);
$appLogger->record('PAYMENT', "Validation manuelle $type pour #" . $contrat->getNumReservation());
$this->addFlash("success", "Paiement $type enregistré.");
return $this->redirectToRoute('app_crm_contrats_view', ['id' => $contrat->getId()]);
}
return $this->render('dashboard/contrats/view.twig', [
'contrat' => $contrat,
'days' => $days,
'solde' => $solde,
'solde' => $totalHt - $dejaPaye,
'totalHT' => $totalHt,
'totalCaution' => $totalCaution,
'arrhes' => $totalHt * 0.25,
]);
}
/**
* Création d'un contrat
*/
#[Route(path: '/crm/contrats/add', name: 'app_crm_contrats_create', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function contratsAdd(
EntityManagerInterface $entityManager,
Request $request,
Client $client,
DevisRepository $devisRepository,
AppLogger $appLogger,
KernelInterface $kernel,
): Response {
#[Route('/add', name: 'app_crm_contrats_create', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function add(Request $request, DevisRepository $devisRepository): Response
{
$devis = $devisRepository->find($request->get('idDevis', 0));
$c = new Contrats();
$contrat = new Contrats();
// Logique de pré-remplissage via Devis...
// Pré-remplissage depuis le devis
if ($devis instanceof Devis) {
$c->setDateAt($devis->getStartAt());
$c->setEndAt($devis->getEndAt());
$c->setCustomer($devis->getCustomer());
$c->setDevis($devis);
if ($devis->getAddressShip()) {
$c->setAddressEvent($devis->getAddressShip()->getAddress());
$c->setZipCodeEvent($devis->getAddressShip()->getZipcode());
$c->setTownEvent($devis->getAddressShip()->getCity());
}
$this->hydrateFromDevis($contrat, $devis);
}
$form = $this->createForm(ContratsType::class, $c);
$form = $this->createForm(ContratsType::class, $contrat);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$postData = $request->request->all();
foreach ($postData['lines'] ?? [] as $line) {
$vc = (new ContratsLine())->setContrat($c)->setName($line['name'])->setPrice1DayHt($line['priceHt1Day'])->setPriceSupDayHt($line['priceHtSupDay'])->setCaution($line['caution']);
$entityManager->persist($vc);
}
foreach ($postData['options'] ?? [] as $opt) {
$vo = (new ContratsOption())->setContrat($c)->setName($opt['name'])->setDetails($opt['details'])->setPrice($opt['priceHt']);
$entityManager->persist($vo);
}
$c->setNumReservation($this->generateReservationNumber());
$c->setCreateAt(new \DateTimeImmutable());
// PDFs
foreach ([true, false] as $isDocuseal) {
$service = new ContratPdfService($kernel, $c, $isDocuseal);
$tmp = sys_get_temp_dir() . '/' . uniqid() . '.pdf';
file_put_contents($tmp, $service->generate());
$file = new UploadedFile($tmp, 'doc.pdf', 'application/pdf', null, true);
$isDocuseal ? $c->setDevisDocuSealFile($file) : $c->setDevisFile($file);
}
$entityManager->persist($c);
$entityManager->flush();
$client->createSubmissionContrat($c);
$appLogger->record('CREATE', "Contrat généré : " . $c->getNumReservation());
return $this->redirectToRoute('app_crm_contrats');
return $this->handleCreateContract($contrat, $request);
}
return $this->render('dashboard/contrats/add.twig', [
'devis' => $devis,
'form' => $form->createView(),
'lines' => $lines ?? [],
'options' => $options ?? [],
]);
}
// --- PRIVATE HELPERS ---
private function handleResendContract(int $id, ContratsRepository $repo): RedirectResponse
{
$contrat = $repo->find($id);
if (!$contrat) {
$this->addFlash("danger", "Contrat introuvable.");
return $this->redirectToRoute('app_crm_contrats');
}
$this->eventDispatcher->dispatch(new ContratEvent($contrat));
$this->appLogger->record('RESEND', "Renvoi du contrat N°" . $contrat->getNumReservation());
$this->addFlash("success", "Le contrat a bien été envoyé à " . $contrat->getCustomer()->getEmail());
return $this->redirectToRoute('app_crm_contrats');
}
private function handleCautionAction(Contrats $contrat, string $action): RedirectResponse
{
$paymentCaution = $this->em->getRepository(ContratsPayments::class)->findOneBy([
'contrat' => $contrat,
'type' => 'caution',
]);
if (!$paymentCaution) {
return $this->redirectToRoute('app_crm_contrats_view', ['id' => $contrat->getId()]);
}
$isRelease = ($action === 'liberer');
$newState = $isRelease ? "release" : "recup";
$subject = $isRelease
? "[Ludikevent] Votre caution a été libérée - #" . $contrat->getNumReservation()
: "[Ludikevent] Votre caution a été encaissée - #" . $contrat->getNumReservation();
$template = $isRelease
? "mails/customer/caution_release.twig"
: "mails/customer/caution_encaissement.twig";
$logAction = $isRelease ? "Libération caution" : "Encaissement caution";
$paymentCaution->setState($newState);
$this->em->flush();
$customer = $contrat->getCustomer();
$this->mailer->send(
$customer->getEmail(),
$customer->getSurname() . ' ' . $customer->getName(),
$subject,
$template,
[
'datas' => [
'contrat' => $contrat,
'payment' => $paymentCaution,
'customer' => $customer,
'reservationLink' => "https://reservation.ludikevent.fr" . $this->generateUrl('gestion_contrat_view', ['num' => $contrat->getNumReservation()])
]
]
);
$this->appLogger->record('PAYMENT', "$logAction pour #" . $contrat->getNumReservation());
$this->addFlash("success", "Action effectuée avec succès.");
return $this->redirectToRoute('app_crm_contrats_view', ['id' => $contrat->getId()]);
}
private function handleManualPayment(Contrats $contrat, string $type): RedirectResponse
{
// Calculs basiques pour le montant (à affiner selon vos besoins réels si différent de la vue)
$totalHt = 0;
$totalCaution = 0;
$days = $contrat->getDateAt()->diff($contrat->getEndAt())->days + 1;
foreach ($contrat->getContratsLines() as $line) {
$totalHt += $line->getPrice1DayHt() + ($line->getPriceSupDayHt() * ($days - 1));
$totalCaution += $line->getCaution();
}
foreach ($contrat->getContratsOptions() as $opt) {
$totalHt += $opt->getPrice();
}
// Calcul solde restant
$dejaPaye = 0;
foreach ($contrat->getContratsPayments() as $p) {
if ($p->getState() === 'complete' && $p->getType() !== 'caution') {
$dejaPaye += $p->getAmount();
}
}
$amount = match($type) {
'accompte' => $totalHt * 0.25,
'caution' => $totalCaution,
'solde' => $totalHt - $dejaPaye,
default => 0
};
$payment = $this->em->getRepository(ContratsPayments::class)->findOneBy([
'contrat' => $contrat,
'type' => $type,
]) ?? new ContratsPayments();
$payment->setContrat($contrat)
->setType($type)
->setAmount($amount)
->setState("complete")
->setPaymentAt(new \DateTimeImmutable())
->setValidateAt(new \DateTimeImmutable())
->setUpdateAt(new \DateTimeImmutable())
->setCard(['type' => 'manuel'])
->setPaymentId("MANUAL-" . uniqid());
// Génération PDF Reçu
$pdf = new PlPdf($this->kernel, $payment, $contrat);
$tmpPath = sys_get_temp_dir() . '/pay_' . uniqid() . '.pdf';
file_put_contents($tmpPath, $pdf->generate()); // generate() retourne le contenu ou Output('S')
$payment->setPaymentFile(new UploadedFile($tmpPath, "recu-" . $type . ".pdf", "application/pdf", null, true));
$this->em->persist($payment);
$this->em->flush();
// Signature automatique (si nécessaire pour manuel ?)
$signedUrl = $this->signatureClient->autoSignConfirmedPayment($payment);
if ($signedUrl) {
$tmpSigned = sys_get_temp_dir() . '/signed_' . uniqid() . '.pdf';
file_put_contents($tmpSigned, file_get_contents($signedUrl));
$payment->setPaymentSignedFile(new UploadedFile($tmpSigned, "sign-" . $type . ".pdf", "application/pdf", null, true));
$this->em->flush();
}
// Notification Client
$customer = $contrat->getCustomer();
$this->mailer->send(
$customer->getEmail(),
$customer->getSurname() . ' ' . $customer->getName(),
"[Ludikevent] Confirmation de paiement - " . $type,
"mails/customer/accompte_confirmation.twig",
[
'datas' => [
'contrat' => $contrat,
'payment' => $payment,
'customer' => $customer,
'reservationLink' => "https://reservation.ludikevent.fr" . $this->generateUrl('gestion_contrat_view', ['num' => $contrat->getNumReservation()])
]
]
);
$this->appLogger->record('PAYMENT', "Validation manuelle $type pour #" . $contrat->getNumReservation());
$this->addFlash("success", "Paiement $type enregistré.");
return $this->redirectToRoute('app_crm_contrats_view', ['id' => $contrat->getId()]);
}
private function handleCreateContract(Contrats $contrat, Request $request): RedirectResponse
{
$postData = $request->request->all();
// Traitement manuel des lignes et options car non mappés directement dans le formulaire Symfony
foreach ($postData['lines'] ?? [] as $line) {
$vc = (new ContratsLine())
->setContrat($contrat)
->setName($line['name'])
->setPrice1DayHt($line['priceHt1Day'])
->setPriceSupDayHt($line['priceHtSupDay'])
->setCaution($line['caution']);
$this->em->persist($vc);
}
foreach ($postData['options'] ?? [] as $opt) {
$vo = (new ContratsOption())
->setContrat($contrat)
->setName($opt['name'])
->setDetails($opt['details'])
->setPrice($opt['priceHt']);
$this->em->persist($vo);
}
$contrat->setNumReservation($this->generateReservationNumber());
$contrat->setCreateAt(new \DateTimeImmutable());
// Génération des PDFs (Interne & DocuSeal)
foreach ([true, false] as $isDocuseal) {
$service = new ContratPdfService($this->kernel, $contrat, $isDocuseal);
$tmp = sys_get_temp_dir() . '/' . uniqid() . '.pdf';
file_put_contents($tmp, $service->generate());
$file = new UploadedFile($tmp, 'doc.pdf', 'application/pdf', null, true);
$isDocuseal ? $contrat->setDevisDocuSealFile($file) : $contrat->setDevisFile($file);
}
$this->em->persist($contrat);
$this->em->flush();
// Création de la soumission de signature
$this->signatureClient->createSubmissionContrat($contrat);
$this->appLogger->record('CREATE', "Contrat généré : " . $contrat->getNumReservation());
return $this->redirectToRoute('app_crm_contrats');
}
private function hydrateFromDevis(Contrats $contrat, Devis $devis): void
{
$contrat->setDateAt($devis->getStartAt())
->setEndAt($devis->getEndAt())
->setCustomer($devis->getCustomer())
->setDevis($devis);
if ($address = $devis->getAddressShip()) {
$contrat->setAddressEvent($address->getAddress())
->setZipCodeEvent($address->getZipcode())
->setTownEvent($address->getCity());
}
}
private function generateReservationNumber(): string
{
return 'RESERV-' . date('Ymd') . '-' . substr(str_shuffle('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, 10);

View File

@@ -10,99 +10,95 @@ use App\Form\CustomerType;
use App\Logger\AppLogger;
use App\Repository\CustomerAddressRepository;
use App\Repository\CustomerRepository;
use App\Service\Search\Client;
use App\Service\Stripe\Client as StripeClient;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/crm/customer')]
class CustomerController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly AppLogger $appLogger,
private readonly EntityManagerInterface $entityManager
) {}
private readonly StripeClient $stripeClient,
private readonly CustomerRepository $customerRepository,
private readonly CustomerAddressRepository $addressRepository
) {
}
#[Route(path: '/crm/customer/address/{id}', name: 'app_crm_customer_address', methods: ['GET'])]
public function customerAddress(?Customer $customer)
#[Route('/address/{id}', name: 'app_crm_customer_address', methods: ['GET'])]
public function addressList(Customer $customer): JsonResponse
{
$addressList = [];
foreach ($customer->getCustomerAddresses() as $address) {
$addressList[] = [
'id' => $address->getId(),
'label' => $address->getAddress()." ".$address->getZipcode()." ".$address->getCity(),
'label' => sprintf('%s %s %s', $address->getAddress(), $address->getZipcode(), $address->getCity()),
];
}
return $this->json([
'addressList' => $addressList,
]);
return $this->json(['addressList' => $addressList]);
}
#[Route(path: '/crm/customer', name: 'app_crm_customer', methods: ['GET'])]
public function index(
PaginatorInterface $paginator,
CustomerRepository $customerRepository,
Request $request
): Response {
#[Route('', name: 'app_crm_customer', methods: ['GET'])]
public function index(PaginatorInterface $paginator, Request $request): Response
{
$this->appLogger->record('VIEW', 'Consultation de la liste des clients');
// Utilisation d'un QueryBuilder (recommandé pour KNP) ou findAll
$query = $customerRepository->createQueryBuilder('c')
$query = $this->customerRepository->createQueryBuilder('c')
->orderBy('c.surname', 'ASC')
->getQuery();
$pagination = $paginator->paginate(
$query,
$request->query->getInt('page', 1),
20
);
return $this->render('dashboard/customer.twig', [
'customers' => $pagination,
'customers' => $paginator->paginate($query, $request->query->getInt('page', 1), 20),
]);
}
#[Route(path: '/crm/customer/add', name: 'app_crm_customer_add', methods: ['GET', 'POST'])]
public function add(Request $request, EntityManagerInterface $entityManager,\App\Service\Stripe\Client $client): Response
#[Route('/add', name: 'app_crm_customer_add', methods: ['GET', 'POST'])]
public function add(Request $request): Response
{
$this->appLogger->record('VIEW', 'Consultation de la page de création client');
$customer = new Customer();
$form = $this->createForm(CustomerAddType::class, $customer);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Gestion manuelle de l'adresse initiale (hors formulaire mappé)
$data = $request->request->all('customer_add');
$data= $request->request->all()['customer_add'];
$address = new CustomerAddress();
$address->setCustomer($customer)
->setAddress($data['adresse'] ?? '')
->setCity($data['city'] ?? '')
->setZipcode($data['zipcode'] ?? '')
->setCountry($data['country'] ?? '')
->setAddress2($data['adresse2'] ?? null)
->setAddress3($data['adresse3'] ?? null);
$userAddresse = new CustomerAddress();
$userAddresse->setCustomer($customer);
$userAddresse->setAddress($data['adresse']);
$userAddresse->setCity($data['city']);
$userAddresse->setZipcode($data['zipcode']);
$userAddresse->setCountry($data['country']);
$userAddresse->setAddress2($data['adresse2']);
$userAddresse->setAddress3($data['adresse3']);
$entityManager->persist($userAddresse);
// 1. Sauvegarde en base de données
$entityManager->persist($customer);
$entityManager->flush();
$this->em->persist($address);
$this->em->persist($customer);
$this->em->flush();
// Création Stripe
try {
$this->stripeClient->createCustomer($customer);
} catch (\Exception $e) {
$this->addFlash('warning', 'Erreur création Stripe : ' . $e->getMessage());
}
$client->createCustomer($customer);
// 2. Log de l'action de création
$this->appLogger->record('CREATE', sprintf(
'Nouveau client créé : %s %s (Type: %s)',
$customer->getSurname(),
$customer->getName(),
$customer->getType()
$customer->getSurname(), $customer->getName(), $customer->getType()
));
// 3. Notification flash pour l'utilisateur
$this->addFlash('success', 'Le client a été enregistré avec succès.');
// 4. Redirection vers la liste
return $this->redirectToRoute('app_crm_customer');
}
@@ -111,38 +107,24 @@ class CustomerController extends AbstractController
]);
}
#[Route(path: '/crm/customer/edit/{id}', name: 'app_crm_customer_edit', methods: ['GET', 'POST'])]
public function edit(
int $id,
CustomerRepository $customerRepository,
CustomerAddressRepository $addressRepository, // Injecté pour charger l'adresse
Request $request,
EntityManagerInterface $entityManager,
\App\Service\Stripe\Client $stripeClient,
AppLogger $appLogger
): Response {
$customer = $customerRepository->find($id);
#[Route('/edit/{id}', name: 'app_crm_customer_edit', methods: ['GET', 'POST'])]
public function edit(int $id, Request $request): Response
{
$customer = $this->customerRepository->find($id);
if (!$customer) {
throw $this->createNotFoundException('Client introuvable');
}
// --- LOGIQUE ADRESSE (ADD OU EDIT) ---
// --- 1. GESTION ADRESSE (Ajout / Modification) ---
$idAddr = $request->query->get('idAddr');
if ($idAddr) {
// Mode ÉDITION d'adresse : on cherche l'adresse existante
$address = $addressRepository->findOneBy([
'id' => $idAddr,
'customer' => $customer // Sécurité : on vérifie que l'adresse appartient bien au client
]);
$address = $this->addressRepository->findOneBy(['id' => $idAddr, 'customer' => $customer]);
if (!$address) {
$this->addFlash('error', 'Adresse introuvable ou non associée à ce client.');
return $this->redirectToRoute('app_crm_customer_edit', ['id' => $customer->getId()]);
}
} else {
// Mode AJOUT : nouvelle instance
$address = new CustomerAddress();
$address->setCustomer($customer);
}
@@ -151,88 +133,83 @@ class CustomerController extends AbstractController
$formAddress->handleRequest($request);
if ($formAddress->isSubmitted() && $formAddress->isValid()) {
// Si c'est une nouvelle adresse, on doit faire persist
if (!$address->getId()) {
$entityManager->persist($address);
$logAction = 'Ajout';
} else {
$logAction = 'Modification';
$isNew = !$address->getId();
if ($isNew) {
$this->em->persist($address);
}
$this->em->flush();
$entityManager->flush();
$this->appLogger->record($isNew ? 'CREATE' : 'EDIT', sprintf(
'%s adresse pour le client : %s',
$isNew ? 'Ajout' : 'Modification',
$customer->getName()
));
$appLogger->record($idAddr ? 'EDIT' : 'CREATE', sprintf('%s adresse pour le client : %s', $logAction, $customer->getName()));
$this->addFlash('success', sprintf('L\'adresse a été %s avec succès.', $idAddr ? 'modifiée' : 'ajoutée'));
// On redirige vers la fiche sans le paramètre idAddr pour nettoyer l'URL
$this->addFlash('success', sprintf('L\'adresse a été %s avec succès.', $isNew ? 'ajoutée' : 'modifiée'));
return $this->redirectToRoute('app_crm_customer_edit', ['id' => $customer->getId()]);
}
// --- LOGIQUE FORMULAIRE CLIENT (Reste identique) ---
// --- 2. GESTION CLIENT ---
$form = $this->createForm(CustomerType::class, $customer);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
if ($customer->getCustomerId()) {
try {
$stripeClient->updateCustomer($customer);
$this->stripeClient->updateCustomer($customer);
} catch (\Exception $e) {
$this->addFlash('warning', 'Erreur synchro Stripe : ' . $e->getMessage());
}
}
$entityManager->flush();
$appLogger->record('EDIT', sprintf('Modification du client : %s %s', $customer->getSurname(), $customer->getName()));
$this->em->flush();
$this->appLogger->record('EDIT', sprintf('Modification du client : %s %s', $customer->getSurname(), $customer->getName()));
$this->addFlash('success', 'Les informations du client ont été mises à jour.');
return $this->redirectToRoute('app_crm_customer_edit', ['id' => $customer->getId()]);
}
if ($request->isMethod('GET')) {
$appLogger->record('VIEW', sprintf('Consultation de la fiche client : %s', $customer->getName()));
if ($request->isMethod('GET') && !$idAddr) {
$this->appLogger->record('VIEW', sprintf('Consultation de la fiche client : %s', $customer->getName()));
}
return $this->render('dashboard/customer/show.twig', [
'customer' => $customer,
'formAddress' => $formAddress->createView(),
'form' => $form->createView(),
'editingAddress' => (bool)$idAddr // Pour changer le texte du bouton dans le Twig
'editingAddress' => (bool)$idAddr
]);
}
#[Route(path: '/crm/customer/delete/{id}', name: 'app_crm_customer_delete', methods: ['GET', 'POST'])]
public function delete(
int $id,
CustomerRepository $customerRepository,
Request $request,
\App\Service\Stripe\Client $client,
AppLogger $appLogger,
EntityManagerInterface $entityManager
): Response {
$customer = $customerRepository->find($id);
#[Route('/delete/{id}', name: 'app_crm_customer_delete', methods: ['GET', 'POST'])]
public function delete(int $id, Request $request): RedirectResponse
{
$customer = $this->customerRepository->find($id);
if (!$customer) {
$this->addFlash('error', 'Le client demandé n\'existe pas.');
return $this->redirectToRoute('app_crm_customer');
}
// Récupération du token CSRF envoyé via data-turbo-method (dans l'URL)
$token = $request->query->get('_token');
if ($this->isCsrfTokenValid('delete' . $customer->getId(), $token)) {
if ($this->isCsrfTokenValid('delete' . $customer->getId(), $request->query->get('_token'))) {
$name = sprintf('%s %s', $customer->getSurname(), $customer->getName());
// Log de l'action de suppression (avant suppression réelle)
$appLogger->record('DELETE', sprintf('Suppression définitive du client : %s %s', $customer->getSurname(), $customer->getName()));
// Suppression sur Stripe si l'ID existe
// Suppression Stripe
if ($customer->getCustomerId()) {
$client->deleteCustomer($customer->getCustomerId());
try {
$this->stripeClient->deleteCustomer($customer->getCustomerId());
} catch (\Exception $e) {
$this->appLogger->record('ERROR', "Erreur suppression Stripe : " . $e->getMessage());
}
}
$entityManager->remove($customer);
$entityManager->flush();
$this->em->remove($customer);
$this->em->flush();
$this->addFlash('success', sprintf('Le client %s %s a été supprimé définitivement.', $customer->getSurname(), $customer->getName()));
$this->appLogger->record('DELETE', "Suppression définitive du client : $name");
$this->addFlash('success', "Le client $name a été supprimé définitivement.");
} else {
$this->addFlash('error', 'Jeton de sécurité invalide. La suppression a été annulée.');
$this->addFlash('error', 'Jeton de sécurité invalide.');
}
return $this->redirectToRoute('app_crm_customer');

View File

@@ -2,15 +2,12 @@
namespace App\Controller\Dashboard;
use App\Entity\CustomerAddress;
use App\Entity\Devis;
use App\Entity\DevisLine;
use App\Entity\DevisOptions;
use App\Entity\Product;
use App\Event\Signature\DevisSend;
use App\Form\NewDevisType;
use App\Logger\AppLogger;
use App\Repository\AccountRepository;
use App\Repository\CustomerAddressRepository;
use App\Repository\CustomerRepository;
use App\Repository\DevisLineRepository;
@@ -22,61 +19,44 @@ use App\Service\Pdf\DevisPdfService;
use App\Service\Signature\Client;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Bundle\PaginatorBundle\KnpPaginatorBundle;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\HttpFoundation\File\File;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Routing\Attribute\Route;
#[Route('/crm/devis')]
class DevisController extends AbstractController
{
/**
* Liste des administrateurs
*/
#[Route(path: '/crm/devis', name: 'app_crm_devis', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function devis(
EventDispatcherInterface $eventDispatcher,
EntityManagerInterface $entityManager,
DevisRepository $devisRepository,
AppLogger $appLogger,
PaginatorInterface $paginator,
Request $request,
KernelInterface $kernel,
): Response
public function __construct(
private readonly EntityManagerInterface $em,
private readonly AppLogger $appLogger,
private readonly EventDispatcherInterface $eventDispatcher,
private readonly Client $signatureClient,
private readonly KernelInterface $kernel,
private readonly DevisRepository $devisRepository,
private readonly ProductRepository $productRepository,
private readonly CustomerAddressRepository $customerAddressRepository,
private readonly CustomerRepository $customerRepository
) {
}
#[Route('', name: 'app_crm_devis', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function index(PaginatorInterface $paginator, Request $request): Response
{
// Gestion du renvoi de la signature
if ($request->query->has('resend')) {
$quoteId = $request->query->get('resend');
$quote = $devisRepository->find($quoteId);
if ($quote instanceof Devis) {
$quote->setState("created_waitsign");
$entityManager->persist($quote);
$entityManager->flush();
// Déclenchement de l'événement de renvoi
$event = new DevisSend($quote);
$eventDispatcher->dispatch($event);
// Journalisation et notification
$appLogger->record('RESEND', 'Relance signature pour le devis ' . $quote->getNum());
$this->addFlash("success", "Le lien de signature pour le devis " . $quote->getNum() . " a été renvoyé au client.");
return $this->redirectToRoute('app_crm_devis');
}
$this->addFlash("error", "Devis introuvable.");
// Gestion de la relance (Action Rapide)
if ($resendId = $request->query->get('resend')) {
return $this->handleResend($resendId);
}
$appLogger->record('VIEW', 'Consultation de la liste des devis');
$this->appLogger->record('VIEW', 'Consultation de la liste des devis');
// Pagination (Tri décroissant sur la date de création pour voir les plus récents en premier)
$pagination = $paginator->paginate(
$devisRepository->findBy([], ['createA' => 'DESC']),
$this->devisRepository->findBy([], ['createA' => 'DESC']),
$request->query->getInt('page', 1),
20
);
@@ -86,226 +66,86 @@ class DevisController extends AbstractController
]);
}
#[Route(path: '/crm/devis/delete/{id}', name: 'app_crm_devis_delete', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function devisDelete(string $id, AppLogger $appLogger, Client $client, Request $request, DevisRepository $devisRepository, EntityManagerInterface $entityManager): Response
#[Route('/add', name: 'app_crm_devis_add', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function add(Request $request, OptionsRepository $optionsRepository): Response
{
$customer = $devisRepository->find($id);
if (!$customer) {
$this->addFlash('error', 'Le devis demandé n\'existe pas.');
return $this->redirectToRoute('app_crm_devis');
}
$token = $request->query->get('_token');
if ($this->isCsrfTokenValid('delete' . $customer->getId(), $token)) {
$appLogger->record('DELETE', sprintf('Suppression définitive du devis : %s', $customer->getNum()));
if ($customer->getSignatureId() != "") {
$client->cancelSign($customer->getSignatureId());
}
foreach ($customer->getDevisLines() as $devisLine) {
$entityManager->remove($devisLine);
}
foreach ($customer->getDevisOptions() as $devisOption) {
$entityManager->remove($devisOption);
}
$entityManager->remove($customer);
$entityManager->flush();
$this->addFlash('success', sprintf('Le devis %s a été supprimé définitivement.', $customer->getNum()));
} else {
$this->addFlash('error', 'Jeton de sécurité invalide. La suppression a été annulée.');
}
return $this->redirectToRoute('app_crm_devis');
}
#[Route(path: '/crm/devis/add', name: 'app_crm_devis_add', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function devisAdd(Client $client, OptionsRepository $optionsRepository, EventDispatcherInterface $eventDispatcher, KernelInterface $kernel, CustomerAddressRepository $customerAddress, ProductRepository $productRepository, EntityManagerInterface $entityManager, CustomerRepository $customerRepository, DevisRepository $devisRepository, AppLogger $appLogger, Request $request): Response
{
$devisNumber = "DEVIS-" . sprintf('%05d', $devisRepository->count() + 1);
$appLogger->record('VIEW', 'Consultation de la création d\'un devis');
$devisNumber = "DEVIS-" . sprintf('%05d', $this->devisRepository->count() + 1);
$this->appLogger->record('VIEW', 'Consultation de la création d\'un devis');
$devis = new Devis();
$devis->setNum($devisNumber);
$devis->setState("draft");
$devis->setCreateA(new \DateTimeImmutable());
$devis->setUpdateAt(new \DateTimeImmutable());
$devis->setNum($devisNumber)
->setState("draft")
->setCreateA(new \DateTimeImmutable())
->setUpdateAt(new \DateTimeImmutable());
$form = $this->createForm(NewDevisType::class, $devis);
$form->handleRequest($request);
if ($request->isMethod('POST')) {
$this->processDevisForm($devis, $request->request->all());
$devis->setStartAt(new DateTimeImmutable($_POST['new_devis']['startAt']));
$devis->setEndAt(new DateTimeImmutable($_POST['new_devis']['endAt']));
$this->generateAndSavePdfs($devis);
$interval = $devis->getStartAt()->diff($devis->getEndAt());
$day = $interval->days;
$devis->setBillAddress($customerAddress->find($_POST['devis']['bill_address']));
$devis->setAddressShip($customerAddress->find($_POST['devis']['ship_address']));
$devis->setCustomer($customerRepository->find($_POST['new_devis']['customer']));
foreach ($_POST['lines'] as $cd => $line) {
$rLine = new DevisLine();
$rLine->setDevi($devis);
$rLine->setPos($cd);
$rLine->setProduct($line['product']);
$rLine->setDay($day);
$rLine->setPriceHt(floatval($line['price_ht']));
$rLine->setPriceHtSup(floatval($line['price_sup_ht']));
$entityManager->persist($rLine);
}
foreach ($_POST['options'] as $line) {
$rLineOptions = new DevisOptions();
$rLineOptions->setDevis($devis);
$rLineOptions->setOption($line['product']);
$rLineOptions->setPriceHt(floatval($line['price_ht']));
$entityManager->persist($rLineOptions);
}
$entityManager->persist($devis);
$entityManager->flush();
$devis->setState("wait-send")
->setUpdateAt(new \DateTimeImmutable());
$docusealService = new DevisPdfService($kernel, $devis, $entityManager->getRepository(Product::class),true);
$contentDocuseal = $docusealService->generate();
$this->em->persist($devis);
$this->em->flush();
$this->signatureClient->createSubmissionDevis($devis);
$tmpPathDocuseal = sys_get_temp_dir() . '/docuseal_' . uniqid() . '.pdf';
file_put_contents($tmpPathDocuseal, $contentDocuseal);
$fileDocuseal = new UploadedFile($tmpPathDocuseal, 'dc_' . $devis->getNum() . '.pdf', 'application/pdf', 0, true);
$devis->setDevisDocuSealFile($fileDocuseal);
$devisService = new DevisPdfService($kernel, $devis, $entityManager->getRepository(Product::class), false);
$contentDevis = $devisService->generate();
$tmpPathDevis = sys_get_temp_dir() . '/devis_' . uniqid() . '.pdf';
file_put_contents($tmpPathDevis, $contentDevis);
$fileDevis = new UploadedFile($tmpPathDevis, 'devis_' . $devis->getNum() . '.pdf', 'application/pdf', 0, true);
$devis->setDevisFile($fileDevis);
$devis->setState("wait-send");
$devis->setUpdateAt(new \DateTimeImmutable());
$entityManager->flush();
$client->createSubmissionDevis($devis);
$this->addFlash('success', sprintf('Le devis %s a été crée.', $devis->getNum()));
$this->addFlash('success', sprintf('Le devis %s a été créé.', $devis->getNum()));
return $this->redirectToRoute('app_crm_devis');
}
return $this->render('dashboard/devis/add.twig', [
'form' => $form->createView(),
'lines' => [
[
'product' => '',
'days'=>'',
'price_ht' => '',
'price_sup_ht' =>''
],
],
'options' => [
[
'product' => '',
'details' => '',
'price_ht' => '',
]
]
'lines' => [['product' => '', 'days' => '', 'price_ht' => '', 'price_sup_ht' => '']],
'options' => [['product' => '', 'details' => '', 'price_ht' => '']],
// Variables nécessaires pour la vue d'ajout si elle utilise les mêmes blocks que edit
'products' => $this->productRepository->findAll(),
'optionsList' => $optionsRepository->findAll(),
'shipAddress' => [],
'billAddress' => [],
'devis' => $devis
]);
}
#[Route(path: '/crm/devis/{id}', name: 'app_crm_devis_edit', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function devisEdit(?Devis $devis,Client $client,DevisLineRepository $devisLineRepository,DevisOptionsRepository $devisOptionsRepository, OptionsRepository $optionsRepository, EventDispatcherInterface $eventDispatcher, KernelInterface $kernel, CustomerAddressRepository $customerAddress, ProductRepository $productRepository, EntityManagerInterface $entityManager, CustomerRepository $customerRepository, DevisRepository $devisRepository, AppLogger $appLogger, Request $request): Response
#[Route('/{id}', name: 'app_crm_devis_edit', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function edit(
Devis $devis,
Request $request,
DevisLineRepository $devisLineRepository,
DevisOptionsRepository $devisOptionsRepository,
OptionsRepository $optionsRepository
): Response
{
$appLogger->record('VIEW', 'Consultation pour modifier le devis '.$devis->getNum());
$this->appLogger->record('VIEW', 'Consultation pour modifier le devis ' . $devis->getNum());
$form = $this->createForm(NewDevisType::class, $devis);
$form->handleRequest($request);
if ($request->isMethod('POST')) {
$devis->setStartAt(new DateTimeImmutable($_POST['new_devis']['startAt']));
$devis->setEndAt(new DateTimeImmutable($_POST['new_devis']['endAt']));
$devis->setBillAddress($customerAddress->find($_POST['devis']['bill_address']));
$devis->setAddressShip($customerAddress->find($_POST['devis']['ship_address']));
$devis->setCustomer($customerRepository->find($_POST['new_devis']['customer']));
$interval = $devis->getStartAt()->diff($devis->getEndAt());
$day = $interval->days;
foreach ($_POST['lines'] as $cd => $line) {
if($line['id'] != "") {
$rLine = $devisLineRepository->find($line['id']);
} else {
$rLine = new DevisLine();
$rLine->setDevi($devis);
$rLine->setPos($cd);
}
$rLine->setDay($day);
$rLine->setProduct($line['product']);
$rLine->setPriceHt(floatval($line['price_ht']));
$rLine->setPriceHtSup(floatval($line['price_sup_ht']));
$entityManager->persist($rLine);
}
$this->processDevisForm($devis, $request->request->all(), $devisLineRepository, $devisOptionsRepository);
foreach ($_POST['options'] as $line) {
if($line['id'] != "") {
$rLineOptions = $devisOptionsRepository->find($line['id']);
} else {
$rLineOptions = new DevisOptions();
$rLineOptions->setDevis($devis);
}
$rLineOptions->setOption($line['product']);
$rLineOptions->setDetails($line['details']);
$rLineOptions->setPriceHt(floatval($line['price_ht']));
$entityManager->persist($rLineOptions);
}
$entityManager->persist($devis);
$entityManager->flush();
$this->generateAndSavePdfs($devis);
$docusealService = new DevisPdfService($kernel, $devis, $entityManager->getRepository(Product::class),true);
$contentDocuseal = $docusealService->generate();
$devis->setUpdateAt(new \DateTimeImmutable())
->setState("wait-send");
$this->em->persist($devis);
$this->em->flush();
$tmpPathDocuseal = sys_get_temp_dir() . '/docuseal_' . uniqid() . '.pdf';
file_put_contents($tmpPathDocuseal, $contentDocuseal);
$fileDocuseal = new UploadedFile($tmpPathDocuseal, 'dc_' . $devis->getNum() . '.pdf', 'application/pdf', 0, true);
$devis->setDevisDocuSealFile($fileDocuseal);
$devisService = new DevisPdfService($kernel, $devis, $entityManager->getRepository(Product::class),false);
$contentDevis = $devisService->generate();
$tmpPathDevis = sys_get_temp_dir() . '/devis_' . uniqid() . '.pdf';
file_put_contents($tmpPathDevis, $contentDevis);
$fileDevis = new UploadedFile($tmpPathDevis, 'devis_' . $devis->getNum() . '.pdf', 'application/pdf', 0, true);
$devis->setDevisFile($fileDevis);
$devis->setUpdateAt(new \DateTimeImmutable());
$devis->setState("wait-send");
$entityManager->flush();
$client->createSubmissionDevis($devis);
$this->addFlash('success', sprintf('Le devis %s a été modifiée.', $devis->getNum()));
$this->signatureClient->createSubmissionDevis($devis);
$this->addFlash('success', sprintf('Le devis %s a été modifié.', $devis->getNum()));
return $this->redirectToRoute('app_crm_devis');
}
$lines =[
[
'id' => '',
'product' => '',
'days'=>'',
'price_ht' => '',
'price_sup_ht' =>''
]
];
$options = [
[
'id' => '',
'product' => '',
'details' => '',
'price_ht' => '',
]
];
$shipAddress = [];
$billAddress =[];
foreach ($devis->getDevisLines() as $key => $line) {
$lines[$key] = [
// Préparation des données pour la vue
$lines = [];
foreach ($devis->getDevisLines() as $line) {
$lines[] = [
'id' => $line->getId(),
'product' => $line->getProduct(),
'days' => $line->getDay(),
@@ -313,28 +153,184 @@ class DevisController extends AbstractController
'price_sup_ht' => $line->getPriceHtSup()
];
}
foreach ($devis->getDevisOptions() as $key => $line) {
$options[$key] = [
if (empty($lines)) $lines[] = ['id' => '', 'product' => '', 'days' => '', 'price_ht' => '', 'price_sup_ht' => ''];
$options = [];
foreach ($devis->getDevisOptions() as $line) {
$options[] = [
'id' => $line->getId(),
'details' => $line->getDetails(),
'product' => $line->getOption(),
'price_ht' => $line->getPriceHt(),
];
}
if (empty($options)) $options[] = ['id' => '', 'product' => '', 'details' => '', 'price_ht' => ''];
foreach ($devis->getCustomer()->getCustomerAddresses() as $customerAddress) {
$shipAddress[$customerAddress->getId()] = $customerAddress->getAddress()." ".$customerAddress->getZipcode()." ".$customerAddress->getCity();
$billAddress[$customerAddress->getId()] = $customerAddress->getAddress()." ".$customerAddress->getZipcode()." ".$customerAddress->getCity();
$shipAddress = [];
$billAddress = [];
if ($customer = $devis->getCustomer()) {
foreach ($customer->getCustomerAddresses() as $addr) {
$label = $addr->getAddress() . " " . $addr->getZipcode() . " " . $addr->getCity();
$shipAddress[$addr->getId()] = $label;
$billAddress[$addr->getId()] = $label;
}
}
return $this->render('dashboard/devis/add.twig', [
'form' => $form->createView(),
'devis' => $devis,
'lines' => $lines,
'options' => $options,
'products' => $productRepository->findAll(),
'products' => $this->productRepository->findAll(),
'optionsList' => $optionsRepository->findAll(),
'shipAddress' => $shipAddress,
'billAddress' => $billAddress,
]);
}
#[Route('/delete/{id}', name: 'app_crm_devis_delete', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function delete(Devis $devis, Request $request): RedirectResponse
{
if ($this->isCsrfTokenValid('delete' . $devis->getId(), $request->query->get('_token'))) {
$this->appLogger->record('DELETE', sprintf('Suppression définitive du devis : %s', $devis->getNum()));
if ($devis->getSignatureId()) {
$this->signatureClient->cancelSign($devis->getSignatureId());
}
// Suppression manuelle des lignes et options pour éviter les orphelins si cascade non configurée
foreach ($devis->getDevisLines() as $line) $this->em->remove($line);
foreach ($devis->getDevisOptions() as $opt) $this->em->remove($opt);
$this->em->remove($devis);
$this->em->flush();
$this->addFlash('success', sprintf('Le devis %s a été supprimé définitivement.', $devis->getNum()));
} else {
$this->addFlash('error', 'Jeton de sécurité invalide. La suppression a été annulée.');
}
return $this->redirectToRoute('app_crm_devis');
}
// --- PRIVATE HELPERS ---
private function handleResend(string $id): RedirectResponse
{
$quote = $this->devisRepository->find($id);
if (!$quote) {
$this->addFlash("error", "Devis introuvable.");
return $this->redirectToRoute('app_crm_devis');
}
$quote->setState("created_waitsign");
$this->em->flush();
$this->eventDispatcher->dispatch(new DevisSend($quote));
$this->appLogger->record('RESEND', 'Relance signature pour le devis ' . $quote->getNum());
$this->addFlash("success", "Le lien de signature pour le devis " . $quote->getNum() . " a été renvoyé au client.");
return $this->redirectToRoute('app_crm_devis');
}
/**
* Traite les données brutes POST pour hydrater le Devis (Lignes, Options, Dates, Adresses)
*/
private function processDevisForm(
Devis $devis,
array $postData,
?DevisLineRepository $lineRepo = null,
?DevisOptionsRepository $optRepo = null
): void
{
$formData = $postData['new_devis'] ?? [];
$devisData = $postData['devis'] ?? [];
// 1. Champs de base
if (!empty($formData['startAt'])) $devis->setStartAt(new DateTimeImmutable($formData['startAt']));
if (!empty($formData['endAt'])) $devis->setEndAt(new DateTimeImmutable($formData['endAt']));
if (!empty($devisData['bill_address'])) $devis->setBillAddress($this->customerAddressRepository->find($devisData['bill_address']));
if (!empty($devisData['ship_address'])) $devis->setAddressShip($this->customerAddressRepository->find($devisData['ship_address']));
if (!empty($formData['customer'])) $devis->setCustomer($this->customerRepository->find($formData['customer']));
// Calcul durée
$day = 1;
if ($devis->getStartAt() && $devis->getEndAt()) {
$interval = $devis->getStartAt()->diff($devis->getEndAt());
$day = $interval->days ?: 1;
}
// 2. Lignes Produits
if (!empty($postData['lines']) && is_array($postData['lines'])) {
foreach ($postData['lines'] as $cd => $lineData) {
if (empty($lineData['product'])) continue;
$line = null;
if ($lineRepo && !empty($lineData['id'])) {
$line = $lineRepo->find($lineData['id']);
}
if (!$line) {
$line = new DevisLine();
$line->setDevi($devis);
$line->setPos($cd);
}
$line->setDay($day)
->setProduct($lineData['product'])
->setPriceHt(floatval($lineData['price_ht'] ?? 0))
->setPriceHtSup(floatval($lineData['price_sup_ht'] ?? 0));
$this->em->persist($line);
}
}
// 3. Lignes Options
if (!empty($postData['options']) && is_array($postData['options'])) {
foreach ($postData['options'] as $optData) {
if (empty($optData['product'])) continue;
$opt = null;
if ($optRepo && !empty($optData['id'])) {
$opt = $optRepo->find($optData['id']);
}
if (!$opt) {
$opt = new DevisOptions();
$opt->setDevis($devis);
}
$opt->setOption($optData['product'])
->setDetails($optData['details'] ?? '')
->setPriceHt(floatval($optData['price_ht'] ?? 0));
$this->em->persist($opt);
}
}
$this->em->persist($devis);
}
private function generateAndSavePdfs(Devis $devis): void
{
// 1. DocuSeal (Version pour signature)
$docusealService = new DevisPdfService($this->kernel, $devis, $this->productRepository, true);
$this->savePdfFile($devis, $docusealService->generate(), 'dc_', 'setDevisDocuSealFile');
// 2. Interne (Version simple)
$devisService = new DevisPdfService($this->kernel, $devis, $this->productRepository, false);
$this->savePdfFile($devis, $devisService->generate(), 'devis_', 'setDevisFile');
}
private function savePdfFile(Devis $devis, string $content, string $prefix, string $setterMethod): void
{
$tmpPath = sys_get_temp_dir() . '/' . $prefix . uniqid() . '.pdf';
file_put_contents($tmpPath, $content);
$file = new UploadedFile($tmpPath, $prefix . $devis->getNum() . '.pdf', 'application/pdf', null, true);
$devis->$setterMethod($file);
}
}

View File

@@ -2,14 +2,14 @@
namespace App\Controller\Dashboard;
use App\Entity\ContratsPayments;
use App\Logger\AppLogger;
use App\Repository\ContratsPaymentsRepository;
use Doctrine\ORM\QueryBuilder;
use Knp\Component\Pager\PaginatorInterface;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Style\Color;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -17,39 +17,25 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
use Vich\UploaderBundle\Templating\Helper\UploaderHelper;
#[Route('/crm/facture')]
class FactureController extends AbstractController
{
#[Route(path: '/crm/facture', name: 'app_crm_facture', methods: ['GET'])]
public function index(
Request $request,
ContratsPaymentsRepository $contratsPaymentsRepo,
AppLogger $appLogger,
UploaderHelper $uploaderHelper,
PaginatorInterface $paginator
): Response {
public function __construct(
private readonly ContratsPaymentsRepository $contratsPaymentsRepo,
private readonly AppLogger $appLogger,
private readonly UploaderHelper $uploaderHelper
) {
}
// 1. Gestion des dates par défaut (Début et Fin du mois en cours)
$startDateStr = $request->query->get('startDate');
$endDateStr = $request->query->get('endDate');
#[Route('', name: 'app_crm_facture', methods: ['GET'])]
public function index(Request $request, PaginatorInterface $paginator): Response
{
// 1. Récupération des dates et filtres
[$startDate, $endDate] = $this->getFilterDates($request);
$searchTerm = $request->query->get('q', '');
if (!$startDateStr) {
$startDate = new \DateTime('first day of this month 00:00:00');
$startDateStr = $startDate->format('Y-m-d');
} else {
$startDate = new \DateTime($startDateStr . ' 00:00:00');
}
if (!$endDateStr) {
$endDate = new \DateTime('last day of this month 23:59:59');
$endDateStr = $endDate->format('Y-m-d');
} else {
$endDate = new \DateTime($endDateStr . ' 23:59:59');
}
// 2. Construction de la requête de recherche (QueryBuilder)
// On récupère une instance de QueryBuilder pour la pagination ou l'export
$queryBuilder = $contratsPaymentsRepo->createQueryBuilder('p')
// 2. Construction de la requête
$queryBuilder = $this->contratsPaymentsRepo->createQueryBuilder('p')
->leftJoin('p.contrat', 'c')
->leftJoin('c.customer', 'u')
->where('p.paymentAt BETWEEN :start AND :end')
@@ -62,56 +48,67 @@ class FactureController extends AbstractController
->setParameter('q', '%' . $searchTerm . '%');
}
// 3. Gestion de l'extraction Excel
// 3. Export Excel (Action Rapide)
if ($request->query->has('extract')) {
// Pour l'export, on récupère tous les résultats filtrés sans pagination
$allFilteredPayments = $queryBuilder->getQuery()->getResult();
// On ne garde que les paiements complétés pour l'export comptable
$dataToExport = array_filter($allFilteredPayments, function($p) {
return $p->getState() === 'complete';
});
if (empty($dataToExport)) {
$this->addFlash('warning', 'Aucune donnée validée à exporter pour cette période.');
return $this->redirectToRoute('app_crm_facture', [
'startDate' => $startDateStr,
'endDate' => $endDateStr,
'q' => $searchTerm
]);
}
$appLogger->record('EXPORT_EXCEL', "Export des factures du {$startDateStr} au {$endDateStr}");
return $this->generateExcelExport(
$dataToExport,
$uploaderHelper,
$request->getSchemeAndHttpHost(),
$startDateStr,
$endDateStr
);
return $this->handleExcelExport($queryBuilder, $request, $startDate, $endDate);
}
// 4. Pagination des résultats pour l'affichage Web
$pagination = $paginator->paginate(
$queryBuilder, // Query object, pas le résultat final
$request->query->getInt('page', 1), // Numéro de page
15 // Nombre d'éléments par page
);
// 4. Affichage standard
// (Log optionnel ici si vous souhaitez logger chaque consultation)
return $this->render('dashboard/contrats/facture.twig', [
'pagination' => $pagination,
'startDate' => $startDateStr,
'endDate' => $endDateStr,
'pagination' => $paginator->paginate($queryBuilder, $request->query->getInt('page', 1), 15),
'startDate' => $startDate->format('Y-m-d'),
'endDate' => $endDate->format('Y-m-d'),
'searchTerm' => $searchTerm,
'active' => $request->query->get('active', 'facture'),
]);
}
// --- PRIVATE HELPERS ---
/**
* Génère un fichier Excel XLSX formaté
* Retourne [Start DateTime, End DateTime] basé sur la requête ou les valeurs par défaut
*/
private function generateExcelExport(array $payments, UploaderHelper $uploaderHelper, string $host, string $start, string $end): StreamedResponse
private function getFilterDates(Request $request): array
{
$startStr = $request->query->get('startDate');
$endStr = $request->query->get('endDate');
$start = $startStr ? new \DateTime($startStr . ' 00:00:00') : new \DateTime('first day of this month 00:00:00');
$end = $endStr ? new \DateTime($endStr . ' 23:59:59') : new \DateTime('last day of this month 23:59:59');
return [$start, $end];
}
/**
* Gère la logique de récupération des données et déclenche la génération du stream Excel
*/
private function handleExcelExport(QueryBuilder $qb, Request $request, \DateTime $start, \DateTime $end): Response
{
$payments = $qb->getQuery()->getResult();
// On garde uniquement les paiements validés pour la compta
$dataToExport = array_filter($payments, fn($p) => $p->getState() === 'complete');
if (empty($dataToExport)) {
$this->addFlash('warning', 'Aucune donnée validée à exporter pour cette période.');
return $this->redirectToRoute('app_crm_facture', [
'startDate' => $start->format('Y-m-d'),
'endDate' => $end->format('Y-m-d'),
'q' => $request->query->get('q')
]);
}
$this->appLogger->record('EXPORT_EXCEL', sprintf("Export des factures du %s au %s", $start->format('Y-m-d'), $end->format('Y-m-d')));
return $this->generateExcelStream($dataToExport, $request->getSchemeAndHttpHost(), $start->format('Y-m-d'), $end->format('Y-m-d'));
}
/**
* Génère le fichier Excel physique via StreamedResponse
*/
private function generateExcelStream(array $payments, string $host, string $startStr, string $endStr): StreamedResponse
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
@@ -130,25 +127,26 @@ class FactureController extends AbstractController
foreach ($headers as $cell => $label) {
$sheet->setCellValue($cell, $label);
$sheet->getStyle($cell)->getFont()->setBold(true)->setColor(new Color('FFFFFF'));
$sheet->getStyle($cell)->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('4F46E5');
$style = $sheet->getStyle($cell);
$style->getFont()->setBold(true)->setColor(new Color('FFFFFF'));
$style->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('4F46E5');
}
$row = 2;
foreach ($payments as $payment) {
$contrat = $payment->getContrat();
$customer = $contrat ? $contrat->getCustomer() : null;
$customer = $contrat?->getCustomer();
$sheet->setCellValue('A' . $row, $payment->getPaymentAt() ? $payment->getPaymentAt()->format('d/m/Y H:i') : '');
$sheet->setCellValue('B' . $row, $customer ? strtoupper((string)$customer->getName()) . ' ' . $customer->getSurname() : 'Inconnu');
$sheet->setCellValue('C' . $row, $contrat ? $contrat->getNumReservation() : 'N/A');
$sheet->setCellValue('D' . $row, strtoupper((string)$payment->getType()));
$sheet->setCellValue('A' . $row, $payment->getPaymentAt()?->format('d/m/Y H:i') ?? '');
$sheet->setCellValue('B' . $row, $customer ? strtoupper($customer->getName()) . ' ' . $customer->getSurname() : 'Inconnu');
$sheet->setCellValue('C' . $row, $contrat?->getNumReservation() ?? 'N/A');
$sheet->setCellValue('D' . $row, strtoupper($payment->getType()));
$sheet->setCellValue('E' . $row, $payment->getAmount());
$sheet->getStyle('E' . $row)->getNumberFormat()->setFormatCode('#,##0.00" €"');
$sheet->setCellValue('F' . $row, $payment->getState());
// Gestion du lien vers le fichier justificatif (VichUploader)
$assetPath = $uploaderHelper->asset($payment, 'paymentFile');
// Lien VichUploader
$assetPath = $this->uploaderHelper->asset($payment, 'paymentFile');
if ($assetPath) {
$sheet->setCellValue('G' . $row, 'Voir le document');
$sheet->getCell('G' . $row)->getHyperlink()->setUrl($host . $assetPath);
@@ -160,17 +158,16 @@ class FactureController extends AbstractController
$row++;
}
// Auto-size des colonnes
foreach (range('A', 'G') as $col) {
$sheet->getColumnDimension($col)->setAutoSize(true);
}
$writer = new Xlsx($spreadsheet);
$response = new StreamedResponse(function() use ($writer) {
$response = new StreamedResponse(function () use ($writer) {
$writer->save('php://output');
});
$filename = "compta_export_{$start}_au_{$end}.xlsx";
$filename = sprintf("compta_export_%s_au_%s.xlsx", $startStr, $endStr);
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', 'attachment;filename="' . $filename . '"');
$response->headers->set('Cache-Control', 'max-age=0');

View File

@@ -6,299 +6,341 @@ use App\Entity\Formules;
use App\Entity\FormulesOptionsInclus;
use App\Entity\FormulesProductInclus;
use App\Entity\FormulesRestriction;
use App\Entity\Options;
use App\Entity\Product;
use App\Entity\ProductDoc;
use App\Form\FormulesType;
use App\Form\OptionsType;
use App\Form\ProductDocType;
use App\Form\ProductType;
use App\Logger\AppLogger;
use App\Repository\FormulesProductInclusRepository;
use App\Repository\FormulesRepository;
use App\Repository\OptionsRepository;
use App\Repository\ProductRepository;
use App\Service\Stripe\Client;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Routing\Attribute\Route;
use Presta\SitemapBundle\Messenger\DumpSitemapMessage;
use Vich\UploaderBundle\Templating\Helper\UploaderHelper;
#[Route('/crm/formules')]
class FormulesController extends AbstractController
{
// --- JSON ENDPOINTS ---
public function __construct(
private readonly EntityManagerInterface $em,
private readonly AppLogger $appLogger,
private readonly FormulesRepository $formulesRepository
) {
}
#[Route(path: '/crm/formules', name: 'app_crm_formules', methods: ['GET','POST'])]
public function formules(PaginatorInterface $paginator,EntityManagerInterface $entityManager,AppLogger $appLogger,Request $request,FormulesRepository $formulesRepository): Response
#[Route('', name: 'app_crm_formules', methods: ['GET', 'POST'])]
public function index(Request $request, PaginatorInterface $paginator): Response
{
if($request->isMethod('POST')) {
$items = $request->getContent();
$items = json_decode($items, true)['items'];
foreach ($items as $key => $item) {
$fv = $formulesRepository->find($item);
$fv->setPos($key);
$entityManager->persist($fv);
// Gestion du réordonnancement (Drag & Drop via JSON)
if ($request->isMethod('POST')) {
$data = json_decode($request->getContent(), true);
if (isset($data['items']) && is_array($data['items'])) {
foreach ($data['items'] as $key => $id) {
$formule = $this->formulesRepository->find($id);
if ($formule) {
$formule->setPos($key);
$this->em->persist($formule);
}
}
$this->em->flush();
}
$entityManager->flush();
return $this->json([]);
return $this->json(['status' => 'success']);
}
$appLogger->record('VIEW', 'Consultation des formules');
$this->appLogger->record('VIEW', 'Consultation des formules');
$query = $this->formulesRepository->findBy([], ['pos' => 'asc']);
return $this->render('dashboard/formules.twig', [
'formules' => $paginator->paginate($formulesRepository->findBy([],['pos'=>'asc']), $request->query->getInt('page', 1), 10),
'formules' => $paginator->paginate($query, $request->query->getInt('page', 1), 10),
]);
}
#[Route(path: '/crm/formules/delete/{id}', name: 'app_crm_formules_delete', methods: ['POST', 'GET'])]
public function formulesDelete(
?Formules $formules,
AppLogger $appLogger,
EntityManagerInterface $entityManager,
Request $request
): Response {
// 1. Vérification de l'existence de la formule
if (!$formules instanceof Formules) {
$this->addFlash('error', 'Formule introuvable.');
return $this->redirectToRoute('app_crm_formules');
#[Route('/add', name: 'app_crm_formules_add', methods: ['GET', 'POST'])]
public function add(Request $request): Response
{
$this->appLogger->record('VIEW', 'Consultation page création formules');
$formule = new Formules();
$formule->setIsPublish(false)
->setUpdatedAt(new \DateTimeImmutable())
->setType($request->query->get('type', 'pack'));
$form = $this->createForm(FormulesType::class, $formule);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->em->persist($formule);
// Création automatique des restrictions pour les formules gratuites
if ($formule->getType() === "free") {
$restriction = new FormulesRestriction();
$restriction->setFormule($formule)
->setNbStructureMax(0)
->setNbAlimentaireMax(0)
->setNbBarhumsMax(0)
->setRestrictionConfig([]);
$this->em->persist($restriction);
}
$this->em->flush();
$this->appLogger->record('CREATED', "Création de la formule : " . $formule->getName());
$this->addFlash("success", "La formule a été créée avec succès.");
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formule->getId()]);
}
// 2. Vérification du jeton CSRF (Sécurité contre les suppressions malveillantes)
// Note: Dans ton Twig, tu passes déjà le token en query string
if ($this->isCsrfTokenValid('delete' . $formules->getId(), $request->query->get('_token'))) {
$nomFormule = $formules->getName();
return $this->render('dashboard/formules/add.twig', [
'formule' => $formule,
'form' => $form->createView(),
'type' => $formule->getType(),
]);
}
if($formules->getFormulesRestriction() instanceof FormulesRestriction)
$entityManager->remove($formules->getFormulesRestriction());
// 3. Suppression
foreach ($formules->getFormulesProductIncluses() as $formulesProductInclus)
$entityManager->remove($formulesProductInclus);
$entityManager->remove($formules);
$entityManager->flush();
#[Route('/{id}', name: 'app_crm_formules_view', methods: ['GET', 'POST'])]
public function view(
Formules $formule,
Request $request,
ProductRepository $productRepository
): Response {
// Changement de type via query param si nécessaire
if ($type = $request->query->get('type')) {
$formule->setType($type);
}
// 4. Logging & Feedback
$appLogger->record('DELETE', 'Suppression définitive de la formule : ' . $nomFormule);
$this->addFlash('success', 'La formule "' . $nomFormule . '" a été supprimée.');
// 1. Actions Rapides (Query Param 'act')
if ($request->query->get('act') === 'togglePublish') {
return $this->handleTogglePublish($formule, $request->query->get('status') === 'true');
}
// 2. Traitement des formulaires manuels (détection via clés POST)
if ($request->isMethod('POST')) {
if ($request->request->has('lines')) {
return $this->handleUpdateProducts($formule, $request->request->all('lines'), $productRepository);
}
if ($request->request->has('option')) {
return $this->handleUpdateOptions($formule, $request->request->all('option'));
}
if ($request->request->has('rest')) {
return $this->handleUpdateRestrictionConfig($formule, $request->request->all('rest'));
}
if ($request->request->has('price')) {
return $this->handleUpdatePrices($formule, $request->request->all('price'));
}
if ($request->request->has('nbStructureMax')) {
return $this->handleUpdateLimits($formule, $request);
}
}
// 3. Formulaire Symfony principal
$form = $this->createForm(FormulesType::class, $formule);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->em->persist($formule);
$this->em->flush();
$this->appLogger->record('UPDATE', "Modification de la formule (infos) : " . $formule->getName());
$this->addFlash("success", "La formule a été modifiée avec succès.");
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formule->getId()]);
}
return $this->render('dashboard/formules/view.twig', [
'formule' => $formule,
'form' => $form->createView(),
'type' => $formule->getType(),
'lines' => $this->prepareLines($formule),
'option' => $this->prepareOptions($formule),
'restriction' => $this->prepareRestrictions($formule),
]);
}
#[Route('/delete/{id}', name: 'app_crm_formules_delete', methods: ['POST', 'GET'])]
public function delete(Formules $formule, Request $request): RedirectResponse
{
if ($this->isCsrfTokenValid('delete' . $formule->getId(), $request->query->get('_token'))) {
$name = $formule->getName();
if ($restriction = $formule->getFormulesRestriction()) {
$this->em->remove($restriction);
}
foreach ($formule->getFormulesProductIncluses() as $inclus) {
$this->em->remove($inclus);
}
$this->em->remove($formule);
$this->em->flush();
$this->appLogger->record('DELETE', "Suppression définitive de la formule : $name");
$this->addFlash('success', "La formule \"$name\" a été supprimée.");
} else {
$this->addFlash('error', 'Jeton de sécurité invalide.');
}
return $this->redirectToRoute('app_crm_formules');
}
#[Route(path: '/crm/formules/add', name: 'app_crm_formules_add', methods: ['GET','POST'])]
public function formulesAdd(EntityManagerInterface $entityManager,PaginatorInterface $paginator,AppLogger $appLogger,Request $request,FormulesRepository $formulesRepository): Response
// --- PRIVATE HELPERS : ACTIONS ---
private function handleTogglePublish(Formules $formule, bool $status): RedirectResponse
{
$appLogger->record('VIEW', 'Consultation page création formules');
$formule->setIsPublish($status);
$this->em->flush();
$formules = new Formules();
$formules->setIsPublish(false);
$formules->setUpdatedAt(new \DateTimeImmutable());
$formules->setType($request->get('type',"pack")); // pack selected // free add listing allow product
$action = $status ? 'Publication' : 'Désactivation';
$this->appLogger->record('UPDATE', "$action de la formule " . $formule->getName());
$this->addFlash('success', 'Statut mis à jour avec succès.');
$form = $this->createForm(FormulesType::class,$formules);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($formules);
if($formules->getType() == "free") {
$formuleRestriction = new FormulesRestriction();
$formuleRestriction->setFormule($formules);
$formuleRestriction->setNbStructureMax(0);
$formuleRestriction->setNbAlimentaireMax(0);
$formuleRestriction->setNbBarhumsMax(0);
$formuleRestriction->setRestrictionConfig([]);
$entityManager->persist($formuleRestriction);
}
$entityManager->flush();
$appLogger->record('CREATED', "Création de la formule : " . $formules->getName());
$this->addFlash("success", "La formule a été créée avec succès.");
return $this->redirectToRoute('app_crm_formules_view',['id'=>$formules->getId()]);
}
return $this->render('dashboard/formules/add.twig', [
'formule' => $formules,
'form' => $form->createView(),
'type' => $request->get('type',"pack"),
]);
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formule->getId()]);
}
#[Route(path: '/crm/formules/{id}', name: 'app_crm_formules_view', methods: ['GET', 'POST'])]
public function formulesView(?Formules $formules,FormulesProductInclusRepository $formulesProductInclusRepository,ProductRepository $productRepository, Request $request, EntityManagerInterface $entityManager, AppLogger $appLogger): Response
private function handleUpdateProducts(Formules $formule, array $lines, ProductRepository $productRepo): RedirectResponse
{
foreach ($lines as $line) {
if (empty($line['product'])) continue;
if (!$formules instanceof Formules) {
$this->addFlash('error', 'Formule introuvable.');
return $this->redirectToRoute('app_crm_formules');
}
if($request->query->has('type'))
$formules->setType($request->get('type',"pack"));
// 1. GESTION DU STATUT (Toggle Publish)
if ($request->get('act') === 'togglePublish') {
$status = $request->get('status') === 'true';
$formules->setIsPublish($status);
$entityManager->flush();
$appLogger->record('UPDATE', ($status ? 'Publication' : 'Désactivation') . ' de la formule ' . $formules->getName());
$this->addFlash('success', 'Statut mis à jour avec succès.');
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formules->getId()]);
}
if ($request->isMethod('POST') && $request->request->has('lines')) {
$lines = $request->request->all('lines');
foreach ($lines as $line) {
if(isset($line['id']) && $line['id'] !="") {
$productInclus = $entityManager->getRepository(FormulesProductInclus::class)->find($line['id']);
} else {
$productInclus = new FormulesProductInclus();
$productInclus->setFormules($formules);
$productInclus->setConfig([]);
}
$productInclus->setPRODUCT($productRepository->findOneBy(['name'=>$line['product']]));
$entityManager->persist($productInclus);
}
$entityManager->flush();
$appLogger->record('UPDATE', 'Mise à jour des produit inclus dans la formule' . $formules->getName());
$this->addFlash('success', 'Produit mis à jour avec succès.');
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formules->getId()]);
}
if ($request->isMethod('POST') && $request->request->has('option')) {
$options = $request->request->all('option');
foreach ($options as $option) {
if(isset($option['id']) && $option['id'] !="") {
$productInclus = $entityManager->getRepository(FormulesOptionsInclus::class)->find($option['id']);
} else {
$productInclus = new FormulesOptionsInclus();
$productInclus->setFormule($formules);
}
$productInclus->setName($option['product']);
$entityManager->persist($productInclus);
}
$entityManager->flush();
$appLogger->record('UPDATE', 'Mise à jour des options inclus dans la formule' . $formules->getName());
$this->addFlash('success', 'Options mis à jour avec succès.');
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formules->getId()]);
}
if ($request->isMethod('POST') && $request->request->has('rest')) {
$rest = $request->request->all('rest');
$f = $formules->getFormulesRestriction()->setRestrictionConfig($rest);
$entityManager->persist($f);
$entityManager->flush();
$appLogger->record('UPDATE', "Mise à jour des restriction pour : " . $formules->getName());
$this->addFlash("success", "Les restriction ont été mis à jour.");
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formules->getId()]);
}
// 2. GESTION DES PRIX (Formulaire Manuel price[])
// On vérifie si le tableau 'price' existe dans la requête POST
if ($request->isMethod('POST') && $request->request->has('price')) {
$prices = $request->request->all('price');
// Mapping manuel des champs du tableau HTML vers l'entité
$formules->setPrice1j($prices['1j'] ?? $formules->getPrice1j());
$formules->setPrice2j($prices['2j'] ?? $formules->getPrice2j());
$formules->setPrice5j($prices['5j'] ?? $formules->getPrice5j());
$formules->setCaution($prices['caution'] ?? $formules->getCaution());
$entityManager->flush();
$appLogger->record('UPDATE', "Mise à jour des tarifs pour : " . $formules->getName());
$this->addFlash("success", "Les tarifs ont été mis à jour.");
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formules->getId()]);
}
// 3. GESTION DU FORMULAIRE CLASSIQUE (Symfony Form)
$form = $this->createForm(FormulesType::class, $formules);
$form->handleRequest($request);
if ($request->isMethod('POST') && $request->request->has('nbStructureMax')) {
// Vérification du type de formule
if ($formules->getType() === "free") {
$nbStructureMax = $request->request->get('nbStructureMax');
$nbBarhumsMax = $request->request->get('nbBarhumsMax');
$nbAlimentaireMax = $request->request->get('nbAlimentaireMax');
$rc = $formules->getFormulesRestriction();
// Si la restriction existe, on met à jour la valeur
if ($rc) {
$rc->setNbStructureMax((int)$nbStructureMax);
$rc->setNbBarhumsMax((int)$nbBarhumsMax);
$rc->setNbAlimentaireMax((int)$nbAlimentaireMax);
// Persistance des modifications
$entityManager->persist($rc);
$entityManager->flush();
// Log de l'activité
$appLogger->record('UPDATE', "Modification de la formule (restriction) : " . $formules->getName());
$this->addFlash("success", "La restriction de la formule a été modifiée avec succès.");
} else {
$this->addFlash("error", "Aucune restriction trouvée pour cette formule.");
}
} else {
$this->addFlash("warning", "Le nombre de structures max n'est modifiable que pour les formules gratuites.");
$productInclus = null;
if (!empty($line['id'])) {
$productInclus = $this->em->getRepository(FormulesProductInclus::class)->find($line['id']);
}
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formules->getId()]);
}
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($formules);
// 2. Sauvegarde globale
$entityManager->flush();
if (!$productInclus) {
$productInclus = new FormulesProductInclus();
$productInclus->setFormules($formule)->setConfig([]);
}
$appLogger->record('UPDATE', "Modification de la formule (infos) : " . $formules->getName());
$this->addFlash("success", "La formule a été modifiée avec succès.");
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formules->getId()]);
}
$lines =[
[
'product' => '',
]
];
$options = [
[
'product' => '',
]
];
foreach ($formules->getFormulesProductIncluses() as $key=>$fc){
$lines[$key]['product'] = $fc->getProduct()->getName();
$lines[$key]['id'] = $fc->getId();
}
foreach ($formules->getFormulesOptionsIncluses() as $key=>$fc){
$options[$key]['product'] = $fc->getName();
$options[$key]['id'] = $fc->getId();
}
$restriction =[
[
'type' => 'structure',
'product' => ''
]
];
if($formules->getFormulesRestriction() instanceof FormulesRestriction) {
if (!empty($formules->getFormulesRestriction()->getRestrictionConfig())) {
$restriction = $formules->getFormulesRestriction()->getRestrictionConfig();
$product = $productRepo->findOneBy(['name' => $line['product']]);
if ($product) {
$productInclus->setPRODUCT($product);
$this->em->persist($productInclus);
}
}
return $this->render('dashboard/formules/view.twig', [
'formule' => $formules,
'form' => $form->createView(),
'type' => $formules->getType(),
'lines' => $lines,
'option' => $options,
'restriction' => $restriction,
]);
$this->em->flush();
$this->appLogger->record('UPDATE', 'Mise à jour des produits inclus dans ' . $formule->getName());
$this->addFlash('success', 'Produits mis à jour avec succès.');
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formule->getId()]);
}
private function handleUpdateOptions(Formules $formule, array $options): RedirectResponse
{
foreach ($options as $opt) {
if (empty($opt['product'])) continue;
$optionInclus = null;
if (!empty($opt['id'])) {
$optionInclus = $this->em->getRepository(FormulesOptionsInclus::class)->find($opt['id']);
}
if (!$optionInclus) {
$optionInclus = new FormulesOptionsInclus();
$optionInclus->setFormule($formule);
}
$optionInclus->setName($opt['product']);
$this->em->persist($optionInclus);
}
$this->em->flush();
$this->appLogger->record('UPDATE', 'Mise à jour des options incluses dans ' . $formule->getName());
$this->addFlash('success', 'Options mises à jour avec succès.');
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formule->getId()]);
}
private function handleUpdateRestrictionConfig(Formules $formule, array $rest): RedirectResponse
{
$restriction = $formule->getFormulesRestriction();
if ($restriction) {
$restriction->setRestrictionConfig($rest);
$this->em->persist($restriction);
$this->em->flush();
$this->appLogger->record('UPDATE', "Mise à jour configuration restriction pour : " . $formule->getName());
$this->addFlash("success", "Les restrictions ont été mises à jour.");
}
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formule->getId()]);
}
private function handleUpdatePrices(Formules $formule, array $prices): RedirectResponse
{
$formule->setPrice1j($prices['1j'] ?? $formule->getPrice1j());
$formule->setPrice2j($prices['2j'] ?? $formule->getPrice2j());
$formule->setPrice5j($prices['5j'] ?? $formule->getPrice5j());
$formule->setCaution($prices['caution'] ?? $formule->getCaution());
$this->em->flush();
$this->appLogger->record('UPDATE', "Mise à jour des tarifs pour : " . $formule->getName());
$this->addFlash("success", "Les tarifs ont été mis à jour.");
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formule->getId()]);
}
private function handleUpdateLimits(Formules $formule, Request $request): RedirectResponse
{
if ($formule->getType() !== "free") {
$this->addFlash("warning", "Le nombre de structures max n'est modifiable que pour les formules gratuites.");
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formule->getId()]);
}
$restriction = $formule->getFormulesRestriction();
if ($restriction) {
$restriction->setNbStructureMax((int) $request->request->get('nbStructureMax'));
$restriction->setNbBarhumsMax((int) $request->request->get('nbBarhumsMax'));
$restriction->setNbAlimentaireMax((int) $request->request->get('nbAlimentaireMax'));
$this->em->persist($restriction);
$this->em->flush();
$this->appLogger->record('UPDATE', "Modification limites (restrictions) : " . $formule->getName());
$this->addFlash("success", "Les limites de la formule ont été modifiées.");
} else {
$this->addFlash("error", "Aucune entité de restriction trouvée.");
}
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formule->getId()]);
}
// --- PRIVATE HELPERS : VIEW PREPARATION ---
private function prepareLines(Formules $formule): array
{
$lines = [];
foreach ($formule->getFormulesProductIncluses() as $fc) {
$lines[] = [
'id' => $fc->getId(),
'product' => $fc->getProduct()->getName()
];
}
if (empty($lines)) $lines[] = ['product' => ''];
return $lines;
}
private function prepareOptions(Formules $formule): array
{
$options = [];
foreach ($formule->getFormulesOptionsIncluses() as $fc) {
$options[] = [
'id' => $fc->getId(),
'product' => $fc->getName()
];
}
if (empty($options)) $options[] = ['product' => ''];
return $options;
}
private function prepareRestrictions(Formules $formule): array
{
$restriction = $formule->getFormulesRestriction();
if ($restriction && !empty($restriction->getRestrictionConfig())) {
return $restriction->getRestrictionConfig();
}
return [['type' => 'structure', 'product' => '']];
}
}

View File

@@ -2,58 +2,99 @@
namespace App\Controller\Dashboard;
use App\Entity\SitePerformance;
use App\Repository\CustomerRepository;
use App\Repository\DevisRepository;
use App\Repository\ProductRepository;
use App\Repository\SitePerformanceRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[Route('/crm')]
class HomeController extends AbstractController
{
#[Route(path: '/crm', name: 'app_crm', options: ['sitemap' => false], methods: ['GET'])]
public function crm(
ProductRepository $productRepository,
CustomerRepository $customerRepository,
DevisRepository $devisRepository,
HttpClientInterface $httpClient,
SitePerformanceRepository $sitePerformanceRepository,
CacheInterface $cache
): Response {
private const UMAMI_WEBSITE_ID = "38d713c3-3923-4791-875a-dfe5f45372c3";
private const UMAMI_BASE_URL = "https://tools-security.esy-web.dev/api";
$websiteId = "38d713c3-3923-4791-875a-dfe5f45372c3";
$baseUrl = "https://tools-security.esy-web.dev/api";
public function __construct(
private readonly ProductRepository $productRepository,
private readonly CustomerRepository $customerRepository,
private readonly DevisRepository $devisRepository,
private readonly SitePerformanceRepository $sitePerformanceRepository,
private readonly HttpClientInterface $httpClient,
private readonly CacheInterface $cache,
#[Autowire('%kernel.project_dir%')]
private readonly string $projectDir
) {
}
// 1. Récupération sécurisée du Token (Cache 2h)
$token = $cache->get('umami_token', function (ItemInterface $item) use ($httpClient, $baseUrl) {
#[Route('', name: 'app_crm', options: ['sitemap' => false], methods: ['GET'])]
public function index(): Response
{
$stats = $this->getUmamiStats();
$updates = $this->getSystemUpdates();
return $this->render('dashboard/home.twig', [
'product' => $this->productRepository->count([]),
'devis_wait_sign' => $this->devisRepository->waitSign(),
'customers' => $this->customerRepository->count([]),
'nbVisitor' => $stats['visitors'],
'nbView' => $stats['views'],
'statview' => "https://tools-security.esy-web.dev/share/o9j2XMjV4Trnnbfb",
'updates' => $updates,
'avg_lcp' => $this->sitePerformanceRepository->getMoyenStat('lcp'),
'avg_inp' => $this->sitePerformanceRepository->getMoyenStat('inp'),
'avg_cls' => $this->sitePerformanceRepository->getMoyenStat('cls'),
]);
}
// --- PRIVATE HELPERS ---
/**
* Récupère les statistiques de visite via l'API Umami (avec mise en cache)
*/
private function getUmamiStats(): array
{
// 1. Récupération du Token (Cache 2h)
$token = $this->cache->get('umami_token', function (ItemInterface $item) {
$item->expiresAfter(7200);
$response = $httpClient->request('POST', "$baseUrl/auth/login", [
'json' => [
'username' => $_ENV['UMAMI_USER'],
'password' => $_ENV['UMAMI_PASSWORD'],
]
]);
return $response->toArray()['token'] ?? null;
try {
$response = $this->httpClient->request('POST', self::UMAMI_BASE_URL . '/auth/login', [
'json' => [
'username' => $_ENV['UMAMI_USER'],
'password' => $_ENV['UMAMI_PASSWORD'],
]
]);
return $response->toArray()['token'] ?? null;
} catch (\Exception $e) {
return null;
}
});
// 2. Récupération des Stats Umami
$stats = $cache->get('umami_stats_24h', function (ItemInterface $item) use ($httpClient, $baseUrl, $token, $websiteId) {
// 2. Récupération des Stats (Cache 15min)
return $this->cache->get('umami_stats_24h', function (ItemInterface $item) use ($token) {
$item->expiresAfter(900);
if (!$token) return ['visitors' => 0, 'views' => 0];
if (!$token) {
return ['visitors' => 0, 'views' => 0];
}
try {
$startAt = (time() - (24 * 3600)) * 1000;
$endAt = time() * 1000;
$response = $httpClient->request('GET', "$baseUrl/websites/$websiteId/stats", [
$response = $this->httpClient->request('GET', self::UMAMI_BASE_URL . '/websites/' . self::UMAMI_WEBSITE_ID . '/stats', [
'headers' => ['Authorization' => "Bearer $token"],
'query' => ['startAt' => $startAt, 'endAt' => $endAt],
]);
$data = $response->toArray();
return [
'visitors' => $data['visitors']['value'] ?? $data['visitors'] ?? 0,
'views' => $data['pageviews']['value'] ?? $data['pageviews'] ?? 0
@@ -62,38 +103,30 @@ class HomeController extends AbstractController
return ['visitors' => 0, 'views' => 0];
}
});
}
// 3. Récupération des Updates (Journal de bord client)
$updateFile = $this->getParameter('kernel.project_dir') . '/var/update.json';
$updates = [];
/**
* Récupère et formate le journal de bord des mises à jour
*/
private function getSystemUpdates(): array
{
$updateFile = $this->projectDir . '/var/update.json';
if (file_exists($updateFile)) {
$rawUpdates = json_decode(file_get_contents($updateFile), true) ?? [];
// On enrichit les données avec les couleurs Tailwind
$updates = array_map(function ($update) {
$update['tag_color'] = match ($update['type'] ?? 'new') {
'feature' => 'bg-emerald-100 text-emerald-700 border-emerald-200',
'fix' => 'bg-rose-100 text-rose-700 border-rose-200',
'optimise' => 'bg-amber-100 text-amber-700 border-amber-200',
'new' => 'bg-slate-100 text-slate-700 border-slate-200',
default => 'bg-gray-100 text-gray-700 border-gray-200',
};
return $update;
}, $rawUpdates);
if (!file_exists($updateFile)) {
return [];
}
return $this->render('dashboard/home.twig', [
'product' => $productRepository->count(),
'devis_wait_sign' => $devisRepository->waitSign(),
'customers' => $customerRepository->count(),
'nbVisitor' => $stats['visitors'],
'nbView' => $stats['views'],
'statview' => "https://tools-security.esy-web.dev/share/o9j2XMjV4Trnnbfb",
'updates' => $updates,
'avg_lcp' => $sitePerformanceRepository->getMoyenStat('lcp'),
'avg_inp' => $sitePerformanceRepository->getMoyenStat('inp'),
'avg_cls' => $sitePerformanceRepository->getMoyenStat('cls'),
]);
$rawUpdates = json_decode(file_get_contents($updateFile), true) ?? [];
return array_map(function ($update) {
$update['tag_color'] = match ($update['type'] ?? 'new') {
'feature' => 'bg-emerald-100 text-emerald-700 border-emerald-200',
'fix' => 'bg-rose-100 text-rose-700 border-rose-200',
'optimise' => 'bg-amber-100 text-amber-700 border-amber-200',
'new' => 'bg-slate-100 text-slate-700 border-slate-200',
default => 'bg-gray-100 text-gray-700 border-gray-200',
};
return $update;
}, $rawUpdates);
}
}

View File

@@ -3,11 +3,13 @@
namespace App\Controller\Dashboard;
use App\Entity\AuditLog;
use App\Logger\AppLogger;
use App\Repository\AccountRepository;
use App\Repository\AuditLogRepository;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -16,150 +18,131 @@ use Symfony\Component\HttpFoundation\StreamedResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[Route('/crm/logs')]
class LogsController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly AuditLogRepository $auditLogRepository,
private readonly AccountRepository $accountRepository,
private readonly PaginatorInterface $paginator,
private readonly AppLogger $appLogger
) {
}
#[Route('', name: 'app_crm_audit_logs', methods: ['GET'])]
public function index(Request $request): Response
{
$this->appLogger->record('VIEW', 'Consultation des journaux d\'audit');
#[Route(path: '/crm/logs', name: 'app_crm_audit_logs', methods: ['GET'])]
public function crmLogs(
AuditLogRepository $auditLogRepository,
AccountRepository $userRepository,
PaginatorInterface $paginator,
Request $request
): Response {
// On récupère l'ID du compte à filtrer depuis l'URL (?account=12)
$filterAccountId = $request->query->get('account');
$query = $this->auditLogRepository->getQueryForUser($this->getUser(), (int)$filterAccountId);
// On appelle la méthode du repository avec l'utilisateur actuel ET le filtre
$query = $auditLogRepository->getQueryForUser($this->getUser(), (int)$filterAccountId);
// Extraction Excel (qui respectera le filtre et la sécurité ROOT)
if ($request->query->get('extract')) {
// Export Excel
if ($request->query->has('extract')) {
return $this->generateExcelExport($query->getResult());
}
$pagination = $paginator->paginate(
$pagination = $this->paginator->paginate(
$query,
$request->query->getInt('page', 1),
25
);
$users = $userRepository->findAdmin();
if($this->isGranted('ROLE_ROOT')) {
$users = $userRepository->findAll();
}
$users = $this->isGranted('ROLE_ROOT')
? $this->accountRepository->findAll()
: $this->accountRepository->findAdmin();
return $this->render('dashboard/audit_logs.twig', [
'logs' => $pagination,
'users_list' => $users // Liste pour ton select Twig
'users_list' => $users
]);
}
#[Route('/delete/{id}', name: 'app_crm_audit_logs_delete', methods: ['POST'])]
#[IsGranted('ROLE_ROOT')]
public function delete(AuditLog $log, Request $request): Response
{
if ($this->isCsrfTokenValid('delete' . $log->getId(), $request->request->get('_token'))) {
$this->em->remove($log);
$this->em->flush();
$this->appLogger->record('DELETE', "Suppression manuelle d'une entrée de log (ID: {$log->getId()})");
$this->addFlash('success', 'L\'entrée du journal a été supprimée.');
} else {
$this->addFlash('error', 'Action non autorisée (Token invalide).');
}
return $this->redirectToRoute('app_crm_audit_logs');
}
// --- PRIVATE HELPERS ---
private function generateExcelExport(array $logs): StreamedResponse
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('Logs Audit');
// 1. Génération d'un mot de passe aléatoire pour ce fichier
// On utilise bin2hex pour un mot de passe robuste
$ultraSecurePassword = bin2hex(random_bytes(32));
// Génération d'un mot de passe robuste pour le fichier
$password = bin2hex(random_bytes(32));
// En-têtes (A à J)
$headers = ['Date', 'Heure', 'Admin', 'Email', 'Action', 'Message', 'URL', 'Navigateur/OS', 'Signature', 'État'];
$columns = range('A', 'J');
foreach ($columns as $i => $column) {
$sheet->setCellValue($column . '1', $headers[$i]);
$sheet->getStyle($column . '1')->getFont()->setBold(true);
$sheet->getStyle($column . '1')->getFill()
->setFillType(\PhpOffice\PhpSpreadsheet\Style\Fill::FILL_SOLID)
->getStartColor()->setARGB('F1F5F9');
foreach ($columns as $i => $col) {
$sheet->setCellValue($col . '1', $headers[$i]);
$sheet->getStyle($col . '1')->getFont()->setBold(true);
$sheet->getStyle($col . '1')->getFill()->setFillType(Fill::FILL_SOLID)->getStartColor()->setARGB('F1F5F9');
}
$row = 2;
/** @var AuditLog $log */
foreach ($logs as $log) {
$isValid = ($log->getHashCode() === $log->generateSignature());
$statusText = $isValid ? 'VALIDE' : 'CORROMPU';
$account = $log->getAccount();
$sheet->setCellValue('A' . $row, $log->getActionAt()->format('d/m/Y'));
$sheet->setCellValue('B' . $row, $log->getActionAt()->format('H:i:s'));
$sheet->setCellValue('C' . $row, $log->getAccount()->getFirstName() . ' ' . $log->getAccount()->getName());
$sheet->setCellValue('D' . $row, $log->getAccount()->getEmail());
$sheet->setCellValue('C' . $row, $account ? $account->getFirstName() . ' ' . $account->getName() : 'Système');
$sheet->setCellValue('D' . $row, $account ? $account->getEmail() : 'N/A');
$sheet->setCellValue('E' . $row, $log->getType());
$sheet->setCellValue('F' . $row, $log->getMessage());
$sheet->setCellValue('G' . $row, $log->getPath());
$sheet->setCellValue('H' . $row, $log->getUserAgent());
$sheet->setCellValue('I' . $row, $log->getHashCode());
$sheet->setCellValue('J' . $row, $statusText);
$sheet->setCellValue('J' . $row, $isValid ? 'VALIDE' : 'CORROMPU');
// Couleur d'état
$color = $isValid ? '059669' : 'DC2626';
$sheet->getStyle('J' . $row)->getFont()->setBold(true)->getColor()->setARGB($color);
$sheet->getStyle('J' . $row)->getFont()->setBold(true)->getColor()->setARGB($isValid ? '059669' : 'DC2626');
$row++;
}
// Auto-ajustement des colonnes
foreach ($columns as $column) {
$sheet->getColumnDimension($column)->setAutoSize(true);
foreach ($columns as $col) {
$sheet->getColumnDimension($col)->setAutoSize(true);
}
// --- VERROUILLAGE DU FICHIER ---
// On protège la feuille contre toute modification
$sheet->getProtection()->setPassword($ultraSecurePassword);
// Protection du fichier
$sheet->getProtection()->setPassword($password);
$sheet->getProtection()->setSheet(true);
// On interdit la sélection des cellules verrouillées (optionnel)
$sheet->getProtection()->setSelectLockedCells(true);
$sheet->getProtection()->setSort(false);
$sheet->getProtection()->setInsertRows(false);
// On verrouille la structure du classeur
$spreadsheet->getSecurity()->setLockStructure(true);
$spreadsheet->getSecurity()->setWorkbookPassword($ultraSecurePassword);
$spreadsheet->getProperties()
->setCreator("Système Audit Ludikevent")
->setLastModifiedBy("Automate")
->setCompany("SARL SITECONSEIL")
->setCategory("Système Audit Ludikevent")
->setTitle("Journal d'audit protégé")
->setSubject("Confidentiel")
->setDescription("Ce fichier est protégé par signature numérique et verrouillage structurel.");
// --- GÉNÉRATION DE LA RÉPONSE ---
$spreadsheet->getSecurity()->setWorkbookPassword($password);
$writer = new Xlsx($spreadsheet);
$response = new StreamedResponse(function() use ($writer) {
$writer->save('php://output');
});
// On ajoute la date et un identifiant court dans le nom du fichier
$fileName = 'audit_ludikevent_' . date('d-m-Y_H-i') . '.xlsx';
$response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
$response->headers->set('Content-Disposition', 'attachment;filename="' . $fileName . '"');
$response->headers->set('Cache-Control', 'max-age=0');
return $response;
}
#[Route(path: '/crm/logs/delete/{id}', name: 'app_crm_audit_logs_delete', methods: ['POST'])]
#[IsGranted('ROLE_ROOT')]
public function deleteLog(
AuditLog $log,
Request $request,
EntityManagerInterface $entityManager
): Response {
// Vérification du token CSRF dynamique (lié à l'ID du log)
if (!$this->isCsrfTokenValid('delete' . $log->getId(), $request->request->get('_token'))) {
$this->addFlash('error', 'Action non autorisée.');
return $this->redirectToRoute('app_crm_audit_logs');
}
$entityManager->remove($log);
$entityManager->flush();
$this->addFlash('success', 'L\'entrée du journal a été supprimée.');
return $this->redirectToRoute('app_crm_audit_logs');
}
}