diff --git a/.gitignore b/.gitignore index 7edaa75..1aeadb7 100644 --- a/.gitignore +++ b/.gitignore @@ -49,3 +49,7 @@ backup/*.sql /public/sw.js ###< spomky-labs/pwa-bundle ### /sauvegarde/*.zip +/public/images/**/*.jpg +/public/images/**/*.jpeg +/public/images/**/*.webp +/public/images/*/*.png diff --git a/ansible/playbook.yml b/ansible/playbook.yml index 5c3328b..9ddfc52 100644 --- a/ansible/playbook.yml +++ b/ansible/playbook.yml @@ -105,7 +105,7 @@ - "{{ path }}/var" - "{{ path }}/var/log" # Specific for log, though var/log might be created by composer later - "{{ path }}/public/media" # For uploads - - "{{ path }}/public/storage" # For uploads + - "{{ path }}/public/images" # For uploads - "{{ path }}/public/tmp-sign" # For upload - "{{ path }}/sauvegarde" @@ -243,6 +243,6 @@ - "{{ path }}/var/log" - "{{ path }}/public/media" - "{{ path }}/sauvegarde" - - "{{ path }}/public/storage" # For uploads + - "{{ path }}/public/images" # For uploads - "{{ path }}/public/tmp-sign" # For uploads diff --git a/assets/admin.js b/assets/admin.js index 9f0d648..356d34a 100644 --- a/assets/admin.js +++ b/assets/admin.js @@ -21,6 +21,31 @@ Sentry.init({ * Initialise les composants de l'interface d'administration. */ function initAdminLayout() { + + + const imageInput = document.getElementById('product_image_input'); + const previewImage = document.getElementById('product-image-preview'); + const placeholderIcon = document.getElementById('product-image-placeholder'); + + if (imageInput) { + imageInput.addEventListener('change', (event) => { + const file = event.target.files[0]; + + if (file) { + const reader = new FileReader(); + + reader.onload = (e) => { + previewImage.src = e.target.result; + previewImage.classList.remove('hidden'); + if (placeholderIcon) { + placeholderIcon.classList.add('hidden'); + } + }; + + reader.readAsDataURL(file); + } + }); + } const sidebar = document.getElementById('sidebar'); const overlay = document.getElementById('sidebar-overlay'); const toggleBtn = document.getElementById('sidebar-toggle'); @@ -123,3 +148,5 @@ document.addEventListener('turbo:before-cache', () => { if (sidebar) sidebar.classList.add('-translate-x-full'); if (overlay) overlay.classList.add('hidden'); }); + + diff --git a/public/images/.gitignore b/public/images/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/src/Command/SearchCommand.php b/src/Command/SearchCommand.php index 65accb9..5ccbecf 100644 --- a/src/Command/SearchCommand.php +++ b/src/Command/SearchCommand.php @@ -6,6 +6,7 @@ namespace App\Command; use App\Entity\Account; use App\Entity\Customer; +use App\Entity\Product; use App\Service\Search\Client; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\Console\Attribute\AsCommand; @@ -60,6 +61,19 @@ class SearchCommand extends Command $this->client->indexDocuments($datas, 'customer'); } + + $products = $this->entityManager->getRepository(Product::class)->findAll(); + + foreach ($products as $product) { + + $datas = [ + 'id' => $product->getId(), + 'name' => $product->getName(), + 'ref' => $product->getRef(), + ]; + + $this->client->indexDocuments($datas, 'product'); + } $output->writeln('Indexation terminée (hors ROOT).'); return Command::SUCCESS; diff --git a/src/Controller/Dashboard/ProductController.php b/src/Controller/Dashboard/ProductController.php index 57e52a9..4a927ad 100644 --- a/src/Controller/Dashboard/ProductController.php +++ b/src/Controller/Dashboard/ProductController.php @@ -40,26 +40,83 @@ class ProductController extends AbstractController 'products' => $paginator->paginate($productRepository->findBy([],['ref'=>'asc']), $request->get('page', 1), 10), ]); } - #[Route(path: '/crm/products/add', name: 'app_crm_product_add', options: ['sitemap' => false], methods: ['GET'])] - public function productAdd(ProductRepository $productRepository,AppLogger $appLogger,Request $request): Response - { - $appLogger->record('VIEW','Consultation page création d\'un produits'); + #[Route(path: '/crm/products/add', name: 'app_crm_product_add', options: ['sitemap' => false], methods: ['GET', 'POST'])] + public function productAdd( + EntityManagerInterface $entityManager, + AppLogger $appLogger, + Client $client, + Request $request + ): Response { + $appLogger->record('VIEW', 'Consultation page création d\'un produit'); $product = new Product(); - $form = $this->createForm(ProductType::class,$product); + $form = $this->createForm(ProductType::class, $product); $form->handleRequest($request); - if ($form->isSubmitted() && $form->isValid()) { + if ($form->isSubmitted() && $form->isValid()) { + // On récupère les données pour le log avant la sauvegarde + $productName = $product->getName(); + $productRef = $product->getRef(); + + + // Sauvegarde en base de données + $entityManager->persist($product); + $entityManager->flush(); + $client->createProduct($product); + // Log de l'action de création + $appLogger->record('CREATE', sprintf('Création du produit : [%s] %s', $productRef, $productName)); + + // Message flash de succès + $this->addFlash('success', sprintf('Le produit "%s" a été ajouté au catalogue avec succès.', $productName)); + + // Redirection vers le listing des produits + return $this->redirectToRoute('app_crm_product'); } + return $this->render('dashboard/products/add.twig', [ 'form' => $form->createView(), + 'product' => $product // Optionnel, utile pour l'aperçu d'image si défini ]); } - #[Route(path: '/crm/products/edit/{id}', name: 'app_crm_product_edit', options: ['sitemap' => false], methods: ['GET'])] - public function productEdit(ProductRepository $productRepository,AppLogger $appLogger): Response - { + #[Route(path: '/crm/products/edit/{id}', name: 'app_crm_product_edit', options: ['sitemap' => false], methods: ['GET', 'POST'])] + public function productEdit( + Product $product, + EntityManagerInterface $entityManager, + AppLogger $appLogger, + Request $request, + Client $stripeService + ): Response { + $appLogger->record('VIEW', 'Consultation modification produit : ' . $product->getName()); + $form = $this->createForm(ProductType::class, $product); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + + // 1. Mise à jour Stripe si le produit possède un ID Stripe + if ($product->getProductId()) { + $stripeResult = $stripeService->updateProduct($product); + + if (!$stripeResult['state']) { + $this->addFlash('warning', 'Erreur synchro Stripe : ' . $stripeResult['message']); + } + } + + // 2. Sauvegarde en base locale + $entityManager->flush(); + + $appLogger->record('UPDATE', 'Mise à jour du produit : ' . $product->getName()); + $this->addFlash('success', 'Le produit a été mis à jour avec succès.'); + + return $this->redirectToRoute('app_crm_product'); + } + + return $this->render('dashboard/products/add.twig', [ + 'form' => $form->createView(), + 'product' => $product, + 'is_edit' => true + ]); } #[Route(path: '/crm/products/delete/{id}', name: 'app_crm_product_delete', options: ['sitemap' => false], methods: ['POST'])] public function productDelete( diff --git a/src/Controller/Dashboard/SearchController.php b/src/Controller/Dashboard/SearchController.php index dee5b5d..04999d5 100644 --- a/src/Controller/Dashboard/SearchController.php +++ b/src/Controller/Dashboard/SearchController.php @@ -9,6 +9,7 @@ use App\Form\RequestPasswordConfirmType; use App\Form\RequestPasswordRequestType; use App\Repository\AccountRepository; use App\Repository\CustomerRepository; +use App\Repository\ProductRepository; use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent; use App\Service\ResetPassword\Event\ResetPasswordEvent; use App\Service\Search\Client; @@ -28,6 +29,7 @@ class SearchController extends AbstractController #[Route(path: '/crm/recherche', name: 'app_crm_search', options: ['sitemap' => false], methods: ['GET'])] public function crmSearch( AccountRepository $accountRepository, + ProductRepository $productRepository, CustomerRepository $customerRepository, Client $client, Request $request @@ -73,6 +75,23 @@ class SearchController extends AbstractController ]; } } + if (str_contains($resultGroup['indexUid'], 'intranet_ludikevent_product')) { + + // Extraction des IDs pour éviter les requêtes en boucle + $ids = array_map(fn($h) => $h['id'], $resultGroup['hits']); + $accounts = $productRepository->findBy(['id' => $ids]); + + foreach ($accounts as $account) { + $unifiedResults[] = [ + 'title' => $account->getName() . " " . $account->getRef(), + 'subtitle' => $account->getCategory(), + 'link' => $this->generateUrl('app_crm_product_edit', ['id' => $account->getId()]), + 'type' => 'Produit', + 'id' => $account->getId(), + 'initials' => strtoupper(substr($account->getName(), 0, 1) . substr($account->getRef(), 0, 1)) + ]; + } + } } } diff --git a/src/Form/ProductType.php b/src/Form/ProductType.php index aa4ab75..348e39c 100644 --- a/src/Form/ProductType.php +++ b/src/Form/ProductType.php @@ -53,6 +53,7 @@ class ProductType extends AbstractType ]) ->add('imageFile',FileType::class,[ 'label' => 'Image du produit', + 'required' => false, ]) ; } diff --git a/src/Service/Search/Client.php b/src/Service/Search/Client.php index f2f26fd..ea966bc 100644 --- a/src/Service/Search/Client.php +++ b/src/Service/Search/Client.php @@ -30,6 +30,7 @@ class Client return [ "intranet_ludikevent_admin" => [], "intranet_ludikevent_customer" => [], + "intranet_ludikevent_product" => [], ]; } diff --git a/src/Service/Stripe/Client.php b/src/Service/Stripe/Client.php index bf43891..e17dd48 100644 --- a/src/Service/Stripe/Client.php +++ b/src/Service/Stripe/Client.php @@ -280,4 +280,89 @@ class Client ]; } } + + /** + * 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()]; + } + } } diff --git a/templates/dashboard/products.twig b/templates/dashboard/products.twig index 4dfde56..34bef12 100644 --- a/templates/dashboard/products.twig +++ b/templates/dashboard/products.twig @@ -15,117 +15,113 @@ {% endblock %} {% block body %} -
- {% for product in products %} -
+
+
+ + + + + + + + + + + + + + {% for product in products %} + + {# VISUEL & REF #} + - {# IMAGE DU PRODUIT #} -
- {% if product.imageName is not null %} - {{ product.name }} - {% else %} -
- - - - Aucun visuel -
- {% endif %} + {# NOM #} +
- {# BADGE CATEGORIE #} -
- - {{ product.category }} - -
- + {# CATEGORIE #} + - {# CONTENU #} -
- {# IDENTITÉ & STRIPE STATUS #} -
-

{{ product.ref }}

-

- {{ product.name }} -

- - {# Badge Synchro Stripe #} -
+ {# STRIPE #} +
- {# GRILLE TARIFS #} -
-
-

Prix Journée

-

{{ product.priceDay|number_format(2, ',', ' ') }}€

-
-
-

Jour Sup.

-

{{ product.priceSup|number_format(2, ',', ' ') }}€

-
-
+ {# PRIX #} + - {# CAUTION & INSTALLATION #} -
-
- Caution - {{ product.caution|number_format(0, ',', ' ') }}€ -
-
- Installation - - {{ product.installation ? 'INCLUS' : 'NON INCLUS' }} - -
-
+ {# INSTALLATION (MONTANT) #} + - {# ACTIONS #} -
- {# Modifier #} - - - - - Détails & Édition - + {# ACTIONS #} +
+ + {% else %} + + + + {% endfor %} + +
Visuel & RéfDésignationCatégorieStripeTarif J1Install.Actions
+
+
+ {% if product.imageName %} + + {% else %} +
+ +
+ {% endif %} +
+ {{ product.ref }} +
+
+ + {{ product.name }} + + + + {{ product.category }} + + {% if product.productId %} -
- - Stripe Synchronisé +
+ + Synchronisé
{% else %} -
- - Non Synchronisé Stripe +
+ + En attente
{% endif %} -
-
+
+ + {{ product.priceDay|number_format(2, ',', ' ') }}€ + + + + {{ product.installation > 0 ? product.installation|number_format(2, ',', ' ') ~ '€' : 'OFFERT' }} + + +
+ + + - {# Supprimer #} - - - - - -
- - - {% else %} -
-

Le catalogue est actuellement vide

-
- {% endfor %} + + + + +
+

Aucun produit dans le catalogue

+
+
{# PAGINATION #} {% if products.getTotalItemCount is defined and products.getTotalItemCount > products.getItemNumberPerPage %} -
+
{{ knp_pagination_render(products) }}
{% endif %} diff --git a/templates/dashboard/products/add.twig b/templates/dashboard/products/add.twig index 2bd1c81..957068e 100644 --- a/templates/dashboard/products/add.twig +++ b/templates/dashboard/products/add.twig @@ -13,45 +13,43 @@
{# 00. IMAGE DU PRODUIT #} + {# SECTION IMAGE #}

00 - Visuel du produit + Image du Produit

-
- {# Aperçu si image existante #} - {% if product is defined and product.imageName %} -
-
- -
-

Image actuelle

-
-
-
- {% endif %} +
+
+
+ {# L'image avec un ID spécifique pour le JS #} + -
- -
- {{ form_errors(form.imageFile) }} + {# L'icône de remplacement #} + + +
+ +
+ {{ 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' + } + }) }} +
- {# 01. INFORMATIONS GÉNÉRALES #}