feat(Devis.php): Ajoute liaison Options <-> Devis et méthode get/set.

 feat(options/add.twig): Ajoute template création/édition des options.

♻️ refactor(.env): Met à jour les URLs ngrok pour la synchro Stripe.

🐛 fix(StripeCommand.php): Corrige et améliore la synchro Stripe.

 feat(products.twig): Ajoute gestion et affichage des options.

 feat(Client.php): Ajoute gestion des options (CRUD) pour Stripe.

 feat(vich_uploader.yaml): Ajoute configuration pour upload images options.

 feat(ProductController.php): Gère les options (CRUD) dans le contrôleur.

 feat(OptionsType.php): Ajoute formulaire pour la gestion des options.
```
This commit is contained in:
Serreau Jovann
2026-01-22 09:19:04 +01:00
parent a4ee1c3379
commit 5ab4b06d7d
14 changed files with 988 additions and 293 deletions

4
.env
View File

@@ -83,8 +83,8 @@ STRIPE_PK=pk_test_51SUA22173W4aeFB1nO6oFfDZ12HOTffDKtCshhZ8rkUg6kUO2ZaQC0tK72rhE
STRIPE_SK=sk_test_51SUA22173W4aeFB16EB2LxGI0hNvNJzFshDI98zRImWBIhSfzqOGAz5TlPxSpUWbj3x4COm6kmSsaal9FpQR1A7M0022DvjbbR
STRIPE_WEBHOOKS_SECRET=
SIGN_URL=https://e2221f1f9e85.ngrok-free.app
STRIPE_BASEURL=https://e2221f1f9e85.ngrok-free.app
SIGN_URL=https://785fe10a414b.ngrok-free.app
STRIPE_BASEURL=https://785fe10a414b.ngrok-free.app
MINIO_S3_URL=
MINIO_S3_CLIENT_ID=

View File

@@ -5,6 +5,10 @@ vich_uploader:
uri_prefix: /images/image_product
upload_destination: '%kernel.project_dir%/public/images/image_product'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
image_options:
uri_prefix: /images/image_options
upload_destination: '%kernel.project_dir%/public/images/image_options'
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
devis_file:
uri_prefix: /pdf/devis_file
upload_destination: '%kernel.project_dir%/public/pdf/devis_file'

View File

@@ -0,0 +1,38 @@
<?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 Version20260122074529 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE TABLE options (id SERIAL NOT NULL, name VARCHAR(255) NOT NULL, price_ht DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))');
$this->addSql('ALTER TABLE devis ADD options_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE devis ADD CONSTRAINT FK_8B27C52B3ADB05F1 FOREIGN KEY (options_id) REFERENCES options (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_8B27C52B3ADB05F1 ON devis (options_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE devis DROP CONSTRAINT FK_8B27C52B3ADB05F1');
$this->addSql('DROP TABLE options');
$this->addSql('DROP INDEX IDX_8B27C52B3ADB05F1');
$this->addSql('ALTER TABLE devis DROP options_id');
}
}

View File

@@ -0,0 +1,32 @@
<?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 Version20260122075732 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE options ADD stripe_id VARCHAR(255) NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE options DROP stripe_id');
}
}

View File

@@ -0,0 +1,37 @@
<?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 Version20260122081226 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE options ADD image_name VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE options ADD image_size INT DEFAULT NULL');
$this->addSql('ALTER TABLE options ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
$this->addSql('COMMENT ON COLUMN options.updated_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE options DROP image_name');
$this->addSql('ALTER TABLE options DROP image_size');
$this->addSql('ALTER TABLE options DROP updated_at');
}
}

View File

@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace App\Command;
use App\Entity\Customer;
use App\Entity\Options;
use App\Entity\Product;
use App\Service\Stripe\Client;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
@@ -33,14 +35,10 @@ class StripeCommand extends Command
// 1. Synchronisation des clients manquants
$io->section('Synchronisation des clients');
$customers = $this->entityManager->getRepository(Customer::class)->findBy(['customerId' => null]);
if (empty($customers)) {
$io->success('Tous les clients sont déjà synchronisés.');
} else {
$io->progressStart(count($customers));
$customers = $this->entityManager->getRepository(Customer::class)->findAll();
foreach ($customers as $customer) {
if($customer->getCustomerId() == null) {
$result = $this->client->createCustomer($customer);
if ($result['state']) {
@@ -48,15 +46,56 @@ class StripeCommand extends Command
} else {
$io->error(sprintf('Échec pour %s : %s', $customer->getEmail(), $result['message']));
}
} else {
$result = $this->client->updateCustomer($customer);
$io->progressAdvance();
if ($result['state']) {
$this->entityManager->persist($customer);
} else {
$io->error(sprintf('Échec pour %s : %s', $customer->getEmail(), $result['message']));
}
}
}
$this->entityManager->flush();
$io->progressFinish();
$io->success('Synchronisation des clients terminée.');
$io->section('Synchronisation des product');
$products = $this->entityManager->getRepository(Product::class)->findAll();
foreach ($products as $product) {
if($product->getProductId() == null) {
$result = $this->client->createProduct($product);
if ($result['state']) {
$this->entityManager->persist($product);
} else {
$io->error(sprintf('Échec pour %s : %s', $product->getName(), $result['message']));
}
} else {
$result = $this->client->updateProduct($product);
if ($result['state']) {
$this->entityManager->persist($product);
} else {
$io->error(sprintf('Échec pour %s : %s', $product->getName(), $result['message']));
}
}
}
$io->section('Synchronisation des options');
$options = $this->entityManager->getRepository(Options::class)->findAll();
foreach ($options as $option) {
if($option->getStripeId() == "") {
$result = $this->client->createOptions($option);
if ($result['state']) {
$this->entityManager->persist($option);
} else {
$io->error(sprintf('Échec pour %s : %s', $option->getName(), $result['message']));
}
} else {
$result = $this->client->updateOptions($option);
if ($result['state']) {
$this->entityManager->persist($option);
} else {
$io->error(sprintf('Échec pour %s : %s', $option->getName(), $result['message']));
}
}
}
// 2. Configuration des Webhooks
$io->section('Configuration des Webhooks');
$this->client->webhooks();

View File

@@ -3,16 +3,19 @@
namespace App\Controller\Dashboard;
use App\Entity\Account;
use App\Entity\Options;
use App\Entity\Product;
use App\Event\Object\EventAdminCreate;
use App\Event\Object\EventAdminDeleted;
use App\Form\AccountPasswordType;
use App\Form\AccountType;
use App\Form\OptionsType;
use App\Form\ProductType;
use App\Logger\AppLogger;
use App\Repository\AccountLoginRegisterRepository;
use App\Repository\AccountRepository;
use App\Repository\AuditLogRepository;
use App\Repository\OptionsRepository;
use App\Repository\ProductRepository;
use App\Service\Mailer\Mailer;
use App\Service\Stripe\Client;
@@ -51,14 +54,59 @@ class ProductController extends AbstractController
return $this->json($products);
}
#[Route(path: '/crm/products', name: 'app_crm_product', options: ['sitemap' => false], methods: ['GET'])]
public function products(ProductRepository $productRepository,AppLogger $appLogger,PaginatorInterface $paginator,Request $request): Response
public function products(OptionsRepository $optionsRepository, ProductRepository $productRepository, AppLogger $appLogger, PaginatorInterface $paginator, Request $request): Response
{
$appLogger->record('VIEW','Consultation liste des produits');
return $this->render('dashboard/products.twig', [
'products' => $paginator->paginate($productRepository->findBy([],['ref'=>'asc']), $request->get('page', 1), 10),
// Utilisation de 'product_page' pour les produits
'products' => $paginator->paginate(
$productRepository->findBy([], ['ref' => 'asc']),
$request->query->getInt('product_page', 1),
10,
['pageParameterName' => 'product_page'] // <--- Important
),
// Utilisation de 'option_page' pour les options
'options' => $paginator->paginate(
$optionsRepository->findBy([], ['id' => 'asc']),
$request->query->getInt('option_page', 1),
10,
['pageParameterName' => 'option_page'] // <--- Important
),
]);
}
#[Route(path: '/crm/products/options/add', name: 'app_crm_product_options_add', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function optionsAdd(
EntityManagerInterface $entityManager,
AppLogger $appLogger,
Client $client,
Request $request,
MessageBusInterface $messageBus,
): Response {
$appLogger->record('VIEW', 'Consultation page création d\'une options');
$options = new Options();
$form = $this->createForm(OptionsType::class, $options);
$form->handleRequest($request);
if($form->isSubmitted() && $form->isValid()) {
$productName = $options->getName();
$appLogger->record('CREATE', sprintf('Création du options : %s', $productName));
$options->setStripeId("");
$options->setUpdatedAt(new \DateTimeImmutable());
$client->createOptions($options);
$entityManager->persist($options);
$entityManager->flush();
$messageBus->dispatch(new DumpSitemapMessage());
$this->addFlash('success', sprintf('L\'options "%s" a été ajouté au catalogue avec succès.', $productName));
return $this->redirectToRoute('app_crm_product');
}
return $this->render('dashboard/options/add.twig', [
'form' => $form->createView(),
'product' => $options // Optionnel, utile pour l'aperçu d'image si défini
]);
}
#[Route(path: '/crm/products/add', name: 'app_crm_product_add', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function productAdd(
EntityManagerInterface $entityManager,
@@ -139,6 +187,47 @@ class ProductController extends AbstractController
'is_edit' => true
]);
}
#[Route(path: '/crm/products/options/edit/{id}', name: 'app_crm_product_options_edit', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function productOptions(
Options $product,
EntityManagerInterface $entityManager,
AppLogger $appLogger,
Request $request,
Client $stripeService
): Response {
$appLogger->record('VIEW', 'Consultation modification options : ' . $product->getName());
$form = $this->createForm(OptionsType::class, $product);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// 1. Mise à jour Stripe si le produit possède un ID Stripe
if ($product->getStripeId()) {
$stripeResult = $stripeService->updateOptions($product);
if (!$stripeResult['state']) {
$this->addFlash('warning', 'Erreur synchro Stripe : ' . $stripeResult['message']);
}
}
// 2. Sauvegarde en base locale
$entityManager->flush();
$appLogger->record('UPDATE', 'Mise à jour de l\'options : ' . $product->getName());
$this->addFlash('success', 'Le options a été mis à jour avec succès.');
return $this->redirectToRoute('app_crm_product');
}
return $this->render('dashboard/options/add.twig', [
'form' => $form->createView(),
'options' => $product,
'is_edit' => true
]);
}
#[Route(path: '/crm/products/delete/{id}', name: 'app_crm_product_delete', options: ['sitemap' => false], methods: ['POST'])]
public function productDelete(
Product $product,
@@ -169,4 +258,34 @@ class ProductController extends AbstractController
// 4. Redirection vers le catalogue
return $this->redirectToRoute('app_crm_product'); // Remplace par le nom de ta route de listing
}
#[Route(path: '/crm/products/options/delete/{id}', name: 'app_crm_product_option_delete', options: ['sitemap' => false], methods: ['POST'])]
public function productOptionsDelete(
Options $product,
EntityManagerInterface $entityManager,
Request $request,
AppLogger $appLogger,
Client $client
): Response {
// 1. Vérification du jeton CSRF (sécurité contre les suppressions via URL forcée)
if ($this->isCsrfTokenValid('delete' . $product->getId(), $request->query->get('_token'))) {
$productName = $product->getName();
// 2. Log de l'action avant suppression
$appLogger->record('DELETE', sprintf('Suppression du produit : %s', $productName));
$client->deleteOptions($product);
// 3. Suppression en base de données
$entityManager->remove($product);
$entityManager->flush();
$this->addFlash('success', sprintf('L\'options "%s" a été supprimé avec succès.', $productName));
} else {
$this->addFlash('error', 'Jeton de sécurité invalide. Impossible de supprimer le produit.');
}
// 4. Redirection vers le catalogue
return $this->redirectToRoute('app_crm_product'); // Remplace par le nom de ta route de listing
}
}

View File

@@ -83,6 +83,9 @@ class Devis
#[ORM\OneToOne(mappedBy: 'devis', cascade: ['persist', 'remove'])]
private ?Contrats $contrats = null;
#[ORM\ManyToOne(inversedBy: 'devis')]
private ?Options $options = null;
public function __construct()
{
$this->devisLines = new ArrayCollection();
@@ -475,4 +478,16 @@ class Devis
return $this;
}
public function getOptions(): ?Options
{
return $this->options;
}
public function setOptions(?Options $options): static
{
$this->options = $options;
return $this;
}
}

175
src/Entity/Options.php Normal file
View File

@@ -0,0 +1,175 @@
<?php
namespace App\Entity;
use App\Repository\OptionsRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Attribute\Uploadable;
use Vich\UploaderBundle\Mapping\Attribute\UploadableField;
#[ORM\Entity(repositoryClass: OptionsRepository::class)]
#[Uploadable]
class Options
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(length: 255)]
private ?string $name = null;
#[ORM\Column]
private ?float $priceHt = null;
/**
* @var Collection<int, Devis>
*/
#[ORM\OneToMany(targetEntity: Devis::class, mappedBy: 'options')]
private Collection $devis;
#[ORM\Column(length: 255)]
private ?string $stripeId = null;
#[UploadableField(mapping: 'image_options', fileNameProperty: 'imageName', size: 'imageSize')]
private ?File $imageFile = null;
#[ORM\Column(nullable: true)]
private ?string $imageName = null;
#[ORM\Column(nullable: true)]
private ?int $imageSize = null;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $updatedAt = null;
public function __construct()
{
$this->devis = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
}
public function getName(): ?string
{
return $this->name;
}
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function getPriceHt(): ?float
{
return $this->priceHt;
}
public function setPriceHt(float $priceHt): static
{
$this->priceHt = $priceHt;
return $this;
}
/**
* @return Collection<int, Devis>
*/
public function getDevis(): Collection
{
return $this->devis;
}
public function addDevi(Devis $devi): static
{
if (!$this->devis->contains($devi)) {
$this->devis->add($devi);
$devi->setOptions($this);
}
return $this;
}
public function removeDevi(Devis $devi): static
{
if ($this->devis->removeElement($devi)) {
// set the owning side to null (unless already changed)
if ($devi->getOptions() === $this) {
$devi->setOptions(null);
}
}
return $this;
}
public function getStripeId(): ?string
{
return $this->stripeId;
}
public function setStripeId(string $stripeId): static
{
$this->stripeId = $stripeId;
return $this;
}
public function setImageFile(?File $imageFile = null): void
{
$this->imageFile = $imageFile;
if (null !== $imageFile) {
// It is required that at least one field changes if you are using doctrine
// otherwise the event listeners won't be called and the file is lost
$this->updatedAt = new \DateTimeImmutable();
}
}
public function getImageFile(): ?File
{
return $this->imageFile;
}
public function setImageName(?string $imageName): void
{
$this->imageName = $imageName;
}
public function getImageName(): ?string
{
return $this->imageName;
}
public function setImageSize(?int $imageSize): void
{
$this->imageSize = $imageSize;
}
public function getImageSize(): ?int
{
return $this->imageSize;
}
/**
* @param \DateTimeImmutable|null $updatedAt
*/
public function setUpdatedAt(?\DateTimeImmutable $updatedAt): void
{
$this->updatedAt = $updatedAt;
}
/**
* @return \DateTimeImmutable|null
*/
public function getUpdatedAt(): ?\DateTimeImmutable
{
return $this->updatedAt;
}
}

45
src/Form/OptionsType.php Normal file
View File

@@ -0,0 +1,45 @@
<?php
namespace App\Form;
use App\Entity\Customer;
use App\Entity\Options;
use App\Entity\Product;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\FileType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class OptionsType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name',TextType::class,[
'label' => 'Nom de l\'option',
'required' => true,
])
->add('priceHt',NumberType::class,[
'label' => 'Prix HT',
'required' => true,
])
->add('imageFile',FileType::class,[
'label' => 'Image de l\'options',
'required' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Options::class,
]);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\Options;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<Options>
*/
class OptionsRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Options::class);
}
// /**
// * @return Options[] Returns an array of Options objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('o')
// ->andWhere('o.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('o.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?Options
// {
// return $this->createQueryBuilder('o')
// ->andWhere('o.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -4,23 +4,47 @@ namespace App\Service\Stripe;
use App\Entity\Customer;
use App\Entity\StripeConfig;
use App\Entity\Product;
use App\Entity\Options;
use Doctrine\ORM\EntityManagerInterface;
use Stripe\Exception\ApiConnectionException;
use Stripe\Exception\ApiErrorException;
use Stripe\Exception\AuthenticationException;
use Stripe\StripeClient;
use Vich\UploaderBundle\Templating\Helper\UploaderHelper;
class Client
{
private StripeClient $client;
private mixed $stripeBaseUrl;
public function __construct(
private EntityManagerInterface $em
private EntityManagerInterface $em,
private UploaderHelper $uploaderHelper,
) {
$stripeSk = $_ENV['STRIPE_SK'] ?? '';
$this->stripeBaseUrl = $_ENV['STRIPE_BASEURL'] ?? '';
$this->client = new StripeClient($stripeSk);
}
/**
* Génère l'URL absolue de l'image pour Stripe
*/
private function getImageUrl($entity): ?array
{
$path = $this->uploaderHelper->asset($entity, 'imageFile');
if (!$path) {
return null;
}
// Construction de l'URL absolue (Stripe nécessite un accès public à l'image)
$fullUrl = str_starts_with($path, 'http')
? $path
: rtrim($this->stripeBaseUrl, '/') . $path;
return [$fullUrl];
}
/**
* Vérifie la connexion avec l'API Stripe
*/
@@ -28,22 +52,18 @@ class Client
{
try {
$this->client->accounts->all(['limit' => 1]);
return [
'state' => true,
'message' => 'Connexion établie avec Stripe'
];
} catch (AuthenticationException $e) {
return ['state' => false, 'message' => 'Clé API Stripe invalide ou expirée.'];
} catch (ApiConnectionException $e) {
return ['state' => false, 'message' => 'Problème de connexion réseau avec Stripe.'];
return ['state' => true, 'message' => 'Connexion établie avec Stripe'];
} catch (AuthenticationException) {
return ['state' => false, 'message' => 'Clé API Stripe invalide.'];
} catch (ApiConnectionException) {
return ['state' => false, 'message' => 'Problème de connexion réseau.'];
} catch (\Exception $e) {
return ['state' => false, 'message' => 'Erreur : ' . $e->getMessage()];
}
}
/**
* Crée un client sur Stripe et met à jour l'entité locale
*/
// --- GESTION DES CLIENTS ---
public function createCustomer(Customer $customer): array
{
try {
@@ -59,64 +79,162 @@ class Client
]);
$customer->setCustomerId($stripeCustomer->id);
return [
'state' => true,
'message' => 'Client synchronisé avec succès.',
'id' => $stripeCustomer->id
];
return ['state' => true, 'id' => $stripeCustomer->id];
} catch (ApiErrorException $e) {
return ['state' => false, 'message' => 'Erreur Stripe : ' . $e->getMessage()];
return ['state' => false, 'message' => $e->getMessage()];
}
}
/**
* Supprime un client sur Stripe
*/
public function deleteCustomer(?string $stripeCustomerId): array
public function updateCustomer(Customer $customer): array
{
if (!$stripeCustomerId) {
return [
'state' => false,
'message' => 'Aucun ID Stripe fourni pour la suppression.'
];
}
if (!$customer->getCustomerId()) return ['state' => false, 'message' => 'Pas d\'ID Stripe'];
try {
$this->client->customers->delete($stripeCustomerId, []);
return [
'state' => true,
'message' => 'Client supprimé avec succès sur Stripe.'
];
$this->client->customers->update($customer->getCustomerId(), [
'name' => sprintf('%s %s', $customer->getSurname(), $customer->getName()),
'email' => $customer->getEmail(),
'phone' => $customer->getPhone(),
'metadata' => [
'internal_id' => $customer->getId(),
'updated_at' => (new \DateTime())->format('Y-m-d H:i:s')
],
]);
return ['state' => true, 'message' => 'Client mis à jour'];
} catch (ApiErrorException $e) {
// Si le client n'existe plus sur Stripe, on considère cela comme un succès pour le CRM
if ($e->getStripeCode() === 'resource_missing') {
return [
'state' => true,
'message' => 'Client déjà absent sur Stripe.'
];
return ['state' => false, 'message' => $e->getMessage()];
}
}
return [
'state' => false,
'message' => 'Erreur API Stripe lors de la suppression : ' . $e->getMessage()
public function deleteCustomer(?string $stripeCustomerId): array
{
if (!$stripeCustomerId) return ['state' => false];
try {
$this->client->customers->delete($stripeCustomerId, []);
return ['state' => true];
} catch (ApiErrorException $e) {
if ($e->getStripeCode() === 'resource_missing') return ['state' => true];
return ['state' => false, 'message' => $e->getMessage()];
}
}
// --- GESTION DES PRODUITS ---
public function createProduct(Product $product): array
{
try {
$metadata = ['internal_id' => $product->getId(), 'reference' => $product->getRef()];
$params = [
'name' => $product->getName(),
'description' => sprintf('REF: %s | CAT: %s', $product->getRef(), $product->getCategory()),
'metadata' => $metadata,
'active' => true,
];
if ($images = $this->getImageUrl($product)) { $params['images'] = $images; }
$stripeProduct = $this->client->products->create($params);
$this->client->prices->create([
'unit_amount' => (int)($product->getPriceDay() * 100),
'currency' => 'eur',
'product' => $stripeProduct->id,
]);
$product->setProductId($stripeProduct->id);
return ['state' => true, 'id' => $stripeProduct->id];
} catch (\Exception $e) {
return [
'state' => false,
'message' => 'Erreur système : ' . $e->getMessage()
];
return ['state' => false, 'message' => $e->getMessage()];
}
}
/**
* Configure, met à jour et sauvegarde les secrets des Webhooks
*/
public function updateProduct(Product $product): array
{
try {
$params = [
'name' => $product->getName(),
'description' => sprintf('REF: %s | CAT: %s', $product->getRef(), $product->getCategory()),
];
if ($images = $this->getImageUrl($product)) { $params['images'] = $images; }
$this->client->products->update($product->getProductId(), $params);
$this->client->prices->create([
'unit_amount' => (int)($product->getPriceDay() * 100),
'currency' => 'eur',
'product' => $product->getProductId(),
]);
return ['state' => true, 'message' => 'Produit mis à jour'];
} catch (\Exception $e) {
return ['state' => false, 'message' => $e->getMessage()];
}
}
public function deleteProduct(Product $product): array
{
if (!$product->getProductId()) return ['state' => true];
try {
$this->client->products->update($product->getProductId(), ['active' => false]);
return ['state' => true];
} catch (\Exception $e) {
return ['state' => false, 'message' => $e->getMessage()];
}
}
// --- GESTION DES OPTIONS ---
public function createOptions(Options $options): array
{
try {
$params = [
'name' => $options->getName(),
'metadata' => ['internal_id' => $options->getId()],
'active' => true,
];
if ($images = $this->getImageUrl($options)) { $params['images'] = $images; }
$stripeProduct = $this->client->products->create($params);
$this->client->prices->create([
'unit_amount' => (int)($options->getPriceHt() * 100),
'currency' => 'eur',
'product' => $stripeProduct->id,
]);
$options->setStripeId($stripeProduct->id);
return ['state' => true, 'id' => $stripeProduct->id];
} catch (\Exception $e) {
return ['state' => false, 'message' => $e->getMessage()];
}
}
public function updateOptions(Options $options): array
{
try {
$params = ['name' => $options->getName()];
if ($images = $this->getImageUrl($options)) { $params['images'] = $images; }
$this->client->products->update($options->getStripeId(), $params);
$this->client->prices->create([
'unit_amount' => (int)($options->getPriceHt() * 100),
'currency' => 'eur',
'product' => $options->getStripeId(),
]);
return ['state' => true, 'message' => 'Option mise à jour'];
} catch (\Exception $e) {
return ['state' => false, 'message' => $e->getMessage()];
}
}
// --- WEBHOOKS & UTILS ---
public function webhooks(): array
{
$baseUrl = $_ENV['STRIPE_BASEURL'] ?? 'https://votre-domaine.fr';
$baseUrl = rtrim($this->stripeBaseUrl, '/');
$configs = [
'refund' => [
'url' => $baseUrl . '/webhooks/refund',
@@ -124,66 +242,61 @@ class Client
],
'payment' => [
'url' => $baseUrl . '/webhooks/payment-intent',
'events' => [
'payment_intent.created',
'payment_intent.canceled',
'payment_intent.succeeded',
'payment_intent.amount_capturable_updated'
]
'events' => ['payment_intent.succeeded', 'payment_intent.canceled']
]
];
$report = [];
try {
$existingEndpoints = $this->client->webhookEndpoints->all(['limit' => 100]);
$endpoints = $this->client->webhookEndpoints->all();
foreach ($configs as $name => $config) {
$stripeEndpoint = null;
foreach ($existingEndpoints->data as $endpoint) {
if ($endpoint->url === $config['url']) {
$stripeEndpoint = $endpoint;
break;
}
foreach ($endpoints->data as $ep) {
if ($ep->url === $config['url']) { $stripeEndpoint = $ep; break; }
}
$dbConfig = $this->em->getRepository(StripeConfig::class)->findOneBy(['name' => $name]);
if (!$dbConfig) {
$dbConfig = new StripeConfig();
$dbConfig = $this->em->getRepository(StripeConfig::class)->findOneBy(['name' => $name]) ?? new StripeConfig();
$dbConfig->setName($name);
}
if ($stripeEndpoint) {
$this->client->webhookEndpoints->update($stripeEndpoint->id, [
$this->client->webhookEndpoints->update($stripeEndpoint->id, ['enabled_events' => $config['events']]);
$dbConfig->setWebhookId($stripeEndpoint->id);
} else {
$new = $this->client->webhookEndpoints->create([
'url' => $config['url'],
'enabled_events' => $config['events']
]);
$dbConfig->setWebhookId($stripeEndpoint->id);
$report[$name] = ['status' => 'updated', 'url' => $config['url']];
} else {
$newEndpoint = $this->client->webhookEndpoints->create([
'url' => $config['url'],
'enabled_events' => $config['events'],
'description' => 'Ludikevent Webhook - ' . $name
]);
$dbConfig->setWebhookId($newEndpoint->id);
$dbConfig->setSecret($newEndpoint->secret);
$report[$name] = ['status' => 'created', 'url' => $config['url']];
$dbConfig->setWebhookId($new->id);
$dbConfig->setSecret($new->secret);
}
$this->em->persist($dbConfig);
}
$this->em->flush();
return ['state' => true, 'data' => $report];
} catch (ApiErrorException $e) {
return ['state' => false, 'message' => 'Erreur API Stripe : ' . $e->getMessage()];
return ['state' => true];
} catch (\Exception $e) {
return ['state' => false, 'message' => 'Erreur système : ' . $e->getMessage()];
return ['state' => false, 'message' => $e->getMessage()];
}
}
public function deleteOptions(Options $options): array
{
if (!$options->getStripeId()) {
return ['state' => true];
}
try {
$this->client->products->update($options->getStripeId(), [
'active' => false,
'metadata' => [
'deleted_at' => (new \DateTime())->format('Y-m-d H:i:s')
]
]);
return [
'state' => true,
'message' => 'Option désactivée sur Stripe.'
];
} catch (\Exception $e) {
return ['state' => false, 'message' => $e->getMessage()];
}
}
@@ -192,183 +305,8 @@ class Client
return $this->client;
}
/**
* Met à jour un client existant sur Stripe
* @return array ['state' => bool, 'message' => string]
*/
public function updateCustomer(Customer $customer): array
public function status(): bool
{
// On vérifie d'abord si le client possède bien un ID Stripe
if (!$customer->getCustomerId()) {
return [
'state' => false,
'message' => 'Ce client n\'est pas encore lié à un compte Stripe.'
];
}
try {
// Préparation des données de mise à jour
$this->client->customers->update($customer->getCustomerId(), [
'name' => sprintf('%s %s', $customer->getSurname(), $customer->getName()),
'email' => $customer->getEmail(),
'phone' => $customer->getPhone(),
'metadata' => [
'internal_id' => $customer->getId(),
'type' => $customer->getType(),
'siret' => $customer->getSiret() ?? 'N/A',
'updated_at' => (new \DateTime())->format('Y-m-d H:i:s')
],
]);
return [
'state' => true,
'message' => 'Client mis à jour sur Stripe avec succès.'
];
} catch (ApiErrorException $e) {
return [
'state' => false,
'message' => 'Erreur API Stripe : ' . $e->getMessage()
];
} catch (\Exception $e) {
return [
'state' => false,
'message' => 'Erreur système lors de la mise à jour : ' . $e->getMessage()
];
}
}
/**
* Désactive un produit sur Stripe
* @return array ['state' => bool, 'message' => string]
*/
public function deleteProduct(\App\Entity\Product $product): array
{
// Si le produit n'a pas d'ID Stripe, rien à faire côté API
if (!$product->getProductId()) {
return [
'state' => true,
'message' => 'Produit local uniquement, aucune action Stripe requise.'
];
}
try {
// Chez Stripe, on ne "supprime" pas un produit qui peut être lié à des archives,
// on le désactive pour qu'il ne soit plus utilisable.
$this->client->products->update($product->getProductId(), [
'active' => false,
'metadata' => [
'deleted_at' => (new \DateTime())->format('Y-m-d H:i:s'),
'internal_id' => $product->getId()
]
]);
return [
'state' => true,
'message' => 'Le produit a été désactivé avec succès sur Stripe.'
];
} catch (\Stripe\Exception\ApiErrorException $e) {
return [
'state' => false,
'message' => 'Erreur Stripe : ' . $e->getMessage()
];
} catch (\Exception $e) {
return [
'state' => false,
'message' => 'Erreur système : ' . $e->getMessage()
];
}
}
/**
* Crée un produit et son prix par défaut sur Stripe
* @return array ['state' => bool, 'id' => string|null, 'message' => string]
*/
public function createProduct(\App\Entity\Product $product): array
{
try {
// 1. Préparation des métadonnées (utile pour retrouver le produit plus tard)
$metadata = [
'internal_id' => $product->getId(),
'reference' => $product->getRef(),
'category' => $product->getCategory()
];
// 2. Création du Produit sur Stripe
$stripeProduct = $this->client->products->create([
'name' => $product->getName(),
'description' => sprintf('Référence : %s | Catégorie : %s', $product->getRef(), $product->getCategory()),
'metadata' => $metadata,
'active' => true,
]);
// 3. Création du Prix associé (Stripe sépare l'objet Produit de l'objet Prix)
// Note : Stripe attend des montants en centimes (ex: 10.00€ -> 1000)
$stripePrice = $this->client->prices->create([
'unit_amount' => (int)($product->getPriceDay() * 100),
'currency' => 'eur',
'product' => $stripeProduct->id,
'metadata' => $metadata,
]);
// 4. On met à jour l'entité avec l'ID Stripe reçu
$product->setProductId($stripeProduct->id);
return [
'state' => true,
'id' => $stripeProduct->id,
'message' => 'Produit et prix synchronisés sur Stripe.'
];
} catch (\Stripe\Exception\ApiErrorException $e) {
return [
'state' => false,
'message' => 'Erreur Stripe : ' . $e->getMessage()
];
} catch (\Exception $e) {
return [
'state' => false,
'message' => 'Erreur système : ' . $e->getMessage()
];
}
}
/**
* Met à jour le produit et son prix sur Stripe
*/
public function updateProduct(\App\Entity\Product $product): array
{
try {
// 1. Mise à jour des infos de base du produit
$this->client->products->update($product->getProductId(), [
'name' => $product->getName(),
'description' => sprintf('REF: %s | CAT: %s', $product->getRef(), $product->getCategory()),
]);
// 2. Gestion du prix (Stripe recommande de créer un nouveau prix plutôt que d'éditer)
// On récupère le prix actuel pour voir s'il a changé
$currentPriceCents = (int)($product->getPriceDay() * 100);
// Optionnel : Tu peux vérifier ici si le prix a réellement changé avant de recréer
$this->client->prices->create([
'unit_amount' => $currentPriceCents,
'currency' => 'eur',
'product' => $product->getProductId(),
]);
// Note: Stripe utilisera toujours le dernier prix créé par défaut pour les nouvelles sessions
return ['state' => true, 'message' => 'Synchro Stripe OK'];
} catch (\Exception $e) {
return ['state' => false, 'message' => $e->getMessage()];
}
}
public function status()
{
$result = $this->check();
return $result['state'];
return $this->check()['state'];
}
}

View File

@@ -0,0 +1,103 @@
{% extends 'dashboard/base.twig' %}
{% block title %}Fiche Option{% endblock %}
{% block title_header %}Gestion des <span class="text-purple-500">Options</span>{% endblock %}
{% block body %}
<div class="max-w-6xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-700">
{{ form_start(form) }}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
{# --- BLOC VISUEL (Gauche) --- #}
<div class="lg:col-span-2 space-y-8">
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl">
<h3 class="text-lg font-bold text-white mb-6 flex items-center">
<span class="w-8 h-8 bg-purple-600/20 text-purple-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">00</span>
Image du Produit
</h3>
<div class="flex flex-col md:flex-row items-center gap-8">
<div class="relative flex-shrink-0">
<div class="w-48 h-48 rounded-[2rem] overflow-hidden border-2 border-white/10 bg-slate-900/50 flex items-center justify-center">
{# L'image avec un ID spécifique pour le JS #}
<img id="product-image-preview"
src="{{ options.imageName ? vich_uploader_asset(options, 'imageFile') | imagine_filter('webp') : '#' }}"
class="w-full h-full object-cover {{ options.imageName ? '' : 'hidden' }}">
{# L'icône de remplacement #}
<svg id="product-image-placeholder"
class="w-12 h-12 text-slate-700 {{ options.imageName ? 'hidden' : '' }}"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
</div>
<div class="flex-1 w-full">
{{ form_label(form.imageFile, 'Sélectionner un fichier', {'label_attr': {'class': 'text-[10px] font-black text-slate-500 uppercase tracking-widest mb-2 block'}}) }}
{# On enlève le "onchange" inline, on cible via l'ID généré par Symfony ou un ID fixe #}
{{ form_widget(form.imageFile, {
'id': 'product_image_input',
'attr': {
'class': 'block w-full text-xs text-slate-400 file:mr-4 file:py-2.5 file:px-4 file:rounded-xl file:border-0 file:text-[10px] file:font-black file:uppercase file:tracking-widest file:bg-blue-600/10 file:text-blue-500 hover:file:bg-blue-600/20 transition-all cursor-pointer'
}
}) }}
</div>
</div>
</div>
</div>
{# --- BLOC CONFIGURATION (Droite) --- #}
<div class="lg:col-span-1 space-y-8">
<div class="backdrop-blur-xl bg-slate-900/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl">
<h3 class="text-sm font-black text-purple-500 uppercase tracking-widest mb-8 flex items-center">
<span class="w-6 h-6 bg-purple-600/20 rounded-lg flex items-center justify-center mr-3 text-[10px]">01</span>
Détails de l'Option
</h3>
<div class="space-y-8">
{# Nom de l'option #}
<div>
{{ form_label(form.name, 'Nom de l\'option', {'label_attr': {'class': 'text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] ml-1 mb-3 block'}}) }}
{{ form_widget(form.name, {'attr': {'class': 'w-full bg-slate-950/50 border-white/5 rounded-2xl text-white focus:ring-purple-500/20 focus:border-purple-500 transition-all py-4 px-6 text-lg font-bold', 'placeholder': 'ex: Assurance Sérénité, Nettoyage...'}}) }}
</div>
<div class="space-y-6">
<div>
{{ form_label(form.priceHt, 'Prix Ht', {'label_attr': {'class': 'text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1 mb-2 block'}}) }}
<div class="relative">
{{ form_widget(form.priceHt, {'attr': {'class': 'w-full bg-slate-900/50 border-white/5 rounded-2xl text-emerald-400 font-black text-xl focus:ring-emerald-500/20 focus:border-emerald-500 transition-all py-4 px-5'}}) }}
<span class="absolute right-5 top-1/2 -translate-y-1/2 text-slate-600 font-bold">€HT</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{# FOOTER ACTIONS #}
<div class="mt-12 mb-20 flex items-center justify-between backdrop-blur-xl bg-slate-900/40 p-6 rounded-[2.5rem] border border-white/5 shadow-xl">
<a href="{{ path('app_crm_product') }}" class="px-8 py-4 text-[10px] font-black text-slate-400 hover:text-white uppercase tracking-widest transition-colors flex items-center group">
<svg class="w-4 h-4 mr-2 transform group-hover:-translate-x-1 transition-transform" 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" />
</svg>
Annuler
</a>
<button type="submit" class="relative overflow-hidden group px-12 py-5 bg-purple-600 hover:bg-purple-500 text-white text-[10px] font-black uppercase tracking-[0.3em] rounded-2xl transition-all shadow-lg shadow-purple-600/30 flex items-center hover:scale-[1.02] active:scale-95">
<span class="relative z-10 flex items-center">
<svg class="w-4 h-4 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7" />
</svg>
Enregistrer l'option
</span>
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/10 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-1000"></div>
</button>
</div>
{{ form_end(form) }}
</div>
{% endblock %}

View File

@@ -126,6 +126,113 @@
</div>
{% endif %}
<div class="mt-5 flex items-end justify-between mb-10 pb-8 border-b border-slate-200 dark:border-slate-800/50">
<div>
<h1 class="text-4xl font-extrabold text-slate-900 dark:text-white">
Gestion des <span class="text-blue-500">Options</span>
</h1>
</div>
<div class="flex items-center space-x-3">
<div class="flex items-center space-x-3">
<a href="{{ path('app_crm_product_options_add') }}" class="flex items-center space-x-2 px-6 py-3 bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-black uppercase tracking-[0.2em] rounded-xl transition-all shadow-lg shadow-blue-600/20 group">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M12 4v16m8-8H4"></path>
</svg>
<span>Nouvelle options</span>
</a>
</div>
</div>
</div>
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] overflow-hidden shadow-2xl animate-in fade-in duration-700">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
<thead>
<tr class="border-b border-white/5 bg-black/20">
<th class="px-6 py-5 text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">Visuel</th>
<th class="px-6 py-5 text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">Désignation</th>
<th class="px-6 py-5 text-[10px] font-black text-slate-500 uppercase tracking-[0.2em]">Stripe</th>
<th class="px-6 py-5 text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] text-center">Tarif</th>
<th class="px-6 py-5 text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-white/5">
{% for option in options %}
<tr class="group hover:bg-white/[0.02] transition-colors">
{# VISUEL & REF #}
<td class="px-6 py-4">
<div class="flex items-center space-x-4">
<div class="h-12 w-12 rounded-xl overflow-hidden border border-white/10 bg-slate-900 flex-shrink-0">
{% if option.imageName %}
<img src="{{ vich_uploader_asset(option, 'imageFile') | imagine_filter('webp') }}" class="h-full w-full object-cover">
{% else %}
<div class="h-full w-full flex items-center justify-center bg-slate-800 text-slate-600">
<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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
</div>
{% endif %}
</div>
</div>
</td>
<td class="px-6 py-4">
<span class="text-sm font-bold text-white group-hover:text-blue-400 transition-colors capitalize">
{{ option.name }}
</span>
</td>
<td class="px-6 py-4">
{% if option.stripeId != "" %}
<div class="flex items-center text-[8px] font-black text-emerald-400 uppercase tracking-widest">
<span class="h-1.5 w-1.5 rounded-full bg-emerald-500 mr-2 shadow-[0_0_8px_rgba(16,185,129,0.5)]"></span>
Synchronisé
</div>
{% else %}
<div class="flex items-center text-[8px] font-black text-rose-500 uppercase tracking-widest opacity-60">
<span class="h-1.5 w-1.5 rounded-full bg-rose-500 mr-2 animate-pulse"></span>
En attente
</div>
{% endif %}
</td>
{# PRIX #}
<td class="px-6 py-4 text-center">
<span class="text-sm font-black text-emerald-400">
{{ option.priceHt|number_format(2, ',', ' ') }}
</span>
</td>
{# ACTIONS #}
<td class="px-6 py-4 text-right">
<div class="flex items-center justify-end space-x-2">
<a href="{{ path('app_crm_product_options_edit', {id: option.id}) }}" class="p-2 bg-blue-600/10 hover:bg-blue-600 text-blue-500 hover:text-white rounded-xl transition-all border border-blue-500/20 shadow-lg shadow-blue-600/5">
<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" /></svg>
</a>
<a href="{{ path('app_crm_product_option_delete', {id: option.id}) }}?_token={{ csrf_token('delete' ~ option.id) }}"
data-turbo-method="post"
data-turbo-confirm="Confirmer la suppression définitive de '{{ option.name }}' ?"
class="p-2 bg-rose-500/10 hover:bg-rose-500 text-rose-500 hover:text-white rounded-xl transition-all border border-rose-500/20 shadow-lg shadow-rose-500/5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</a>
</div>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="py-24 text-center">
<p class="text-slate-500 italic uppercase tracking-[0.2em] text-[10px] font-black">Aucun options dans le catalogue</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{# PAGINATION #}
{% if options.getTotalItemCount is defined and options.getTotalItemCount > options.getItemNumberPerPage %}
<div class="mt-8 flex justify-center custom-pagination">
{{ knp_pagination_render(options) }}
</div>
{% endif %}
<style>
.custom-pagination nav ul { @apply flex space-x-2; }
.custom-pagination nav ul li span,