✨ feat(product): Ajoute gestion des images, indexation et synchro Stripe produits
Ce commit ajoute la gestion des images pour les produits, l'indexation des produits pour la recherche et la synchronisation avec Stripe. Ajoute un formulaire de création/édition de produits avec gestion de l'image, l'indexation pour la recherche, et la synchronisation des produits avec Stripe. Gère les uploads d'images.
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
|
||||
0
public/images/.gitignore
vendored
Normal file
0
public/images/.gitignore
vendored
Normal file
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ class ProductType extends AbstractType
|
||||
])
|
||||
->add('imageFile',FileType::class,[
|
||||
'label' => 'Image du produit',
|
||||
'required' => false,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ class Client
|
||||
return [
|
||||
"intranet_ludikevent_admin" => [],
|
||||
"intranet_ludikevent_customer" => [],
|
||||
"intranet_ludikevent_product" => [],
|
||||
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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()];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,117 +15,113 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8 animate-in fade-in duration-700">
|
||||
{% for product in products %}
|
||||
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] overflow-hidden group hover:border-blue-500/30 transition-all duration-500 hover:shadow-2xl hover:shadow-blue-500/10 flex flex-col">
|
||||
<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 & Réf</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]">Catégorie</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 J1</th>
|
||||
<th class="px-6 py-5 text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] text-center">Install.</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 product in products %}
|
||||
<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 product.imageName %}
|
||||
<img src="{{ vich_uploader_asset(product, '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>
|
||||
<span class="text-[10px] font-mono font-bold text-blue-500 tracking-wider">{{ product.ref }}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{# IMAGE DU PRODUIT #}
|
||||
<div class="relative h-64 w-full overflow-hidden bg-slate-900/50">
|
||||
{% if product.imageName is not null %}
|
||||
<img src="{{ vich_uploader_asset(product, 'imageFile') }}"
|
||||
alt="{{ product.name }}"
|
||||
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110">
|
||||
{% else %}
|
||||
<div class="w-full h-full flex flex-col items-center justify-center text-slate-600">
|
||||
<svg class="w-12 h-12 mb-2" 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>
|
||||
<span class="text-[10px] font-bold uppercase tracking-widest">Aucun visuel</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# NOM #}
|
||||
<td class="px-6 py-4">
|
||||
<span class="text-sm font-bold text-white group-hover:text-blue-400 transition-colors capitalize">
|
||||
{{ product.name }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{# BADGE CATEGORIE #}
|
||||
<div class="absolute top-6 left-6">
|
||||
<span class="px-4 py-1.5 backdrop-blur-md bg-black/40 border border-white/10 text-white text-[9px] font-black uppercase tracking-widest rounded-lg">
|
||||
{{ product.category }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{# CATEGORIE #}
|
||||
<td class="px-6 py-4">
|
||||
<span class="px-3 py-1 bg-white/5 border border-white/10 rounded-lg text-[9px] font-black text-slate-400 uppercase tracking-widest">
|
||||
{{ product.category }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{# CONTENU #}
|
||||
<div class="p-8 flex-1 flex flex-col">
|
||||
{# IDENTITÉ & STRIPE STATUS #}
|
||||
<div class="mb-8">
|
||||
<p class="text-[10px] font-black text-blue-500 uppercase tracking-[0.3em] mb-1">{{ product.ref }}</p>
|
||||
<h3 class="text-2xl font-bold text-white tracking-tight group-hover:text-blue-400 transition-colors mb-3">
|
||||
{{ product.name }}
|
||||
</h3>
|
||||
|
||||
{# Badge Synchro Stripe #}
|
||||
<div class="flex">
|
||||
{# STRIPE #}
|
||||
<td class="px-6 py-4">
|
||||
{% if product.productId %}
|
||||
<div class="flex items-center text-[8px] font-black text-emerald-400 uppercase tracking-[0.1em] bg-emerald-500/10 px-2 py-0.5 rounded-md border border-emerald-500/30">
|
||||
<span class="flex h-1.5 w-1.5 rounded-full bg-emerald-500 mr-2"></span>
|
||||
Stripe Synchronisé
|
||||
<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-[0.1em] bg-rose-500/10 px-2 py-0.5 rounded-md border border-rose-500/30 shadow-sm shadow-rose-500/10">
|
||||
<span class="flex h-1.5 w-1.5 rounded-full bg-rose-500 mr-2 animate-pulse"></span>
|
||||
Non Synchronisé Stripe
|
||||
<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 %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{# GRILLE TARIFS #}
|
||||
<div class="grid grid-cols-2 gap-4 mb-8">
|
||||
<div class="p-4 bg-slate-900/40 rounded-2xl border border-white/5 text-center">
|
||||
<p class="text-[8px] font-bold text-slate-500 uppercase tracking-widest mb-1">Prix Journée</p>
|
||||
<p class="text-lg font-black text-emerald-400">{{ product.priceDay|number_format(2, ',', ' ') }}€</p>
|
||||
</div>
|
||||
<div class="p-4 bg-slate-900/40 rounded-2xl border border-white/5 text-center">
|
||||
<p class="text-[8px] font-bold text-slate-500 uppercase tracking-widest mb-1">Jour Sup.</p>
|
||||
<p class="text-lg font-black text-blue-400">{{ product.priceSup|number_format(2, ',', ' ') }}€</p>
|
||||
</div>
|
||||
</div>
|
||||
{# PRIX #}
|
||||
<td class="px-6 py-4 text-center">
|
||||
<span class="text-sm font-black text-emerald-400">
|
||||
{{ product.priceDay|number_format(2, ',', ' ') }}€
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{# CAUTION & INSTALLATION #}
|
||||
<div class="space-y-4 mb-8 bg-black/10 p-5 rounded-[1.5rem] border border-white/5">
|
||||
<div class="flex items-center justify-between text-[11px]">
|
||||
<span class="text-slate-500 font-bold uppercase tracking-widest">Caution</span>
|
||||
<span class="text-slate-300 font-mono bg-white/5 px-2 py-0.5 rounded">{{ product.caution|number_format(0, ',', ' ') }}€</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-[11px]">
|
||||
<span class="text-slate-500 font-bold uppercase tracking-widest">Installation</span>
|
||||
<span class="font-bold {{ product.installation ? 'text-emerald-500' : 'text-slate-600' }}">
|
||||
{{ product.installation ? 'INCLUS' : 'NON INCLUS' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{# INSTALLATION (MONTANT) #}
|
||||
<td class="px-6 py-4 text-center">
|
||||
<span class="text-[10px] font-bold {{ product.installation > 0 ? 'text-amber-500' : 'text-slate-600' }}">
|
||||
{{ product.installation > 0 ? product.installation|number_format(2, ',', ' ') ~ '€' : 'OFFERT' }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{# ACTIONS #}
|
||||
<div class="mt-auto pt-6 border-t border-white/5 flex items-center justify-between">
|
||||
{# Modifier #}
|
||||
<a href="{{ path('app_crm_product_edit', {id: product.id}) }}" class="flex items-center text-[10px] font-black text-slate-500 hover:text-blue-400 uppercase tracking-widest transition-all group/btn">
|
||||
<svg class="w-4 h-4 mr-2 transition-transform group-hover/btn:scale-110" 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>
|
||||
Détails & Édition
|
||||
</a>
|
||||
{# 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_edit', {id: product.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>
|
||||
|
||||
{# Supprimer #}
|
||||
<a href="{{ path('app_crm_product_delete', {id: product.id}) }}?_token={{ csrf_token('delete' ~ product.id) }}"
|
||||
data-turbo-method="post"
|
||||
data-turbo-confirm="Confirmer la suppression définitive de '{{ product.name }}' ?"
|
||||
class="p-2.5 text-slate-500 hover:text-rose-500 hover:bg-rose-500/10 rounded-xl transition-all border border-transparent hover:border-rose-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" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-span-full py-24 text-center backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[3rem]">
|
||||
<p class="text-slate-500 italic uppercase tracking-[0.2em] text-xs font-black">Le catalogue est actuellement vide</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<a href="{{ path('app_crm_product_delete', {id: product.id}) }}?_token={{ csrf_token('delete' ~ product.id) }}"
|
||||
data-turbo-method="post"
|
||||
data-turbo-confirm="Confirmer la suppression définitive de '{{ product.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 produit dans le catalogue</p>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# PAGINATION #}
|
||||
{% if products.getTotalItemCount is defined and products.getTotalItemCount > products.getItemNumberPerPage %}
|
||||
<div class="mt-12 flex justify-center custom-pagination">
|
||||
<div class="mt-8 flex justify-center custom-pagination">
|
||||
{{ knp_pagination_render(products) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -13,45 +13,43 @@
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
|
||||
{# 00. IMAGE DU PRODUIT #}
|
||||
{# SECTION IMAGE #}
|
||||
<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
|
||||
Image 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="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="{{ product.imageName ? vich_uploader_asset(product, 'imageFile') | imagine_filter('webp') : '#' }}"
|
||||
class="w-full h-full object-cover {{ product.imageName ? '' : 'hidden' }}">
|
||||
|
||||
<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) }}
|
||||
{# L'icône de remplacement #}
|
||||
<svg id="product-image-placeholder"
|
||||
class="w-12 h-12 text-slate-700 {{ product.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>
|
||||
|
||||
{# 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">
|
||||
|
||||
Reference in New Issue
Block a user