✨ feat(ProductType): Ajoute le formulaire de création et édition des produits.
✨ feat(add.twig): Ajoute le template d'ajout de produit avec formulaire. ♻️ refactor(Stripe/Client): Ajoute la fonction pour désactiver un produit. 🔥 feat(ProductController): Ajoute les actions pour ajouter, éditer, supprimer.
This commit is contained in:
@@ -3,16 +3,19 @@
|
||||
namespace App\Controller\Dashboard;
|
||||
|
||||
use App\Entity\Account;
|
||||
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\ProductType;
|
||||
use App\Logger\AppLogger;
|
||||
use App\Repository\AccountLoginRegisterRepository;
|
||||
use App\Repository\AccountRepository;
|
||||
use App\Repository\AuditLogRepository;
|
||||
use App\Repository\ProductRepository;
|
||||
use App\Service\Mailer\Mailer;
|
||||
use App\Service\Stripe\Client;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
@@ -33,14 +36,24 @@ class ProductController extends AbstractController
|
||||
{
|
||||
$appLogger->record('VIEW','Consultation liste des produits');
|
||||
|
||||
return $this->render('product/products.twig', [
|
||||
return $this->render('dashboard/products.twig', [
|
||||
'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): Response
|
||||
public function productAdd(ProductRepository $productRepository,AppLogger $appLogger,Request $request): Response
|
||||
{
|
||||
$appLogger->record('VIEW','Consultation page création d\'un produits');
|
||||
|
||||
$product = new Product();
|
||||
$form = $this->createForm(ProductType::class,$product);
|
||||
$form->handleRequest($request);
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
|
||||
}
|
||||
return $this->render('dashboard/products/add.twig', [
|
||||
'form' => $form->createView(),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route(path: '/crm/products/edit/{id}', name: 'app_crm_product_edit', options: ['sitemap' => false], methods: ['GET'])]
|
||||
@@ -48,9 +61,34 @@ class ProductController extends AbstractController
|
||||
{
|
||||
|
||||
}
|
||||
#[Route(path: '/crm/products/delete/{id}', name: 'app_crm_product_delete', options: ['sitemap' => false], methods: ['GET'])]
|
||||
public function productDelete(ProductRepository $productRepository,AppLogger $appLogger): Response
|
||||
{
|
||||
#[Route(path: '/crm/products/delete/{id}', name: 'app_crm_product_delete', options: ['sitemap' => false], methods: ['POST'])]
|
||||
public function productDelete(
|
||||
Product $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();
|
||||
$productRef = $product->getRef();
|
||||
|
||||
// 2. Log de l'action avant suppression
|
||||
$appLogger->record('DELETE', sprintf('Suppression du produit : [%s] %s', $productRef, $productName));
|
||||
|
||||
$client->deleteProduct($product);
|
||||
// 3. Suppression en base de données
|
||||
$entityManager->remove($product);
|
||||
$entityManager->flush();
|
||||
|
||||
$this->addFlash('success', sprintf('Le produit "%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
|
||||
}
|
||||
}
|
||||
|
||||
66
src/Form/ProductType.php
Normal file
66
src/Form/ProductType.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Entity\Customer;
|
||||
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\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class ProductType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$builder
|
||||
->add('name',TextType::class,[
|
||||
'label' => 'Nom du produit',
|
||||
'required' => true,
|
||||
])
|
||||
->add('ref',TextType::class,[
|
||||
'label' => 'Reference du produit',
|
||||
'required' => true,
|
||||
])
|
||||
->add('category',TextType::class,[
|
||||
'label' => 'Catégorie du produit',
|
||||
'required' => true,
|
||||
])
|
||||
->add('caution',NumberType::class,[
|
||||
'label' => 'Caution du produit',
|
||||
'required' => true,
|
||||
'html5' => true,
|
||||
])
|
||||
->add('installation',NumberType::class,[
|
||||
'label' => 'Installation du produit',
|
||||
'required' => true,
|
||||
'html5' => true,
|
||||
])
|
||||
->add('priceDay',NumberType::class,[
|
||||
'label' => 'Prix journée',
|
||||
'required' => true,
|
||||
'html5' => true,
|
||||
])
|
||||
->add('priceSup',NumberType::class,[
|
||||
'label' => 'Prix Suplémentaire',
|
||||
'required' => true,
|
||||
'html5' => true,
|
||||
])
|
||||
->add('imageFile',FileType::class,[
|
||||
'label' => 'Image du produit',
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => Product::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -237,4 +237,47 @@ class Client
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
149
templates/dashboard/products/add.twig
Normal file
149
templates/dashboard/products/add.twig
Normal file
@@ -0,0 +1,149 @@
|
||||
{% extends 'dashboard/base.twig' %}
|
||||
|
||||
{% block title %}Fiche Produit{% endblock %}
|
||||
{% block title_header %}Gestion du <span class="text-blue-500">Matériel</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">
|
||||
|
||||
{# COLONNE GAUCHE : VISUEL & IDENTITÉ #}
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
|
||||
{# 00. IMAGE DU PRODUIT #}
|
||||
<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>
|
||||
Visuel du produit
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
{# Aperçu si image existante #}
|
||||
{% if product is defined and product.imageName %}
|
||||
<div class="flex justify-center mb-6">
|
||||
<div class="relative group">
|
||||
<img src="{{ vich_uploader_asset(product, 'imageFile') }}" class="h-48 w-72 object-cover rounded-[2rem] border border-white/10 shadow-2xl transition-transform group-hover:scale-105 duration-500">
|
||||
<div class="absolute inset-0 bg-black/40 rounded-[2rem] opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<p class="text-[10px] font-black text-white uppercase tracking-widest">Image actuelle</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="relative">
|
||||
<label class="flex flex-col items-center justify-center w-full h-40 border-2 border-dashed border-white/10 rounded-[2rem] cursor-pointer bg-slate-900/30 hover:bg-slate-900/50 hover:border-blue-500/40 transition-all group">
|
||||
<div class="flex flex-col items-center justify-center pt-5 pb-6 text-center px-4">
|
||||
<svg class="w-10 h-10 mb-3 text-slate-500 group-hover:text-blue-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p class="mb-1 text-[10px] font-black text-slate-400 uppercase tracking-widest">
|
||||
<span class="text-blue-500">Cliquez pour modifier</span> l'image
|
||||
</p>
|
||||
<p class="text-[9px] text-slate-600 uppercase font-bold tracking-tighter">JPG, PNG ou WEBP (Max 2Mo)</p>
|
||||
</div>
|
||||
{{ form_widget(form.imageFile, {'attr': {'class': 'hidden'}}) }}
|
||||
</label>
|
||||
<div class="text-rose-500 text-[10px] font-bold mt-2 ml-4 uppercase">
|
||||
{{ form_errors(form.imageFile) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# 01. INFORMATIONS GÉNÉRALES #}
|
||||
<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-blue-600/20 text-blue-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">01</span>
|
||||
Détails du Catalogue
|
||||
</h3>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="md:col-span-2">
|
||||
{{ form_label(form.name, 'Désignation Commerciale', {'label_attr': {'class': 'text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] ml-1 mb-2 block'}}) }}
|
||||
{{ form_widget(form.name, {'attr': {'placeholder': 'Ex: Château Gonflable Jungle', 'class': 'w-full bg-slate-900/50 border-white/5 rounded-2xl text-white focus:ring-blue-500/20 focus:border-blue-500 transition-all py-3.5 px-5'}}) }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form_label(form.category, 'Catégorie', {'label_attr': {'class': 'text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] ml-1 mb-2 block'}}) }}
|
||||
{{ form_widget(form.category, {'attr': {'class': 'w-full bg-slate-900/50 border-white/5 rounded-2xl text-white focus:ring-blue-500/20 focus:border-blue-500 transition-all py-3.5 px-5'}}) }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{{ form_label(form.ref, 'Référence Interne (SKU)', {'label_attr': {'class': 'text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] ml-1 mb-2 block'}}) }}
|
||||
{{ form_widget(form.ref, {'attr': {'placeholder': 'REF-000', 'class': 'w-full bg-slate-900/50 border-white/5 rounded-2xl text-white font-mono focus:ring-blue-500/20 focus:border-blue-500 transition-all py-3.5 px-5'}}) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# COLONNE DROITE : TARIFICATION #}
|
||||
<div class="lg:col-span-1 space-y-8">
|
||||
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl h-full border-l-emerald-500/10 border-l-2">
|
||||
<h3 class="text-lg font-bold text-white mb-8 flex items-center">
|
||||
<span class="w-8 h-8 bg-emerald-600/20 text-emerald-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">02</span>
|
||||
Finances & Tarifs
|
||||
</h3>
|
||||
|
||||
<div class="space-y-8">
|
||||
{# INSTALLATION (Montant) #}
|
||||
<div class="p-5 bg-white/5 rounded-3xl border border-white/5 relative overflow-hidden group">
|
||||
<div class="absolute top-0 right-0 p-2 opacity-10">
|
||||
<svg class="w-12 h-12 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
|
||||
</div>
|
||||
{{ form_label(form.installation, 'Forfait Installation (€)', {'label_attr': {'class': 'text-[9px] font-black text-amber-500 uppercase tracking-widest mb-3 block'}}) }}
|
||||
{{ form_widget(form.installation, {'attr': {'class': 'w-full bg-slate-950/50 border-white/10 rounded-xl text-amber-500 font-bold focus:ring-amber-500/20 focus:border-amber-500 transition-all py-3 px-4 text-lg'}}) }}
|
||||
<p class="text-[8px] text-slate-500 mt-2 font-bold uppercase italic tracking-tighter">Saisir 0 si inclus dans la loc</p>
|
||||
</div>
|
||||
|
||||
{# PRIX LOCATION #}
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
{{ form_label(form.priceDay, 'Tarif 1er Jour (€)', {'label_attr': {'class': 'text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1 mb-2 block'}}) }}
|
||||
<div class="relative">
|
||||
{{ form_widget(form.priceDay, {'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>
|
||||
{{ form_label(form.priceSup, 'Tarif Jour Sup. (€)', {'label_attr': {'class': 'text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1 mb-2 block'}}) }}
|
||||
<div class="relative">
|
||||
{{ form_widget(form.priceSup, {'attr': {'class': 'w-full bg-slate-900/50 border-white/5 rounded-2xl text-blue-400 font-black text-xl focus:ring-blue-500/20 focus:border-blue-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>
|
||||
|
||||
{# CAUTION #}
|
||||
<div class="pt-8 border-t border-white/5">
|
||||
{{ form_label(form.caution, 'Montant de la Caution (€)', {'label_attr': {'class': 'text-[10px] font-black text-slate-500 uppercase tracking-widest ml-1 mb-2 block'}}) }}
|
||||
<div class="relative">
|
||||
{{ form_widget(form.caution, {'attr': {'class': 'w-full bg-slate-900/50 border-white/5 rounded-2xl text-white font-mono focus:ring-blue-500/20 focus:border-blue-500 transition-all py-3 px-5 text-center'}}) }}
|
||||
</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-[2rem] border border-white/5 shadow-xl">
|
||||
<a href="{{ path('app_crm_product') }}" class="px-8 py-3 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>
|
||||
Retour au catalogue
|
||||
</a>
|
||||
|
||||
<button type="submit" class="relative overflow-hidden group px-12 py-4 bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-black uppercase tracking-[0.2em] rounded-2xl transition-all shadow-lg shadow-blue-600/30">
|
||||
<span class="relative z-10 flex items-center">
|
||||
<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="3" d="M5 13l4 4L19 7" /></svg>
|
||||
Enregistrer la fiche
|
||||
</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 %}
|
||||
Reference in New Issue
Block a user