✨ feat(Product): Ajoute la gestion des images des produits avec VichUploader.
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
vich_uploader:
|
||||
db_driver: orm
|
||||
mappings:
|
||||
|
||||
image_product:
|
||||
uri_prefix: /images/image_product
|
||||
upload_destination: '%kernel.project_dir%/public/images/image_product'
|
||||
namer: Vich\UploaderBundle\Naming\SmartUniqueNamer
|
||||
|
||||
37
migrations/Version20260116124736.php
Normal file
37
migrations/Version20260116124736.php
Normal 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 Version20260116124736 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 product ADD image_name VARCHAR(255) DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE product ADD image_size INT DEFAULT NULL');
|
||||
$this->addSql('ALTER TABLE product ADD updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL');
|
||||
$this->addSql('COMMENT ON COLUMN product.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 product DROP image_name');
|
||||
$this->addSql('ALTER TABLE product DROP image_size');
|
||||
$this->addSql('ALTER TABLE product DROP updated_at');
|
||||
}
|
||||
}
|
||||
31
migrations/Version20260116124808.php
Normal file
31
migrations/Version20260116124808.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20260116124808 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
|
||||
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql('CREATE SCHEMA public');
|
||||
}
|
||||
}
|
||||
56
src/Controller/Dashboard/ProductController.php
Normal file
56
src/Controller/Dashboard/ProductController.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller\Dashboard;
|
||||
|
||||
use App\Entity\Account;
|
||||
use App\Event\Object\EventAdminCreate;
|
||||
use App\Event\Object\EventAdminDeleted;
|
||||
use App\Form\AccountPasswordType;
|
||||
use App\Form\AccountType;
|
||||
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 Doctrine\ORM\EntityManagerInterface;
|
||||
use Knp\Component\Pager\PaginatorInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
|
||||
class ProductController extends AbstractController
|
||||
{
|
||||
|
||||
#[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
|
||||
{
|
||||
$appLogger->record('VIEW','Consultation liste des produits');
|
||||
|
||||
return $this->render('product/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
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
#[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/delete/{id}', name: 'app_crm_product_delete', options: ['sitemap' => false], methods: ['GET'])]
|
||||
public function productDelete(ProductRepository $productRepository,AppLogger $appLogger): Response
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,11 @@ namespace App\Entity;
|
||||
|
||||
use App\Repository\ProductRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Component\HttpFoundation\File\File;
|
||||
use Vich\UploaderBundle\Mapping\Annotation as Vich;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ProductRepository::class)]
|
||||
#[Vich\Uploadable()]
|
||||
class Product
|
||||
{
|
||||
#[ORM\Id]
|
||||
@@ -34,6 +37,18 @@ class Product
|
||||
#[ORM\Column]
|
||||
private ?float $caution = null;
|
||||
|
||||
|
||||
#[Vich\UploadableField(mapping: 'image_product', 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 getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
@@ -122,4 +137,40 @@ class Product
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
{% import _self as menu %}
|
||||
|
||||
{{ menu.nav_link(path('app_crm'), 'Dashboard', '<path d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>', 'app_crm') }}
|
||||
{{ menu.nav_link(path('app_crm_product'), 'Produits', '<path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>', 'app_clients') }}
|
||||
{{ menu.nav_link(path('app_crm_customer'), 'Clients', '<path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>', 'app_clients') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
142
templates/product/products.twig
Normal file
142
templates/product/products.twig
Normal file
@@ -0,0 +1,142 @@
|
||||
{% extends 'dashboard/base.twig' %}
|
||||
|
||||
{% block title %}Catalogue Produits{% endblock %}
|
||||
{% block title_header %}Gestion du <span class="text-blue-500">Matériel</span>{% endblock %}
|
||||
|
||||
{% block actions %}
|
||||
<div class="flex items-center space-x-3">
|
||||
<a href="{{ path('app_crm_product_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" />
|
||||
</svg>
|
||||
<span>Nouveau Produit</span>
|
||||
</a>
|
||||
</div>
|
||||
{% 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">
|
||||
|
||||
{# 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 %}
|
||||
|
||||
{# 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>
|
||||
|
||||
{# CONTENU #}
|
||||
<div class="p-8 flex-1 flex flex-col">
|
||||
{# REF, NOM #}
|
||||
<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">
|
||||
{{ product.name }}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{# GRILLE TARIFS (priceDay & priceSup) #}
|
||||
<div class="grid grid-cols-2 gap-4 mb-8">
|
||||
<div class="p-4 bg-slate-900/40 rounded-2xl border border-white/5">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
{# CAUTION & INSTALLATION #}
|
||||
<div class="space-y-4 mb-8">
|
||||
<div class="flex items-center justify-between text-[11px]">
|
||||
<span class="text-slate-500 font-bold uppercase tracking-widest flex items-center">
|
||||
<svg class="w-3.5 h-3.5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
|
||||
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 flex items-center">
|
||||
<svg class="w-3.5 h-3.5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 4a2 2 0 114 0v1a2 2 0 01-2 2H3m2 4l-2 2m0 0l2 2m-2-2h8m-5-8h4a2 2 0 012 2v1a2 2 0 01-2 2H3" /></svg>
|
||||
Installation
|
||||
</span>
|
||||
<span class="font-bold {{ product.installation ? 'text-emerald-500' : 'text-slate-600' }}">
|
||||
{{ product.installation ? 'INCLUS' : 'NON INCLUS' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ACTIONS #}
|
||||
<div class="mt-auto pt-6 border-t border-white/5 flex items-center justify-between">
|
||||
{# Bouton Modifier (Redirection vers la fiche) #}
|
||||
<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>
|
||||
Modifier le produit
|
||||
</a>
|
||||
|
||||
{# Bouton Supprimer (Identique aux clients) #}
|
||||
<div class="flex items-center space-x-1">
|
||||
<a href="{{ path('app_crm_product_delete', {id: product.id}) }}?_token={{ csrf_token('delete' ~ product.id) }}"
|
||||
data-turbo-method="post"
|
||||
data-turbo-confirm="Voulez-vous vraiment supprimer le produit '{{ product.name }}' ? Cette action est irréversible."
|
||||
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>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="col-span-full py-24 text-center backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[3rem]">
|
||||
<div class="w-20 h-20 bg-slate-800 rounded-3xl flex items-center justify-center mx-auto mb-6 border border-white/5">
|
||||
<svg class="w-10 h-10 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" /></svg>
|
||||
</div>
|
||||
<p class="text-slate-500 italic uppercase tracking-[0.2em] text-xs font-black">Le catalogue est actuellement vide</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# PAGINATION #}
|
||||
{% if products.getTotalItemCount is defined and products.getTotalItemCount > products.getItemNumberPerPage %}
|
||||
<div class="mt-12 flex justify-center custom-pagination">
|
||||
{{ knp_pagination_render(products) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<style>
|
||||
.custom-pagination nav ul { @apply flex space-x-2; }
|
||||
.custom-pagination nav ul li span,
|
||||
.custom-pagination nav ul li a {
|
||||
@apply px-4 py-2 rounded-xl bg-[#1e293b]/40 backdrop-blur-md border border-white/5 text-slate-400 text-xs font-bold transition-all;
|
||||
}
|
||||
.custom-pagination nav ul li.active span {
|
||||
@apply bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-600/20;
|
||||
}
|
||||
.custom-pagination nav ul li a:hover {
|
||||
@apply bg-white/10 text-white border-white/20;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user