feat(env): Met à jour les URLs ngrok pour l'environnement.
 feat(Prestaire): Ajoute contrainte d'unicité email et relations Contrats/OrderSession.
 feat(OrderSession): Ajoute une relation ManyToOne vers Prestaire.
 feat(Contrats): Ajoute une relation ManyToOne vers Prestaire.
🐛 fix(SignatureController): Corrige la création de contrat à partir du devis signé.
 feat(FlowController): Ajoute un sélecteur de prestataire à la session.
 feat(devis/list.twig): Ajoute une légende des actions dans la liste des devis.
 feat(ContratsController): Ajoute le prestataire au contrat lors de la génération.
 feat(SearchController): Ajoute la recherche de prestataires.
🐛 fix(SignatureClient): Corrige le stockage de l'ID de signature du devis.
 feat(base.twig): Ajoute un lien vers la liste des prestataires dans le menu.
 feat(PrestataireRepository): Ajoute une méthode de recherche par nom et email.
```
This commit is contained in:
Serreau Jovann
2026-02-06 10:42:50 +01:00
parent 9323e79c3e
commit 2fbe64c6d9
22 changed files with 930 additions and 28 deletions

6
.env
View File

@@ -83,9 +83,9 @@ STRIPE_PK=pk_test_51SUA22173W4aeFB1nO6oFfDZ12HOTffDKtCshhZ8rkUg6kUO2ZaQC0tK72rhE
STRIPE_SK=sk_test_51SUA22173W4aeFB16EB2LxGI0hNvNJzFshDI98zRImWBIhSfzqOGAz5TlPxSpUWbj3x4COm6kmSsaal9FpQR1A7M0022DvjbbR
STRIPE_WEBHOOKS_SECRET=
SIGN_URL=https://04dcf28c87cd.ngrok-free.app
STRIPE_BASEURL=https://04dcf28c87cd.ngrok-free.app
CONTRAT_BASEURL=https://04dcf28c87cd.ngrok-free.app
SIGN_URL=https://81dd-82-67-166-187.ngrok-free.app
STRIPE_BASEURL=https://81dd-82-67-166-187.ngrok-free.app
CONTRAT_BASEURL=https://81dd-82-67-166-187.ngrok-free.app
MINIO_S3_URL=
MINIO_S3_CLIENT_ID=

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260206120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add password field to Prestaire entity';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE prestaire ADD password VARCHAR(255) NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE prestaire DROP password');
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260206130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add relation between Prestaire and Contrats';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE contrats ADD prestataire_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE contrats ADD CONSTRAINT FK_9591436BE30DA2F7 FOREIGN KEY (prestataire_id) REFERENCES prestaire (id)');
$this->addSql('CREATE INDEX IDX_9591436BE30DA2F7 ON contrats (prestataire_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE contrats DROP FOREIGN KEY FK_9591436BE30DA2F7');
$this->addSql('DROP INDEX IDX_9591436BE30DA2F7 ON contrats');
$this->addSql('ALTER TABLE contrats DROP prestataire_id');
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20260206140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add relation between Prestaire and OrderSession';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE order_session ADD prestataire_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE order_session ADD CONSTRAINT FK_88755657BE30DA2F7 FOREIGN KEY (prestataire_id) REFERENCES prestaire (id)');
$this->addSql('CREATE INDEX IDX_88755657BE30DA2F7 ON order_session (prestataire_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE order_session DROP FOREIGN KEY FK_88755657BE30DA2F7');
$this->addSql('DROP INDEX IDX_88755657BE30DA2F7 ON order_session');
$this->addSql('ALTER TABLE order_session DROP prestataire_id');
}
}

View File

@@ -338,6 +338,10 @@ class ContratsController extends AbstractController
->setCustomer($devis->getCustomer())
->setDevis($devis);
if ($devis->getOrderSession() && $devis->getOrderSession()->getPrestataire()) {
$contrat->setPrestataire($devis->getOrderSession()->getPrestataire());
}
if ($address = $devis->getAddressShip()) {
$contrat->setAddressEvent($address->getAddress())
->setZipCodeEvent($address->getZipcode())

View File

@@ -11,6 +11,7 @@ use App\Logger\AppLogger;
use App\Repository\DevisRepository;
use App\Repository\OptionsRepository;
use App\Repository\OrderSessionRepository;
use App\Repository\PrestaireRepository;
use App\Repository\ProductRepository;
use App\Service\Pdf\DevisPdfService;
use App\Service\Signature\Client;
@@ -34,6 +35,7 @@ class FlowController extends AbstractController
private readonly DevisRepository $devisRepository,
private readonly ProductRepository $productRepository,
private readonly OptionsRepository $optionsRepository,
private readonly PrestaireRepository $prestaireRepository,
private readonly KernelInterface $kernel,
private readonly Client $signatureClient,
private readonly EventDispatcherInterface $eventDispatcher
@@ -70,6 +72,7 @@ class FlowController extends AbstractController
return $this->render('dashboard/flow/view.twig', [
'session' => $session,
'prestataires' => $this->prestaireRepository->findAll(),
]);
}
@@ -85,6 +88,15 @@ class FlowController extends AbstractController
if ($request->request->has('typePaiement')) {
$session->setTypePaiement($request->request->get('typePaiement'));
}
if ($request->request->has('prestataire')) {
$prestataireId = $request->request->get('prestataire');
if ($prestataireId) {
$prestataire = $this->prestaireRepository->find($prestataireId);
$session->setPrestataire($prestataire);
} else {
$session->setPrestataire(null);
}
}
// Recalculate if address changed or forced update (optional, but good for consistency)
// For now, simple update.
@@ -103,19 +115,38 @@ class FlowController extends AbstractController
$devis = new Devis();
$devisNumber = "DEVIS-" . sprintf('%05d', $this->devisRepository->count() + 1);
$devis->setNum($devisNumber)
->setState("wait-send")
->setState("created_waitsign")
->setCreateA(new \DateTimeImmutable())
->setUpdateAt(new \DateTimeImmutable());
// 2. Customer
$devis->setCustomer($session->getCustomer());
// 2.1 Set additional Devis fields from OrderSession
$devis->setDistance($session->getDeliveryDistance());
$devis->setPriceShip($session->getDeliveryPrice());
$devis->setPaymentMethod($session->getTypePaiement());
$devis->setOrderSession($session);
if (str_contains($session->getTypePaiement() ?? '', 'Chorus')) {
$devis->setIsNotAddCaution(true);
}
// Set Prestataire from session to devis (needs to be added to Devis entity too if not already, but Contrats has it)
// Since Devis transforms to Contrats, we'll need to ensure Contrats gets this info.
// For now, let's assume Devis doesn't strictly need it unless we add the field to Devis too.
// Wait, the user asked to add 'selecteur pour choisir le prestaire en charge de la livraison' in the view.
// And I added the relation to OrderSession.
// When Devis is signed and becomes Contrat, Contrat has the relation.
// So we should probably store it on Devis as well or just pass it through.
// Let's check Devis entity if I can add it there too, or if I just rely on OrderSession link.
// Actually, creating Devis here.
// We should add 'prestataire' to Devis entity as well to persist this choice through the flow.
// Or, when creating Contrat from Devis later, we check the OrderSession linked to Devis.
// Let's update Devis entity to be safe and consistent.
// For this step I'll just update the controller logic to SET it if Devis has it.
// I will check Devis entity in a moment.
// 3. Addresses
// Billing Address
$billAddr = $this->findOrCreateAddress(
@@ -168,13 +199,13 @@ class FlowController extends AbstractController
$product = $this->productRepository->find($prodId);
if ($product) {
$line = new DevisLine();
$line->setDevi($devis);
$line->setPos($pos++);
$line->setProduct($product->getName());
$line->setDay($days);
$line->setPriceHt($product->getPriceDay());
$line->setPriceHtSup($product->getPriceSup());
$em->persist($line);
$devis->addDevisLine($line);
// Linked Options
if (isset($productsData['options'][$prodId])) {
@@ -182,10 +213,10 @@ class FlowController extends AbstractController
$option = $this->optionsRepository->find($optId);
if ($option) {
$devisOpt = new DevisOptions();
$devisOpt->setDevis($devis);
$devisOpt->setOption($option->getName());
$devisOpt->setPriceHt($option->getPriceHt());
$em->persist($devisOpt);
$devis->addDevisOption($devisOpt);
}
}
}
@@ -193,10 +224,11 @@ class FlowController extends AbstractController
}
}
// 6. Orphan Options
$sessionOptions = $session->getOptions();
$productIds = $productsData['ids'] ?? [];
if ($sessionOptions && is_array($sessionOptions)) {
foreach ($sessionOptions as $prodId => $opts) {
if (!in_array($prodId, $productIds) && is_array($opts)) {
@@ -217,15 +249,16 @@ class FlowController extends AbstractController
// 7. Delivery Fee
if ($session->getDeliveryPrice() > 0) {
$devisOpt = new DevisOptions();
$devisOpt->setDevis($devis);
$devisOpt->setOption("Frais de livraison");
$dist = number_format($session->getDeliveryDistance(), 1, ',', ' ');
$town = $session->getBillingTown() ?: 'Ville inconnue';
$devisOpt->setDetails("Livraison ($dist km) - $town");
$devisOpt->setPriceHt($session->getDeliveryPrice());
$em->persist($devisOpt);
$devis->addDevisOption($devisOpt);
}
// 8. Persist & Flush to generate IDs
$em->persist($devis);
$em->flush();
@@ -239,7 +272,7 @@ class FlowController extends AbstractController
// Internal
$devisService = new DevisPdfService($this->kernel, $devis, $this->productRepository, false);
$this->savePdfFile($devis, $devisService->generate(), 'devis_', 'setDevisFile');
$em->flush();
// 10. DocuSeal Submission
@@ -249,6 +282,7 @@ class FlowController extends AbstractController
$this->eventDispatcher->dispatch(new DevisSend($devis));
} catch (\Exception $e) {
dd($e->getMessage());
$this->appLogger->record('ERROR', 'Erreur génération PDF Devis auto: ' . $e->getMessage());
}
@@ -315,7 +349,7 @@ class FlowController extends AbstractController
$itineraire = $itineraireResponse->toArray();
$distance = $itineraire['distance'];
$geometry = $itineraire['geometry'] ?? null;
$rate = 0.50;
$trips = 4;
$price = 0.0;

View File

@@ -0,0 +1,134 @@
<?php
namespace App\Controller\Dashboard;
use App\Entity\Prestaire;
use App\Form\PrestaireType;
use App\Logger\AppLogger;
use App\Repository\PrestaireRepository;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Component\Pager\PaginatorInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
use App\Service\Mailer\Mailer;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
#[Route('/crm/prestataire')]
class PrestaireController extends AbstractController
{
public function __construct(
private readonly EntityManagerInterface $em,
private readonly AppLogger $appLogger,
private readonly Mailer $mailer,
private readonly UrlGeneratorInterface $urlGenerator
) {
}
#[Route('', name: 'app_crm_prestataire', options: ['sitemap' => false], methods: ['GET'])]
public function index(PrestaireRepository $repository, PaginatorInterface $paginator, Request $request): Response
{
$this->appLogger->record('VIEW', 'Consultation de la liste des Prestataires');
$query = $repository->createQueryBuilder('p')
->orderBy('p.name', 'ASC')
->getQuery();
return $this->render('dashboard/prestaire.twig', [
'prestataires' => $paginator->paginate($query, $request->query->getInt('page', 1), 10),
]);
}
#[Route('/add', name: 'app_crm_prestataire_add', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function add(Request $request, UserPasswordHasherInterface $passwordHasher): Response
{
$prestataire = new Prestaire();
$form = $this->createForm(PrestaireType::class, $prestataire);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Génération mot de passe aléatoire temporaire
$plainPassword = bin2hex(random_bytes(10));
$prestataire->setPassword($passwordHasher->hashPassword($prestataire, $plainPassword));
// Rôles par défaut
$prestataire->setRoles(['ROLE_PRESTAIRE']);
$this->em->persist($prestataire);
$this->em->flush();
// Envoi de l'email de bienvenue
$this->mailer->send(
$prestataire->getEmail(),
"{$prestataire->getSurname()} {$prestataire->getName()}",
"Bienvenue sur l'Intranet Ludikevent",
"mails/prestataire/create.twig",
[
'prestataire' => $prestataire,
'password' => $plainPassword,
'login_url' => $this->urlGenerator->generate('etl_home', [], UrlGeneratorInterface::ABSOLUTE_URL)
]
);
$this->appLogger->record('CREATE', sprintf(
"Création du Prestataire : %s %s (%s)",
$prestataire->getSurname(), $prestataire->getName(), $prestataire->getEmail()
));
$this->addFlash('success', "Le prestataire {$prestataire->getName()} a été créé avec succès et ses identifiants ont été envoyés par email.");
return $this->redirectToRoute('app_crm_prestataire');
}
return $this->render('dashboard/prestaire/add.twig', [
'form' => $form->createView()
]);
}
#[Route('/{id}', name: 'app_crm_prestataire_view', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function view(Prestaire $prestataire, Request $request): Response
{
$form = $this->createForm(PrestaireType::class, $prestataire);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->em->flush();
$this->appLogger->record('UPDATE', sprintf(
"Mise à jour du Prestataire : %s %s",
$prestataire->getSurname(), $prestataire->getName()
));
$this->addFlash('success', "Informations mises à jour.");
return $this->redirectToRoute('app_crm_prestataire_view', ['id' => $prestataire->getId()]);
}
$this->appLogger->record('VIEW', "Consultation fiche Prestataire : {$prestataire->getName()}");
return $this->render('dashboard/prestaire/view.twig', [
'prestataire' => $prestataire,
'form' => $form->createView()
]);
}
#[Route('/delete/{id}', name: 'app_crm_prestataire_delete', options: ['sitemap' => false], methods: ['POST'])]
public function delete(Prestaire $prestataire, Request $request): Response
{
if ($this->isCsrfTokenValid('delete' . $prestataire->getId(), $request->request->get('_token'))) {
$name = $prestataire->getSurname() . ' ' . $prestataire->getName();
$this->em->remove($prestataire);
$this->em->flush();
$this->appLogger->record('DELETE', "Suppression Prestataire : $name");
$this->addFlash('success', "Le prestataire $name a été supprimé.");
} else {
$this->addFlash('error', "Token de sécurité invalide.");
}
return $this->redirectToRoute('app_crm_prestataire');
}
}

View File

@@ -11,6 +11,7 @@ use App\Repository\AccountRepository;
use App\Repository\ContratsRepository;
use App\Repository\CustomerRepository;
use App\Repository\OptionsRepository;
use App\Repository\PrestaireRepository;
use App\Repository\ProductRepository;
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
use App\Service\ResetPassword\Event\ResetPasswordEvent;
@@ -35,6 +36,7 @@ class SearchController extends AbstractController
CustomerRepository $customerRepository,
ContratsRepository $contratsRepository,
OptionsRepository $optionsRepository,
PrestaireRepository $prestaireRepository,
Client $client,
Request $request
): Response {
@@ -42,6 +44,19 @@ class SearchController extends AbstractController
$unifiedResults = [];
if (!empty($query)) {
// Recherche DB directe pour Prestataires
$prestataires = $prestaireRepository->search($query);
foreach ($prestataires as $prestataire) {
$unifiedResults[] = [
'title' => $prestataire->getSurname() . " " . $prestataire->getName(),
'subtitle' => $prestataire->getEmail(),
'link' => $this->generateUrl('app_crm_prestataire_view', ['id' => $prestataire->getId()]),
'type' => 'Prestataire',
'id' => $prestataire->getId(),
'initials' => strtoupper(substr($prestataire->getSurname(), 0, 1) . substr($prestataire->getName(), 0, 1))
];
}
$response = $client->searchGlobal($query, 20);
foreach ($response['results'] as $resultGroup) {

View File

@@ -2,6 +2,10 @@
namespace App\Controller;
use App\Entity\Contrats;
use App\Entity\ContratsLine;
use App\Entity\ContratsOption;
use App\Service\Pdf\ContratPdfService;
use App\Entity\Account;
use App\Entity\AccountResetPasswordRequest;
use App\Entity\Devis;
@@ -24,6 +28,7 @@ use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Mime\Part\DataPart;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\Routing\Attribute\Route;
@@ -43,9 +48,10 @@ class SignatureController extends AbstractController
EntityManagerInterface $entityManager,
Request $request,
Mailer $mailer,
KernelInterface $kernel
): Response {
if ($request->get('type') === "contrat") {
$contrats = $contratsRepository->find($request->get('id'));
if ($request->query->get('type') === "contrat") {
$contrats = $contratsRepository->find($request->query->get('id'));
if (!$contrats) {
throw $this->createNotFoundException("Contrat introuvable.");
}
@@ -126,8 +132,8 @@ class SignatureController extends AbstractController
}
return $this->render('sign/contrat_sign_success.twig', ['contrat' => $contrats]);
}
if ($request->get('type') === "devis") {
$devis = $devisRepository->find($request->get('id'));
if ($request->query->get('type') === "devis") {
$devis = $devisRepository->find($request->query->get('id'));
if (!$devis) {
throw $this->createNotFoundException("Devis introuvable.");
@@ -196,6 +202,71 @@ class SignatureController extends AbstractController
],
$attachments
);
// --- AUTOMATION: Create Contrat from Devis if OrderSession exists ---
if ($devis->getOrderSession()) {
$contrat = new Contrats();
$contrat->setNumReservation('RESERV-' . date('Ymd') . '-' . substr(str_shuffle('ABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, 10));
$contrat->setCreateAt(new \DateTimeImmutable());
// Hydrate from Devis
$contrat->setDateAt($devis->getStartAt())
->setEndAt($devis->getEndAt())
->setCustomer($devis->getCustomer())
->setDevis($devis);
if ($devis->getOrderSession()->getPrestataire()) {
$contrat->setPrestataire($devis->getOrderSession()->getPrestataire());
}
if ($address = $devis->getAddressShip()) {
$contrat->setAddressEvent($address->getAddress())
->setZipCodeEvent($address->getZipcode())
->setTownEvent($address->getCity());
}
// Copy Lines
foreach ($devis->getDevisLines() as $dLine) {
$cLine = (new ContratsLine())
->setName($dLine->getProduct())
->setPrice1DayHt($dLine->getPriceHt())
->setPriceSupDayHt($dLine->getPriceHtSup())
->setCaution(0);
$product = $productRepository->findOneBy(['name' => $dLine->getProduct()]);
if ($product) {
$cLine->setCaution($product->getCaution());
}
$entityManager->persist($cLine);
$contrat->addContratsLine($cLine);
}
// Copy Options
foreach ($devis->getDevisOptions() as $dOpt) {
$cOpt = (new ContratsOption())
->setName($dOpt->getOption())
->setDetails($dOpt->getDetails())
->setPrice($dOpt->getPriceHt());
$entityManager->persist($cOpt);
$contrat->addContratsOption($cOpt);
}
// Generate PDF
foreach ([true, false] as $isDocuseal) {
$service = new ContratPdfService($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);
}
$entityManager->persist($contrat);
$entityManager->flush();
// Create Signature Submission
$client->createSubmissionContrat($contrat);
}
} catch (\Exception $e) {
return new Response("Erreur lors de la récupération ou de l'envoi des documents : " . $e->getMessage(), 500);
}

View File

@@ -146,6 +146,9 @@ class Contrats
#[ORM\OneToOne(mappedBy: 'contrat', cascade: ['persist', 'remove'])]
private ?EtatLieux $etatLieux = null;
#[ORM\ManyToOne(inversedBy: 'contrats')]
private ?Prestaire $prestataire = null;
public function __construct()
{
$this->contratsPayments = new ArrayCollection();
@@ -855,6 +858,18 @@ class Contrats
return $this;
}
public function getPrestataire(): ?Prestaire
{
return $this->prestataire;
}
public function setPrestataire(?Prestaire $prestataire): static
{
$this->prestataire = $prestataire;
return $this;
}
public function isCaution()
{
return $this->contratsPayments->filter(function (ContratsPayments $contratsPayments){

View File

@@ -93,6 +93,9 @@ class OrderSession
#[ORM\OneToOne(mappedBy: 'orderSession', cascade: ['persist', 'remove'])]
private ?Devis $devis = null;
#[ORM\ManyToOne(inversedBy: 'orderSessions')]
private ?Prestaire $prestataire = null;
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
@@ -123,6 +126,18 @@ class OrderSession
return $this;
}
public function getPrestataire(): ?Prestaire
{
return $this->prestataire;
}
public function setPrestataire(?Prestaire $prestataire): static
{
$this->prestataire = $prestataire;
return $this;
}
#[ORM\PrePersist]
public function setCreatedAtValue(): void
{

View File

@@ -11,6 +11,7 @@ use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
#[ORM\Entity(repositoryClass: PrestaireRepository::class)]
#[UniqueEntity(fields: ['email'], message: 'Il existe déjà un compte avec cet email')]
class Prestaire implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
@@ -24,6 +25,18 @@ class Prestaire implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\OneToMany(targetEntity: EtatLieux::class, mappedBy: 'prestataire')]
private Collection $etatLieuxes;
/**
* @var Collection<int, Contrats>
*/
#[ORM\OneToMany(targetEntity: Contrats::class, mappedBy: 'prestataire')]
private Collection $contrats;
/**
* @var Collection<int, OrderSession>
*/
#[ORM\OneToMany(targetEntity: OrderSession::class, mappedBy: 'prestataire')]
private Collection $orderSessions;
#[ORM\Column(length: 255)]
private ?string $email = null;
@@ -36,6 +49,9 @@ class Prestaire implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(length: 255)]
private ?string $phone = null;
#[ORM\Column(length: 255)]
private ?string $password = null;
/**
* @var list<string> The user roles
*/
@@ -45,6 +61,8 @@ class Prestaire implements UserInterface, PasswordAuthenticatedUserInterface
public function __construct()
{
$this->etatLieuxes = new ArrayCollection();
$this->contrats = new ArrayCollection();
$this->orderSessions = new ArrayCollection();
}
public function getId(): ?int
@@ -52,6 +70,66 @@ class Prestaire implements UserInterface, PasswordAuthenticatedUserInterface
return $this->id;
}
/**
* @return Collection<int, OrderSession>
*/
public function getOrderSessions(): Collection
{
return $this->orderSessions;
}
public function addOrderSession(OrderSession $orderSession): static
{
if (!$this->orderSessions->contains($orderSession)) {
$this->orderSessions->add($orderSession);
$orderSession->setPrestataire($this);
}
return $this;
}
public function removeOrderSession(OrderSession $orderSession): static
{
if ($this->orderSessions->removeElement($orderSession)) {
// set the owning side to null (unless already changed)
if ($orderSession->getPrestataire() === $this) {
$orderSession->setPrestataire(null);
}
}
return $this;
}
/**
* @return Collection<int, Contrats>
*/
public function getContrats(): Collection
{
return $this->contrats;
}
public function addContrat(Contrats $contrat): static
{
if (!$this->contrats->contains($contrat)) {
$this->contrats->add($contrat);
$contrat->setPrestataire($this);
}
return $this;
}
public function removeContrat(Contrats $contrat): static
{
if ($this->contrats->removeElement($contrat)) {
// set the owning side to null (unless already changed)
if ($contrat->getPrestataire() === $this) {
$contrat->setPrestataire(null);
}
}
return $this;
}
/**
* @return Collection<int, EtatLieux>
*/
@@ -130,9 +208,19 @@ class Prestaire implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
/**
* @see PasswordAuthenticatedUserInterface
*/
public function getPassword(): ?string
{
// TODO: Implement getPassword() method.
return $this->password;
}
public function setPassword(string $password): static
{
$this->password = $password;
return $this;
}
public function getRoles(): array
@@ -156,7 +244,8 @@ class Prestaire implements UserInterface, PasswordAuthenticatedUserInterface
public function eraseCredentials(): void
{
// TODO: Implement eraseCredentials() method.
// If you store any temporary, sensitive data on the user, clear it here
// $this->plainPassword = null;
}
public function getUserIdentifier(): string

View File

@@ -0,0 +1,39 @@
<?php
namespace App\Form;
use App\Entity\Prestaire;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class PrestaireType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('name', TextType::class, [
'label' => 'Nom',
'required' => true,
])
->add('surname', TextType::class, [
'label' => 'Prénom',
'required' => true,
])
->add('email', EmailType::class, [
'label' => 'Email',
'required' => true,
])
->add('phone', TextType::class, [
'label' => 'Téléphone',
'required' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefault('data_class', Prestaire::class);
}
}

View File

@@ -31,13 +31,27 @@ class PrestaireRepository extends ServiceEntityRepository
// ;
// }
// public function findOneBySomeField($value): ?Prestaire
// {
// return $this->createQueryBuilder('p')
// ->andWhere('p.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
public function findOneByNameAndEmail(string $name, string $email): ?Prestaire
{
return $this->createQueryBuilder('p')
->andWhere('p.name = :name')
->andWhere('p.email = :email')
->setParameter('name', $name)
->setParameter('email', $email)
->getQuery()
->getOneOrNullResult();
}
/**
* @return Prestaire[]
*/
public function search(string $query): array
{
return $this->createQueryBuilder('p')
->andWhere('p.name LIKE :query OR p.surname LIKE :query OR p.email LIKE :query')
->setParameter('query', '%' . $query . '%')
->orderBy('p.name', 'ASC')
->getQuery()
->getResult();
}
}

View File

@@ -93,6 +93,7 @@ class Client
// Stockage de l'ID submitter de Docuseal dans ton entité
$devis->setSignatureId($submission['submitters'][1]['id']);
$this->entityManager->persist($devis);
$this->entityManager->flush();
}

View File

@@ -45,6 +45,7 @@
{{ menu.nav_link(path('app_crm_formules'), 'Formules', '<path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>', 'app_crm_formules') }}
{{ menu.nav_link(path('app_crm_facture'), 'Facture', '<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>', 'app_crm_facture') }}
{{ menu.nav_link(path('app_crm_customer'), 'Clients', '<path d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"></path>', 'app_clients') }}
{{ menu.nav_link(path('app_crm_prestataire'), 'Prestataires', '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 13.255A23.931 23.931 0 0112 15c-3.183 0-6.22-.62-9-1.745M16 6V4a2 2 0 00-2-2h-4a2 2 0 00-2 2v2m4 6h.01M5 20h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>', 'app_crm_prestataire') }}
{% set pendingCount = getPendingOrderSessionCount() %}
<a data-turbo="false" href="{{ path('app_crm_flow') }}" class="flex items-center justify-between px-4 py-3 rounded-xl transition-all duration-200 group {{ app.current_route == 'app_crm_flow' ? 'bg-blue-600 text-white shadow-lg shadow-blue-500/30' : 'hover:bg-slate-800 text-slate-400' }}">

View File

@@ -102,7 +102,7 @@
<div class="flex items-center justify-end space-x-2">
{# Renvoyer lien de signature #}
{% if quote.state == "created_waitsign" and quote.state == "wait-send" %}
{% if quote.state == "created_waitsign" or quote.state == "wait-send" %}
<a data-turbo="false" href="{{ path('app_crm_devis', {resend: quote.id}) }}"
title="Renvoyer le lien de signature"
class="p-2 bg-indigo-600/10 hover:bg-indigo-600 text-indigo-500 hover:text-white rounded-xl transition-all border border-indigo-500/20 shadow-lg shadow-indigo-600/5">
@@ -185,6 +185,37 @@
</div>
</div>
{# LÉGENDE #}
<div class="mt-6 p-4 rounded-2xl bg-[#1e293b]/40 border border-white/5 backdrop-blur-sm">
<p class="text-[10px] font-black text-slate-400 uppercase tracking-[0.2em] mb-3">Légende des actions</p>
<div class="flex flex-wrap gap-4 text-xs text-slate-300">
<div class="flex items-center gap-2">
<div class="p-1.5 bg-indigo-600/10 text-indigo-500 rounded-lg border border-indigo-500/20"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg></div>
<span>Renvoyer le lien</span>
</div>
<div class="flex items-center gap-2">
<div class="p-1.5 bg-blue-600/10 text-blue-500 rounded-lg border border-blue-500/20"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg></div>
<span>Modifier</span>
</div>
<div class="flex items-center gap-2">
<div class="p-1.5 bg-emerald-600/10 text-emerald-500 rounded-lg border border-emerald-500/20"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg></div>
<span>Télécharger Devis Signé</span>
</div>
<div class="flex items-center gap-2">
<div class="p-1.5 bg-purple-600/10 text-purple-500 rounded-lg border border-purple-500/20"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg></div>
<span>Télécharger Certificat Audit</span>
</div>
<div class="flex items-center gap-2">
<div class="p-1.5 bg-slate-600/10 text-slate-300 rounded-lg border border-slate-500/20"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg></div>
<span>Télécharger Devis PDF</span>
</div>
<div class="flex items-center gap-2">
<div class="p-1.5 bg-rose-500/10 text-rose-500 rounded-lg border border-rose-500/20"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg></div>
<span>Supprimer</span>
</div>
</div>
</div>
{# PAGINATION #}
{% if quotes.getTotalItemCount is defined and quotes.getTotalItemCount > quotes.getItemNumberPerPage %}
<div class="mt-8 flex justify-center custom-pagination">

View File

@@ -73,7 +73,18 @@
</select>
</div>
</div>
<div class="flex justify-end">
<div class="mt-4">
<label class="block text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-1">Prestataire (Livraison)</label>
<select name="prestataire" class="w-full bg-slate-900/50 border border-slate-700 rounded-lg px-3 py-2 text-white text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none transition-all">
<option value="">-- Aucun prestataire assigné --</option>
{% for p in prestataires %}
<option value="{{ p.id }}" {% if session.prestataire and session.prestataire.id == p.id %}selected{% endif %}>
{{ p.surname }} {{ p.name }} ({{ p.email }})
</option>
{% endfor %}
</select>
</div>
<div class="flex justify-end mt-4">
<button type="submit" class="py-2 px-6 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-bold uppercase tracking-widest rounded-lg transition-all">
Enregistrer les modifications
</button>

View File

@@ -0,0 +1,104 @@
{% extends 'dashboard/base.twig' %}
{% block title %}Prestataires{% endblock %}
{% block actions %}
<a data-turbo="false" href="{{ path('app_crm_prestataire_add') }}"
class="inline-flex items-center px-4 py-2 text-sm font-black uppercase tracking-widest text-white bg-indigo-600 rounded-xl hover:bg-indigo-700 shadow-lg shadow-indigo-500/20 transition-all active:scale-95">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Ajouter un Prestataire
</a>
{% endblock %}
{% block body %}
<div class="p-4 md:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen w-full">
<div class="w-full">
{# HEADER #}
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-black text-gray-800 dark:text-white tracking-tight">Liste des Prestataires</h1>
<p class="text-sm text-gray-500 dark:text-gray-200 font-medium mt-1">Gestion des prestataires externes.</p>
</div>
<div class="flex items-center space-x-3">
<span class="px-4 py-2 text-xs font-black text-indigo-600 bg-indigo-100 rounded-full dark:bg-indigo-900/40 dark:text-indigo-300 uppercase tracking-widest shadow-sm border border-indigo-200 dark:border-indigo-800">
{{ prestataires|length }} Prestataires
</span>
</div>
</div>
{# TABLE CARD #}
<div class="bg-white dark:bg-[#1e293b] shadow-sm rounded-3xl border border-gray-200 dark:border-gray-800 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-gray-500 dark:text-gray-200">
<thead class="text-[10px] text-gray-200 uppercase bg-gray-50/50 dark:bg-gray-900/50 dark:text-gray-500 border-b border-gray-100 dark:border-gray-800">
<tr>
<th scope="col" class="px-8 py-5 font-black tracking-widest">Identité</th>
<th scope="col" class="px-8 py-5 font-black tracking-widest">Téléphone</th>
<th scope="col" class="px-8 py-5 font-black tracking-widest text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100 dark:divide-gray-800">
{% for prestataire in prestataires %}
<tr class="bg-white dark:bg-[#1e293b] hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors group">
{# COLONNE 1 : IDENTITÉ #}
<td class="px-8 py-5">
<div class="flex items-center space-x-4">
<div class="h-11 w-11 rounded-2xl bg-slate-100 dark:bg-slate-800 flex items-center justify-center text-slate-300 dark:text-slate-400 font-black text-xs border border-slate-200 dark:border-slate-700 shadow-sm transition-transform group-hover:scale-110">
{{ prestataire.surname|first|upper }}{{ prestataire.name|first|upper }}
</div>
<div class="flex flex-col">
<span class="font-bold text-slate-900 dark:text-white text-base">{{ prestataire.surname }} {{ prestataire.name }}</span>
<span class="text-xs text-slate-400 font-medium tracking-tight font-mono">{{ prestataire.email }}</span>
</div>
</div>
</td>
{# COLONNE 2 : TELEPHONE #}
<td class="px-8 py-5">
<span class="text-slate-500 dark:text-slate-300">{{ prestataire.phone|default('N/A') }}</span>
</td>
{# COLONNE 3 : ACTIONS #}
<td class="px-8 py-5 text-right whitespace-nowrap">
<div class="flex items-center justify-end space-x-3">
{# Bouton Gérer #}
<a data-turbo="false" href="{{ path('app_crm_prestataire_view', {id: prestataire.id}) }}"
class="flex items-center space-x-2 px-3 py-2 bg-slate-50 dark:bg-slate-800 text-slate-600 dark:text-slate-300 hover:bg-indigo-600 hover:text-white dark:hover:bg-indigo-600 dark:hover:text-white rounded-xl transition-all border border-slate-200 dark:border-slate-700 font-bold text-xs shadow-sm"
title="Gérer le prestataire">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path></svg>
<span>Gérer</span>
</a>
{# Bouton Supprimer #}
<a data-turbo="false" href="{{ path('app_crm_prestataire_delete', {id: prestataire.id}) }}?_token={{ csrf_token('delete' ~ prestataire.id) }}"
data-turbo-method="post"
data-turbo-confirm="Confirmer la suppression définitive du prestataire {{ prestataire.surname }} {{ prestataire.name }} ?"
class="p-2.5 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-500/10 rounded-xl transition-all border border-transparent hover:border-red-100 dark:hover:border-red-500/20"
title="Supprimer">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
</a>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="3" class="px-8 py-24 text-center">
<div class="flex flex-col items-center">
<div class="w-20 h-20 bg-slate-50 dark:bg-slate-900/50 rounded-3xl flex items-center justify-center text-slate-200 dark:text-slate-800 mb-6">
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path></svg>
</div>
<p class="text-slate-400 font-bold text-lg">Aucun Prestataire enregistré</p>
<p class="text-slate-400/60 text-sm mt-1">Commencez par en ajouter un via le bouton en haut à droite.</p>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,90 @@
{% extends 'dashboard/base.twig' %}
{% block title %}Nouveau Prestataire{% endblock %}
{% block actions %}
<a data-turbo="false" href="{{ path('app_crm_prestataire') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-200 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Retour à la liste
</a>
{% endblock %}
{% block body %}
<div class="p-4 md:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen w-full">
<div class="w-full">
<div class="bg-white dark:bg-gray-800 shadow-md rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="p-8">
{{ form_start(form, {'attr': {'class': 'space-y-8'}}) }}
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-8">
{# PRENOM #}
<div class="space-y-2">
{{ form_label(form.surname, 'Prénom', {
'label_attr': {'class': 'block text-sm font-semibold text-gray-200 dark:text-gray-200'}
}) }}
{{ form_widget(form.surname, {
'attr': {
'class': 'bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-3 dark:bg-gray-700 dark:border-gray-600 dark:text-white',
'placeholder': 'Prénom'
}
}) }}
<div class="text-red-500 text-xs mt-1 italic">{{ form_errors(form.surname) }}</div>
</div>
{# NOM #}
<div class="space-y-2">
{{ form_label(form.name, 'Nom', {
'label_attr': {'class': 'block text-sm font-semibold text-gray-200 dark:text-gray-200'}
}) }}
{{ form_widget(form.name, {
'attr': {
'class': 'bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-3 dark:bg-gray-700 dark:border-gray-600 dark:text-white',
'placeholder': 'Nom'
}
}) }}
<div class="text-red-500 text-xs mt-1 italic">{{ form_errors(form.name) }}</div>
</div>
{# EMAIL #}
<div class="space-y-2">
{{ form_label(form.email, 'Email', {
'label_attr': {'class': 'block text-sm font-semibold text-gray-200 dark:text-gray-200'}
}) }}
{{ form_widget(form.email, {
'attr': {
'class': 'bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-3 dark:bg-gray-700 dark:border-gray-600 dark:text-white',
'placeholder': 'email@example.com'
}
}) }}
<div class="text-red-500 text-xs mt-1 italic">{{ form_errors(form.email) }}</div>
</div>
{# TELEPHONE #}
<div class="space-y-2">
{{ form_label(form.phone, 'Téléphone', {
'label_attr': {'class': 'block text-sm font-semibold text-gray-200 dark:text-gray-200'}
}) }}
{{ form_widget(form.phone, {
'attr': {
'class': 'bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-3 dark:bg-gray-700 dark:border-gray-600 dark:text-white',
'placeholder': '06...'
}
}) }}
<div class="text-red-500 text-xs mt-1 italic">{{ form_errors(form.phone) }}</div>
</div>
</div>
<div class="flex items-center justify-end pt-8 border-t border-gray-200 dark:border-gray-700 mt-10">
<button type="submit" class="flex items-center justify-center text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-bold rounded-lg text-sm px-8 py-3.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 transition-all shadow-lg hover:shadow-blue-500/30">
Créer le prestataire
</button>
</div>
{{ form_end(form) }}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,99 @@
{% extends 'dashboard/base.twig' %}
{% block title %}Gestion Prestataire{% endblock %}
{% block actions %}
<a data-turbo="false" href="{{ path('app_crm_prestataire') }}" class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-200 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Retour à la liste
</a>
{% endblock %}
{% block body %}
<div class="p-4 md:p-6 bg-gray-50 dark:bg-gray-900 min-h-screen w-full">
<div class="w-full">
<div class="flex justify-between items-center mb-8">
<div>
<h1 class="text-3xl font-black text-gray-800 dark:text-white tracking-tight">
{{ prestataire.surname }} {{ prestataire.name }}
</h1>
<p class="text-sm text-gray-500 dark:text-gray-200 font-medium mt-1">Modification des informations.</p>
</div>
</div>
<div class="bg-white dark:bg-gray-800 shadow-md rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<div class="p-8">
{{ form_start(form, {'attr': {'class': 'space-y-8'}}) }}
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-12 gap-y-8">
{# PRENOM #}
<div class="space-y-2">
{{ form_label(form.surname, 'Prénom', {
'label_attr': {'class': 'block text-sm font-semibold text-gray-200 dark:text-gray-200'}
}) }}
{{ form_widget(form.surname, {
'attr': {
'class': 'bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-3 dark:bg-gray-700 dark:border-gray-600 dark:text-white',
'placeholder': 'Prénom'
}
}) }}
<div class="text-red-500 text-xs mt-1 italic">{{ form_errors(form.surname) }}</div>
</div>
{# NOM #}
<div class="space-y-2">
{{ form_label(form.name, 'Nom', {
'label_attr': {'class': 'block text-sm font-semibold text-gray-200 dark:text-gray-200'}
}) }}
{{ form_widget(form.name, {
'attr': {
'class': 'bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-3 dark:bg-gray-700 dark:border-gray-600 dark:text-white',
'placeholder': 'Nom'
}
}) }}
<div class="text-red-500 text-xs mt-1 italic">{{ form_errors(form.name) }}</div>
</div>
{# EMAIL #}
<div class="space-y-2">
{{ form_label(form.email, 'Email', {
'label_attr': {'class': 'block text-sm font-semibold text-gray-200 dark:text-gray-200'}
}) }}
{{ form_widget(form.email, {
'attr': {
'class': 'bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-3 dark:bg-gray-700 dark:border-gray-600 dark:text-white',
'placeholder': 'email@example.com'
}
}) }}
<div class="text-red-500 text-xs mt-1 italic">{{ form_errors(form.email) }}</div>
</div>
{# TELEPHONE #}
<div class="space-y-2">
{{ form_label(form.phone, 'Téléphone', {
'label_attr': {'class': 'block text-sm font-semibold text-gray-200 dark:text-gray-200'}
}) }}
{{ form_widget(form.phone, {
'attr': {
'class': 'bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-3 dark:bg-gray-700 dark:border-gray-600 dark:text-white',
'placeholder': '06...'
}
}) }}
<div class="text-red-500 text-xs mt-1 italic">{{ form_errors(form.phone) }}</div>
</div>
</div>
<div class="flex items-center justify-end pt-8 border-t border-gray-200 dark:border-gray-700 mt-10">
<button type="submit" class="flex items-center justify-center text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:outline-none focus:ring-blue-300 font-bold rounded-lg text-sm px-8 py-3.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 transition-all shadow-lg hover:shadow-blue-500/30">
Modifier
</button>
</div>
{{ form_end(form) }}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,34 @@
{% extends 'mails/base.twig' %}
{% block content %}
<mj-text>
Bonjour <strong>{{ datas.prestataire.surname }} {{ datas.prestataire.name }}</strong>,
</mj-text>
<mj-text>
Un compte prestataire a été créé pour vous sur l'Intranet Ludikevent.
</mj-text>
<mj-text>
Voici vos identifiants de connexion :
</mj-text>
<mj-table>
<tr style="border-bottom:1px solid #ecedee;text-align:left;padding:15px 0;">
<th style="padding: 0 15px 0 0;">Identifiant (Email)</th>
<td style="padding: 0 15px;">{{ datas.prestataire.email }}</td>
</tr>
<tr style="border-bottom:1px solid #ecedee;text-align:left;padding:15px 0;">
<th style="padding: 0 15px 0 0;">Mot de passe</th>
<td style="padding: 0 15px;">{{ datas.password }}</td>
</tr>
</mj-table>
<mj-text>
Nous vous conseillons de changer ce mot de passe dès votre première connexion.
</mj-text>
<mj-button href="{{ datas.login_url }}" align="center">
Accéder à mon espace
</mj-button>
<mj-text>
Si le bouton ne fonctionne pas, vous pouvez copier-coller le lien suivant dans votre navigateur :
<br />
<a href="{{ datas.login_url }}" class="link-style">{{ datas.login_url }}</a>
</mj-text>
{% endblock %}