feat(Product.php): Ajoute la liaison ManyToMany avec l'entité Options
 feat(Devis.php): Ajoute la propriété isNotAddCaution pour masquer la caution
♻️ refactor(.env): Met à jour les URLs de SIGN, STRIPE et CONTRAT
 feat(workflow.twig): Adapte le workflow et supprime l'étape de caution
 feat(NewDevisType.php): Ajoute un champ pour gérer
This commit is contained in:
Serreau Jovann
2026-02-04 09:10:41 +01:00
parent d993a545d9
commit d23e75034c
16 changed files with 488 additions and 35 deletions

6
.env
View File

@@ -83,9 +83,9 @@ STRIPE_PK=pk_test_51SUA22173W4aeFB1nO6oFfDZ12HOTffDKtCshhZ8rkUg6kUO2ZaQC0tK72rhE
STRIPE_SK=sk_test_51SUA22173W4aeFB16EB2LxGI0hNvNJzFshDI98zRImWBIhSfzqOGAz5TlPxSpUWbj3x4COm6kmSsaal9FpQR1A7M0022DvjbbR STRIPE_SK=sk_test_51SUA22173W4aeFB16EB2LxGI0hNvNJzFshDI98zRImWBIhSfzqOGAz5TlPxSpUWbj3x4COm6kmSsaal9FpQR1A7M0022DvjbbR
STRIPE_WEBHOOKS_SECRET= STRIPE_WEBHOOKS_SECRET=
SIGN_URL=https://790f740ccd60.ngrok-free.app SIGN_URL=https://04dcf28c87cd.ngrok-free.app
STRIPE_BASEURL=https://790f740ccd60.ngrok-free.app STRIPE_BASEURL=https://04dcf28c87cd.ngrok-free.app
CONTRAT_BASEURL=https://790f740ccd60.ngrok-free.app CONTRAT_BASEURL=https://04dcf28c87cd.ngrok-free.app
MINIO_S3_URL= MINIO_S3_URL=
MINIO_S3_CLIENT_ID= MINIO_S3_CLIENT_ID=

View File

@@ -8,10 +8,12 @@ import { CrmEditor } from "./libs/CrmEditor.js";
import { initTomSelect } from "./libs/initTomSelect.js"; import { initTomSelect } from "./libs/initTomSelect.js";
import { SearchProduct, SearchOptions } from "./libs/SearchProduct.js"; import { SearchProduct, SearchOptions } from "./libs/SearchProduct.js";
import { SearchProductDevis, SearchOptionsDevis } from "./libs/SearchProductDevis.js"; import { SearchProductDevis, SearchOptionsDevis } from "./libs/SearchProductDevis.js";
import { SearchOptionsProduct } from "./libs/SearchProductProduct.js";
import { SearchProductFormule, SearchOptionsFormule } from "./libs/SearchProductFormule.js"; import { SearchProductFormule, SearchOptionsFormule } from "./libs/SearchProductFormule.js";
import PlaningLogestics from "./libs/PlaningLogestics.js"; import PlaningLogestics from "./libs/PlaningLogestics.js";
import {SortableReorder} from "./libs/SortableReorder.js"; import {SortableReorder} from "./libs/SortableReorder.js";
import { StripeCommissionCalculator } from "./libs/StripeCommissionCalculator.js"; import { StripeCommissionCalculator } from "./libs/StripeCommissionCalculator.js";
import { ProductAddOption } from "./libs/ProductAddOption.js";
// --- CONFIGURATION SENTRY --- // --- CONFIGURATION SENTRY ---
Sentry.init({ Sentry.init({
@@ -34,12 +36,14 @@ const registerCustomElements = () => {
{ name: 'search-product', class: SearchProduct, extends: 'button' }, { name: 'search-product', class: SearchProduct, extends: 'button' },
{ name: 'search-productformule', class: SearchProductFormule, extends: 'button' }, { name: 'search-productformule', class: SearchProductFormule, extends: 'button' },
{ name: 'search-optionsformule', class: SearchOptionsFormule, extends: 'button' }, { name: 'search-optionsformule', class: SearchOptionsFormule, extends: 'button' },
{ name: 'search-optionsproduct', class: SearchOptionsProduct, extends: 'button' },
{ name: 'planing-logestics', class: PlaningLogestics }, { name: 'planing-logestics', class: PlaningLogestics },
{ name: 'search-options', class: SearchOptions, extends: 'button' }, { name: 'search-options', class: SearchOptions, extends: 'button' },
{ name: 'search-productdevis', class: SearchProductDevis, extends: 'button' }, { name: 'search-productdevis', class: SearchProductDevis, extends: 'button' },
{ name: 'search-optionsdevis', class: SearchOptionsDevis, extends: 'button' }, { name: 'search-optionsdevis', class: SearchOptionsDevis, extends: 'button' },
{ name: 'crm-editor', class: CrmEditor, extends: 'textarea' }, { name: 'crm-editor', class: CrmEditor, extends: 'textarea' },
{ name: 'stripe-commission-calculator', class: StripeCommissionCalculator, extends: 'div' } { name: 'stripe-commission-calculator', class: StripeCommissionCalculator, extends: 'div' },
{ name: 'product-add-option', class: ProductAddOption, extends: 'button' }
]; ];
elements.forEach(el => { elements.forEach(el => {

View File

@@ -0,0 +1,24 @@
export class ProductAddOption extends HTMLButtonElement {
constructor() {
super();
this.addEventListener('click', this.handleClick.bind(this));
}
handleClick(e) {
e.preventDefault();
const inputId = this.getAttribute('data-input-id');
const baseUrl = this.getAttribute('data-url');
const input = document.getElementById(inputId);
if (input && input.value) {
// The base URL comes from Twig path() which includes ?act=addOption
// We safely append the idOption parameter
window.location.href = `${baseUrl}&idOption=${input.value}`;
} else {
// Visual feedback could be added here (e.g., shake effect or border color)
alert("Veuillez sélectionner une option avant d'ajouter.");
}
}
}

View File

@@ -0,0 +1,146 @@
export class SearchOptionsProduct extends HTMLButtonElement{
constructor() {
super();
this.allOptions = [];
}
connectedCallback() {
this.addEventListener('click', (e) => {
e.preventDefault();
this.openModal();
});
}
async openModal() {
let modal = document.getElementById('modal-search-options');
if (!modal) {
modal = this.createModalStructure();
document.body.appendChild(modal);
this.setupSearchEvent(modal);
}
modal.classList.remove('hidden');
const container = modal.querySelector('#results-container-options');
const searchInput = modal.querySelector('#modal-search-input-options');
searchInput.value = '';
container.innerHTML = `
<div class="flex flex-col items-center justify-center p-12 text-slate-500">
<div class="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mb-4"></div>
<p class="text-[10px] font-black uppercase tracking-[0.2em]">Chargement des options...</p>
</div>
`;
try {
const response = await fetch('/crm/options/json');
this.allOptions = await response.json();
this.renderOptions(this.allOptions, container, modal);
setTimeout(() => searchInput.focus(), 100);
} catch (error) {
container.innerHTML = '<div class="text-rose-500 p-8 text-center text-xs font-bold uppercase">Erreur de liaison catalogue options.</div>';
}
}
createModalStructure() {
const div = document.createElement('div');
div.id = 'modal-search-options';
div.className = 'fixed inset-0 z-[100] flex items-center justify-center p-4 bg-[#0f172a]/90 backdrop-blur-md hidden animate-in fade-in duration-300';
div.innerHTML = `
<div class="bg-[#1e293b] border border-white/10 w-full max-w-2xl max-h-[85vh] rounded-[2.5rem] shadow-2xl overflow-hidden flex flex-col scale-in-center">
<div class="p-8 border-b border-white/5 bg-white/5 space-y-6">
<div class="flex justify-between items-center">
<h3 class="text-white font-black uppercase tracking-[0.3em] text-[11px] flex items-center">
<span class="w-2 h-2 bg-blue-500 rounded-full mr-3 animate-pulse shadow-[0_0_8px_#3b82f6]"></span>
Sélection Option
</h3>
<button type="button" onclick="this.closest('#modal-search-options').classList.add('hidden')" class="p-2 text-slate-400 hover:text-white transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="relative">
<input type="text" id="modal-search-input-options" placeholder="RECHERCHER DANS LE CATALOGUE..."
class="w-full bg-[#0f172a] border border-white/10 rounded-2xl py-4 pl-12 pr-5 text-white text-xs font-black tracking-widest focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all uppercase placeholder:text-slate-700 outline-none">
<svg class="w-5 h-5 absolute left-4 top-4 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
</div>
<div id="results-container-options" class="overflow-y-auto p-6 space-y-2 custom-scrollbar flex-grow min-h-[300px]"></div>
<div class="p-4 bg-white/5 text-center border-t border-white/5">
<p class="text-[9px] text-slate-500 font-black uppercase tracking-widest">Appuyez sur Échap pour annuler</p>
</div>
</div>
`;
return div;
}
setupSearchEvent(modal) {
const input = modal.querySelector('#modal-search-input-options');
const container = modal.querySelector('#results-container-options');
input.oninput = () => {
const query = input.value.toLowerCase().trim();
const filtered = this.allOptions.filter(o => o.name.toLowerCase().includes(query));
this.renderOptions(filtered, container, modal);
};
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') modal.classList.add('hidden');
});
}
renderOptions(options, container, modal) {
container.innerHTML = '';
if (options.length === 0) {
container.innerHTML = '<div class="text-slate-600 p-12 text-center text-[10px] font-black uppercase tracking-[0.2em]">Aucune option trouvée</div>';
return;
}
options.forEach(option => {
const card = document.createElement('div');
card.className = 'flex items-center gap-5 p-4 bg-white/5 border border-white/5 rounded-2xl hover:bg-blue-600/10 hover:border-blue-500/40 transition-all cursor-pointer group animate-in slide-in-from-bottom-2';
const imgHtml = option.image
? `<img src="${option.image}" class="w-14 h-14 rounded-xl object-cover shadow-lg border border-white/10 group-hover:scale-105 transition-transform" onerror="this.src='/provider/images/favicon.png'">`
: `<div class="w-14 h-14 bg-slate-950 rounded-xl flex items-center justify-center text-[8px] text-slate-700 font-black border border-white/5">OPT</div>`;
card.innerHTML = `
${imgHtml}
<div class="flex-grow">
<div class="text-white font-black text-xs uppercase tracking-wider mb-1">${option.name}</div>
<div class="flex gap-4">
<span class="text-blue-500 text-[10px] font-black uppercase tracking-tighter">PRIX HT: ${option.price}€</span>
</div>
</div>
<div class="w-10 h-10 rounded-xl bg-slate-800 group-hover:bg-blue-600 flex items-center justify-center text-slate-500 group-hover:text-white transition-all shadow-lg">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
</div>
`;
card.onclick = () => {
this.fillOptionLine(option);
modal.classList.add('hidden');
};
container.appendChild(card);
});
}
fillOptionLine(option) {
const row = this.closest('.w-fulla');
if (row) {
const nameInput = row.querySelector('#selected-option-name');
const nameid = row.querySelector('#selected-option-id');
if(nameInput) nameInput.value = option.name;
if(nameid) nameid.value = option.id;
const fieldset = row.querySelector('fieldset');
if (fieldset) {
fieldset.classList.add('border-blue-500/50', 'bg-blue-600/5');
setTimeout(() => fieldset.classList.remove('border-blue-500/50', 'bg-blue-600/5'), 800);
}
}
}
}

View 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 Version20260203140718 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 devis ADD is_not_add_caution BOOLEAN DEFAULT false NOT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE devis DROP is_not_add_caution');
}
}

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 Version20260204075839 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 product_options (product_id INT NOT NULL, options_id INT NOT NULL, PRIMARY KEY (product_id, options_id))');
$this->addSql('CREATE INDEX IDX_1ECE1374584665A ON product_options (product_id)');
$this->addSql('CREATE INDEX IDX_1ECE1373ADB05F1 ON product_options (options_id)');
$this->addSql('ALTER TABLE product_options ADD CONSTRAINT FK_1ECE1374584665A FOREIGN KEY (product_id) REFERENCES product (id) ON DELETE CASCADE');
$this->addSql('ALTER TABLE product_options ADD CONSTRAINT FK_1ECE1373ADB05F1 FOREIGN KEY (options_id) REFERENCES options (id) ON DELETE CASCADE');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE product_options DROP CONSTRAINT FK_1ECE1374584665A');
$this->addSql('ALTER TABLE product_options DROP CONSTRAINT FK_1ECE1373ADB05F1');
$this->addSql('DROP TABLE product_options');
}
}

View File

@@ -131,7 +131,7 @@ class ProductController extends AbstractController
} }
#[Route(path: '/crm/products/edit/{id}', name: 'app_crm_product_edit', methods: ['GET', 'POST'])] #[Route(path: '/crm/products/edit/{id}', name: 'app_crm_product_edit', methods: ['GET', 'POST'])]
public function productEdit(Product $product, EntityManagerInterface $em, AppLogger $logger, Request $request, Client $stripe, \App\Repository\ProductBlockedRepository $blockedRepo, \App\Repository\ProductReserveRepository $reserveRepo): Response public function productEdit(Product $product, EntityManagerInterface $em, AppLogger $logger, Request $request, Client $stripe, \App\Repository\ProductBlockedRepository $blockedRepo, \App\Repository\ProductReserveRepository $reserveRepo, OptionsRepository $optionsRepo): Response
{ {
// 0. Toggle Publish // 0. Toggle Publish
if ($request->query->get('act') === 'togglePublish') { if ($request->query->get('act') === 'togglePublish') {
@@ -144,6 +144,30 @@ class ProductController extends AbstractController
return $this->redirectToRoute('app_crm_product_edit', ['id' => $product->getId()]); return $this->redirectToRoute('app_crm_product_edit', ['id' => $product->getId()]);
} }
// 0.1 Ajout d'option
if ($request->query->get('act') === 'addOption' && $idOption = $request->query->get('idOption')) {
$option = $optionsRepo->find($idOption);
if ($option) {
$product->addOption($option);
$em->flush();
$logger->record('UPDATE', "Option ajoutée sur {$product->getName()} : {$option->getName()}");
$this->addFlash('success', 'Option ajoutée.');
}
return $this->redirectToRoute('app_crm_product_edit', ['id' => $product->getId()]);
}
// 0.2 Suppression d'option
if ($request->query->get('act') === 'deleteOption' && $idOption = $request->query->get('idOption')) {
$option = $optionsRepo->find($idOption);
if ($option) {
$product->removeOption($option);
$em->flush();
$logger->record('UPDATE', "Option supprimée sur {$product->getName()} : {$option->getName()}");
$this->addFlash('success', 'Option supprimée.');
}
return $this->redirectToRoute('app_crm_product_edit', ['id' => $product->getId()]);
}
// 1. Suppression de Document // 1. Suppression de Document
if ($idDoc = $request->query->get('idDoc')) { if ($idDoc = $request->query->get('idDoc')) {
$doc = $em->getRepository(ProductDoc::class)->find($idDoc); $doc = $em->getRepository(ProductDoc::class)->find($idDoc);
@@ -256,7 +280,7 @@ class ProductController extends AbstractController
$dummyReserve->setStartAt($blocked->getDateStart()); $dummyReserve->setStartAt($blocked->getDateStart());
$dummyReserve->setEndAt($blocked->getDateEnd()); $dummyReserve->setEndAt($blocked->getDateEnd());
$isAvailable = $reserveRepo->checkAvailability($dummyReserve); $isAvailable = $reserveRepo->checkAvailability($dummyReserve);
if (count($overlaps) > 0) { if (count($overlaps) > 0) {
$this->addFlash('error', 'Impossible de bloquer cette période : elle chevauche une période DÉJÀ BLOQUÉE.'); $this->addFlash('error', 'Impossible de bloquer cette période : elle chevauche une période DÉJÀ BLOQUÉE.');
} elseif (!$isAvailable) { } elseif (!$isAvailable) {
@@ -291,7 +315,8 @@ class ProductController extends AbstractController
'formVideo' => $formVideo->createView(), 'formVideo' => $formVideo->createView(),
'formBlocked' => $formBlocked->createView(), 'formBlocked' => $formBlocked->createView(),
'product' => $product, 'product' => $product,
'is_edit' => true 'is_edit' => true,
'optionsList' => $optionsRepo->findBy([], ['name' => 'ASC'])
]); ]);
} }
#[Route(path: '/crm/products/delete/{id}', name: 'app_crm_product_delete', methods: ['POST'])] #[Route(path: '/crm/products/delete/{id}', name: 'app_crm_product_delete', methods: ['POST'])]
@@ -328,7 +353,7 @@ class ProductController extends AbstractController
return $this->redirectToRoute('app_crm_product'); return $this->redirectToRoute('app_crm_product');
} }
return $this->render('dashboard/options/add.twig', ['form' => $form->createView(), 'product' => $option]); return $this->render('dashboard/options/add.twig', ['form' => $form->createView(), 'options' => $option]);
} }
#[Route(path: '/crm/products/options/edit/{id}', name: 'app_crm_product_options_edit', methods: ['GET', 'POST'])] #[Route(path: '/crm/products/options/edit/{id}', name: 'app_crm_product_options_edit', methods: ['GET', 'POST'])]

View File

@@ -87,6 +87,9 @@ class Devis
#[ORM\Column] #[ORM\Column]
private ?\DateTimeImmutable $endAt = null; private ?\DateTimeImmutable $endAt = null;
#[ORM\Column(options: ['default' => false])]
private ?bool $isNotAddCaution = false;
/** /**
* @var Collection<int, DevisOptions> * @var Collection<int, DevisOptions>
@@ -100,6 +103,18 @@ class Devis
$this->devisOptions = new ArrayCollection(); $this->devisOptions = new ArrayCollection();
} }
public function isIsNotAddCaution(): ?bool
{
return $this->isNotAddCaution;
}
public function setIsNotAddCaution(bool $isNotAddCaution): static
{
$this->isNotAddCaution = $isNotAddCaution;
return $this;
}
public function getId(): ?int public function getId(): ?int
{ {
return $this->id; return $this->id;

View File

@@ -44,9 +44,16 @@ class Options
#[ORM\Column(type: 'boolean', options: ['default' => true])] #[ORM\Column(type: 'boolean', options: ['default' => true])]
private ?bool $isPublish = true; private ?bool $isPublish = true;
/**
* @var Collection<int, Product>
*/
#[ORM\ManyToMany(targetEntity: Product::class, mappedBy: 'options')]
private Collection $products;
public function __construct() public function __construct()
{ {
$this->products = new ArrayCollection();
} }
public function isPublish(): ?bool public function isPublish(): ?bool
@@ -162,4 +169,31 @@ class Options
return$s->slugify($this->id."-".$this->name); return$s->slugify($this->id."-".$this->name);
} }
/**
* @return Collection<int, Product>
*/
public function getProducts(): Collection
{
return $this->products;
}
public function addProduct(Product $product): static
{
if (!$this->products->contains($product)) {
$this->products->add($product);
$product->addOption($this);
}
return $this;
}
public function removeProduct(Product $product): static
{
if ($this->products->removeElement($product)) {
$product->removeOption($this);
}
return $this;
}
} }

View File

@@ -108,6 +108,12 @@ class Product
#[ORM\Column(nullable: true, options: ['default' => true])] #[ORM\Column(nullable: true, options: ['default' => true])]
private ?bool $isPublish = true; private ?bool $isPublish = true;
/**
* @var Collection<int, Options>
*/
#[ORM\ManyToMany(targetEntity: Options::class, inversedBy: 'products')]
private Collection $options;
public function __construct() public function __construct()
{ {
$this->productReserves = new ArrayCollection(); $this->productReserves = new ArrayCollection();
@@ -116,6 +122,7 @@ class Product
$this->productPhotos = new ArrayCollection(); $this->productPhotos = new ArrayCollection();
$this->productVideos = new ArrayCollection(); $this->productVideos = new ArrayCollection();
$this->productBlockeds = new ArrayCollection(); $this->productBlockeds = new ArrayCollection();
$this->options = new ArrayCollection();
$this->isPublish = true; $this->isPublish = true;
} }
public function slug() public function slug()
@@ -527,4 +534,28 @@ class Product
return $this; return $this;
} }
/**
* @return Collection<int, Options>
*/
public function getOptions(): Collection
{
return $this->options;
}
public function addOption(Options $option): static
{
if (!$this->options->contains($option)) {
$this->options->add($option);
}
return $this;
}
public function removeOption(Options $option): static
{
$this->options->removeElement($option);
return $this;
}
} }

View File

@@ -6,6 +6,7 @@ use App\Entity\Customer;
use App\Entity\Devis; use App\Entity\Devis;
use Symfony\Bridge\Doctrine\Form\Type\EntityType; use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\DateTimeType; use Symfony\Component\Form\Extension\Core\Type\DateTimeType;
use Symfony\Component\Form\Extension\Core\Type\DateType; use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\Extension\Core\Type\TextType;
@@ -42,6 +43,10 @@ class NewDevisType extends AbstractType
'required' => true, 'required' => true,
'widget' => 'single_text', 'widget' => 'single_text',
]) ])
->add('isNotAddCaution', CheckboxType::class, [
'label' => 'Ne pas ajouter de caution',
'required' => false,
])
->add('customer', EntityType::class, [ ->add('customer', EntityType::class, [
'label' => 'Client', 'label' => 'Client',
'required' => true, 'required' => true,

View File

@@ -95,6 +95,16 @@
</div> </div>
</div> </div>
<div class="flex items-center space-x-4 bg-slate-900/40 p-4 rounded-2xl border border-white/5">
<div class="flex items-center h-5">
{{ form_widget(form.isNotAddCaution, {'attr': {'class': 'w-5 h-5 rounded border-white/10 bg-slate-800 text-blue-600 focus:ring-blue-500 focus:ring-offset-slate-900'}}) }}
</div>
<div class="ml-3 text-sm">
{{ form_label(form.isNotAddCaution, null, {'label_attr': {'class': 'font-medium text-white'}}) }}
<p class="text-xs text-slate-400">Cochez cette case pour ne pas inclure la caution dans ce devis.</p>
</div>
</div>
<hr class="border-white/5"> <hr class="border-white/5">
{# SECTION REPEATER #} {# SECTION REPEATER #}

View File

@@ -259,11 +259,85 @@
{{ form_end(form) }} {{ form_end(form) }}
{# 04. DOCUMENTS TECHNIQUES (PDF) #} {# 04. LISTE DES OPTIONS #}
{% if is_edit is defined and is_edit %}
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl mt-8">
<h3 class="text-lg font-bold text-white mb-6 flex items-center">
<span class="w-8 h-8 bg-indigo-600/20 text-indigo-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">04</span>
Options du produit
</h3>
{# LISTE DES OPTIONS #}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-10">
{% for option in product.options %}
<div class="flex items-center justify-between p-4 bg-white/5 border border-white/5 rounded-2xl group hover:bg-white/10 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 bg-indigo-500/20 text-indigo-500 rounded-xl flex items-center justify-center overflow-hidden">
{% if option.imageName %}
<img src="{{ vich_uploader_asset(option, 'imageFile') }}" class="w-full h-full object-cover">
{% else %}
<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 6h16M4 12h16M4 18h7" /></svg>
{% endif %}
</div>
<div>
<div class="text-xs font-black text-white uppercase tracking-wider">{{ option.name }}</div>
<div class="text-[9px] font-bold text-slate-500 uppercase tracking-tighter">
{{ option.priceHt }} € HT
</div>
</div>
</div>
<div class="flex gap-2">
<a data-turbo="false" href="{{ path('app_crm_product_edit', {'id': product.id, act:'deleteOption', idOption: option.id}) }}"
onclick="return confirm('Retirer cette option ?');"
class="p-2 text-slate-400 hover:text-rose-500 transition-colors" title="Retirer">
<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>
{% else %}
<div class="md:col-span-2 py-8 text-center border-2 border-dashed border-white/5 rounded-3xl">
<p class="text-[10px] font-black text-slate-600 uppercase tracking-[0.2em]">Aucune option liée</p>
</div>
{% endfor %}
</div>
<div class="h-px bg-white/5 w-full mb-10"></div>
{# FORMULAIRE D'AJOUT OPTION #}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 items-end">
<div class="md:col-span-2 bg-slate-950/40 p-6 rounded-[2rem] border border-dashed border-white/10 group hover:border-indigo-500/30 transition-all">
<label class="text-[10px] font-black text-slate-300 uppercase tracking-widest mb-4 block text-center">Ajouter une option existante</label>
<div class="flex gap-4">
<div class="relative w-full flex items-center w-fulla">
<input type="hidden" id="selected-option-id">
<input type="text" id="selected-option-name"
class="w-full bg-slate-900/50 border-white/5 rounded-2xl text-white focus:ring-indigo-500/20 focus:border-indigo-500 transition-all py-4 pl-5 pr-12 font-bold text-sm"
placeholder="Rechercher une option...">
<button is="search-optionsproduct" type="button"
class="absolute right-2 p-2 bg-indigo-500/10 hover:bg-indigo-500 text-indigo-400 hover:text-white rounded-xl transition-all duration-300 group/search"
title="Rechercher">
Rechercher une option
</button>
</div>
<button is="product-add-option" type="button" data-turbo="false"
data-input-id="selected-option-id"
data-url="{{ path('app_crm_product_edit', {id: product.id, act: 'addOption'}) }}"
class="px-8 bg-indigo-600 hover:bg-indigo-500 text-white text-[10px] font-black uppercase tracking-widest rounded-2xl transition-all shadow-lg shadow-indigo-600/30 whitespace-nowrap">
Ajouter
</button>
</div>
</div>
</div>
</div>
{% endif %}
{# 05. DOCUMENTS TECHNIQUES (PDF) #}
{% if formDoc is defined %} {% if formDoc is defined %}
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl mt-8"> <div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl mt-8">
<h3 class="text-lg font-bold text-white mb-6 flex items-center"> <h3 class="text-lg font-bold text-white mb-6 flex items-center">
<span class="w-8 h-8 bg-amber-600/20 text-amber-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">04</span> <span class="w-8 h-8 bg-amber-600/20 text-amber-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">05</span>
Documents & Notices Documents & Notices
</h3> </h3>
@@ -340,11 +414,11 @@
</div> </div>
{% endif %} {% endif %}
{# 05. VIDEOS #} {# 06. VIDEOS #}
{% if formVideo is defined %} {% if formVideo is defined %}
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl mt-8"> <div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl mt-8">
<h3 class="text-lg font-bold text-white mb-6 flex items-center"> <h3 class="text-lg font-bold text-white mb-6 flex items-center">
<span class="w-8 h-8 bg-rose-600/20 text-rose-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">05</span> <span class="w-8 h-8 bg-rose-600/20 text-rose-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">06</span>
Vidéos Vidéos
</h3> </h3>
@@ -399,11 +473,11 @@
</div> </div>
{% endif %} {% endif %}
{# 06. PHOTOS #} {# 07. PHOTOS #}
{% if formPhoto is defined %} {% if formPhoto is defined %}
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl mt-8"> <div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl mt-8">
<h3 class="text-lg font-bold text-white mb-6 flex items-center"> <h3 class="text-lg font-bold text-white mb-6 flex items-center">
<span class="w-8 h-8 bg-cyan-600/20 text-cyan-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">06</span> <span class="w-8 h-8 bg-cyan-600/20 text-cyan-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">07</span>
Photos Supplémentaires Photos Supplémentaires
</h3> </h3>
@@ -455,11 +529,11 @@
</div> </div>
{% endif %} {% endif %}
{# 07. PÉRIODES BLOQUÉES #} {# 08. PÉRIODES BLOQUÉES #}
{% if formBlocked is defined %} {% if formBlocked is defined %}
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl mt-8"> <div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[2.5rem] p-8 shadow-2xl mt-8">
<h3 class="text-lg font-bold text-white mb-6 flex items-center"> <h3 class="text-lg font-bold text-white mb-6 flex items-center">
<span class="w-8 h-8 bg-rose-600/20 text-rose-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">07</span> <span class="w-8 h-8 bg-rose-600/20 text-rose-500 rounded-lg flex items-center justify-center mr-3 text-[10px] font-black">08</span>
Indisponibilités & Blocages Indisponibilités & Blocages
</h3> </h3>

View File

@@ -138,18 +138,38 @@
<span class="text-[9px] font-black text-slate-400 uppercase tracking-widest italic">LA CAUTION</span> <span class="text-[9px] font-black text-slate-400 uppercase tracking-widest italic">LA CAUTION</span>
</div> </div>
</div> </div>
{# Message Caution Stripe #} {# Options disponibles #}
<div class="max-w-sm p-4 rounded-2xl bg-white/40 border border-slate-200/60 backdrop-blur-sm shadow-sm"> {% if product.options|length > 0 %}
<div class="flex gap-3"> <div class="w-full mt-6">
<svg class="w-5 h-5 text-blue-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <span class="text-[10px] font-black text-slate-400 uppercase tracking-[0.3em] mb-4 block italic text-center md:text-left">Options disponibles</span>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path> <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
</svg> {% for option in product.options %}
<p class="text-[10px] md:text-[11px] leading-relaxed text-slate-500"> {% if option.isPublish %}
<strong class="text-slate-900 font-bold uppercase tracking-tight block mb-0.5">Caution</strong> <div class="flex items-center gap-4 bg-white border border-slate-100 p-4 rounded-2xl shadow-sm hover:shadow-md transition-shadow">
Cette somme sera prélevée sur votre compte bancaire et pourra être bloquée pour un maximum de <span class="text-slate-900 font-semibold">4 jours</span> selon les politiques de <span class="font-medium text-blue-600">Stripe</span>. </p> <div class="w-16 h-16 bg-slate-50 rounded-xl overflow-hidden flex-shrink-0">
{% if option.imageName %}
<img src="{{ vich_uploader_asset(option, 'imageFile') }}" alt="{{ option.name }}" class="w-full h-full object-cover">
{% else %}
<div class="w-full h-full flex items-center justify-center text-slate-300">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7" /></svg>
</div>
{% endif %}
</div>
<div>
<h4 class="text-sm font-black text-slate-900 uppercase italic">{{ option.name }}</h4>
{% if tvaEnabled %}
<span class="text-xs font-bold text-[#f39e36]">+ {{ (option.priceHt*1.20)|format_currency('EUR') }} TTC</span>
{% else %}
<span class="text-xs font-bold text-[#f39e36]">+ {{ option.priceHt|format_currency('EUR') }}</span>
{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
</div> </div>
</div> </div>
{% endif %}
</div> </div>
</div> </div>

View File

@@ -95,15 +95,15 @@
<div class="flex-1 hidden md:block order-3"></div> <div class="flex-1 hidden md:block order-3"></div>
</div> </div>
{# 6 & 7. RAPPEL & CAUTION #} {# 6. RAPPEL #}
<div class="flex flex-col md:flex-row items-center gap-12 group" data-aos="fade-left"> <div class="flex flex-col md:flex-row items-center gap-12 group" data-aos="fade-left">
<div class="flex-1 hidden md:block"></div> <div class="flex-1 hidden md:block"></div>
<div class="w-24 h-24 bg-violet-600 rounded-[2rem] flex items-center justify-center shadow-xl shadow-violet-200 shrink-0 transform group-hover:-rotate-12 transition-transform"> <div class="w-24 h-24 bg-violet-600 rounded-[2rem] flex items-center justify-center shadow-xl shadow-violet-200 shrink-0 transform group-hover:-rotate-12 transition-transform">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg> <svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg>
</div> </div>
<div class="flex-1 text-center md:text-left"> <div class="flex-1 text-center md:text-left">
<h2 class="text-3xl font-black text-slate-900 uppercase italic mb-4 group-hover:text-violet-600 transition-colors">{{ 'workflow.step6_7.title'|trans }}</h2> <h2 class="text-3xl font-black text-slate-900 uppercase italic mb-4 group-hover:text-violet-600 transition-colors">{{ 'workflow.step6.title'|trans }}</h2>
<p class="text-slate-500 font-medium leading-relaxed">{{ 'workflow.step6_7.desc'|trans|raw }}</p> <p class="text-slate-500 font-medium leading-relaxed">{{ 'workflow.step6.desc'|trans|raw }}</p>
</div> </div>
</div> </div>

View File

@@ -5,13 +5,10 @@ GREEN='\033[0;32m'
CYAN='\033[0;36m' CYAN='\033[0;36m'
RESET='\033[0m' # Reset color to default RESET='\033[0m' # Reset color to default
sudo update-alternatives --set php /usr/bin/php8.4 echo "${CYAN}##########################${RESET}"
php bin/console app:git-log-update echo "${CYAN}# E-COSPLAY UPDATE START #${RESET}"
echo "${CYAN}####################################${RESET}" echo "${CYAN}##########################${RESET}"
echo "${CYAN}# LUDIKEVENT INTRANET UPDATE START #${RESET}"
echo "${CYAN}####################################${RESET}"
ansible-playbook -i ansible/hosts.ini ansible/playbook.yml ansible-playbook -i ansible/hosts.ini ansible/playbook.yml
echo "${CYAN}##############${RESET}" echo "${CYAN}##############${RESET}"
echo "${CYAN}# END UPDATE #${RESET}" echo "${CYAN}# END UPDATE #${RESET}"
echo "${CYAN}##############${RESET}" echo "${CYAN}##############${RESET}"