From 52e92b423064a2b2594c9b935732995d9d401957 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Tue, 27 Jan 2026 19:35:54 +0100 Subject: [PATCH] =?UTF-8?q?```=20=E2=9C=A8=20feat(devis):=20Am=C3=A9liore?= =?UTF-8?q?=20la=20s=C3=A9lection=20des=20produits=20et=20options=20avec?= =?UTF-8?q?=20modales=20de=20recherche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Supprime la relation Product->DevisLine, ajoute des composants de recherche modale pour produits/options dans les devis. ``` --- assets/admin.js | 10 +- assets/libs/SearchProductDevis.js | 288 +++++++++++++++++++ migrations/Version20260127091504.php | 38 +++ src/Command/DeployConfigCommand.php | 189 ++++++++++++ src/Controller/Dashboard/DevisController.php | 4 +- src/Entity/Devis.php | 3 - src/Entity/DevisLine.php | 8 +- src/Entity/Product.php | 36 --- templates/dashboard/devis/add.twig | 38 ++- 9 files changed, 548 insertions(+), 66 deletions(-) create mode 100644 assets/libs/SearchProductDevis.js create mode 100644 migrations/Version20260127091504.php create mode 100644 src/Command/DeployConfigCommand.php diff --git a/assets/admin.js b/assets/admin.js index 6503b3f..cd09583 100644 --- a/assets/admin.js +++ b/assets/admin.js @@ -1,11 +1,12 @@ import './admin.scss'; import * as Sentry from "@sentry/browser"; import * as Turbo from "@hotwired/turbo"; -import TomSelect from "tom-select"; + import { RepeatLine } from "./libs/RepeatLine.js"; import { DevisManager } from "./libs/DevisManager.js"; import { initTomSelect } from "./libs/initTomSelect.js"; import { SearchProduct,SearchOptions } from "./libs/SearchProduct.js"; +import { SearchProductDevis,SearchOptionsDevis } from "./libs/SearchProductDevis.js"; // --- INITIALISATION SENTRY --- Sentry.init({ dsn: "https://803814be6540031b1c37bf92ba9c0f79@sentry.esy-web.dev/24", @@ -70,6 +71,13 @@ function initAdminLayout() { if (!customElements.get('search-options')) { customElements.define('search-options', SearchOptions, { extends: 'button' }); } + if (!customElements.get('search-productdevis')) { + customElements.define('search-productdevis', SearchProductDevis, { extends: 'button' }); + } + if (!customElements.get('search-optionsdevis')) { + customElements.define('search-optionsdevis', SearchOptionsDevis, { extends: 'button' }); + } + // S // Sidebar & UI const sidebar = document.getElementById('sidebar'); const overlay = document.getElementById('sidebar-overlay'); diff --git a/assets/libs/SearchProductDevis.js b/assets/libs/SearchProductDevis.js new file mode 100644 index 0000000..5ddd4c7 --- /dev/null +++ b/assets/libs/SearchProductDevis.js @@ -0,0 +1,288 @@ +export class SearchOptionsDevis extends HTMLButtonElement { + constructor() { + super(); + this.allOptions = []; + } + + connectedCallback() { + this.addEventListener('click', () => 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 = '
Chargement des options...
'; + + try { + const response = await fetch('/crm/options/json'); + this.allOptions = await response.json(); + this.renderOptions(this.allOptions, container, modal); + searchInput.focus(); + } catch (error) { + container.innerHTML = '
Erreur catalogue options.
'; + } + } + + 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-slate-950/90 backdrop-blur-md hidden animate-in fade-in duration-300'; + div.innerHTML = ` +
+
+
+

+ + Sélection Option +

+ +
+ +
+ + + + +
+
+ +
+ +
+

Appuyez sur Échap pour fermer

+
+
+ `; + 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 = '
Aucune option trouvée
'; + 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-500/10 hover:border-blue-500/40 transition-all cursor-pointer group animate-in fade-in slide-in-from-bottom-2'; + + const imgHtml = option.image + ? `` + : `
OPT
`; + + card.innerHTML = ` + ${imgHtml} +
+
${option.name}
+
+ PRIX HT: ${option.price}€ +
+
+
+ +
+ `; + + card.onclick = () => { + this.fillOptionLine(option); + modal.classList.add('hidden'); + }; + container.appendChild(card); + }); + } + + fillOptionLine(option) { + // On cherche la ligne parente (ajuste le sélecteur si différent de celui des produits) + const row = this.closest('.form-repeater__row'); + if (row) { + // Mapping selon ta structure de DevisOption + const nameInput = row.querySelector('input[name*="[product]"]'); + const priceInput = row.querySelector('input[name*="[price_ht]"]'); + + if(nameInput) nameInput.value = option.name; + if(priceInput) priceInput.value = option.price; + + // Feedback visuel (Bleu pour les options) + const fieldset = row.querySelector('fieldset'); + if (fieldset) { + fieldset.classList.add('border-blue-500/50', 'bg-blue-500/5'); + setTimeout(() => fieldset.classList.remove('border-blue-500/50', 'bg-blue-500/5'), 800); + } + } + } +} + +export class SearchProductDevis extends HTMLButtonElement { + constructor() { + super(); + this.allProducts = []; // Stockage local pour la recherche + } + + connectedCallback() { + this.addEventListener('click', () => this.openModal()); + } + + async openModal() { + let modal = document.getElementById('modal-search-product'); + if (!modal) { + modal = this.createModalStructure(); + document.body.appendChild(modal); + this.setupSearchEvent(modal); + } + + modal.classList.remove('hidden'); + const container = modal.querySelector('#results-container'); + const searchInput = modal.querySelector('#modal-search-input'); + + searchInput.value = ''; // Reset recherche + container.innerHTML = '
Synchronisation catalogue...
'; + + try { + const response = await fetch('/crm/products/json'); + this.allProducts = await response.json(); + this.renderProducts(this.allProducts, container, modal); + searchInput.focus(); + } catch (error) { + container.innerHTML = '
Erreur catalogue.
'; + } + } + + createModalStructure() { + const div = document.createElement('div'); + div.id = 'modal-search-product'; + div.className = 'fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-950/90 backdrop-blur-md hidden animate-in fade-in duration-300'; + div.innerHTML = ` +
+
+
+

+ + Sélection Produit +

+ +
+ +
+ + + + +
+
+ +
+ +
+

Appuyez sur Échap pour fermer

+
+
+ `; + return div; + } + + setupSearchEvent(modal) { + const input = modal.querySelector('#modal-search-input'); + const container = modal.querySelector('#results-container'); + + input.oninput = () => { + const query = input.value.toLowerCase().trim(); + const filtered = this.allProducts.filter(p => + p.name.toLowerCase().includes(query) + ); + this.renderProducts(filtered, container, modal); + }; + + // Fermeture sur Echap + window.addEventListener('keydown', (e) => { + if (e.key === 'Escape') modal.classList.add('hidden'); + }); + } + + renderProducts(products, container, modal) { + container.innerHTML = ''; + + if (products.length === 0) { + container.innerHTML = '
Aucun produit trouvé
'; + return; + } + + products.forEach(product => { + 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-purple-500/10 hover:border-purple-500/40 transition-all cursor-pointer group animate-in fade-in slide-in-from-bottom-2'; + + const imgHtml = product.image + ? `` + : `
IMG
`; + + card.innerHTML = ` + ${imgHtml} +
+
${product.name}
+
+ 1J: ${product.price1day}€ + SUP: ${product.priceSup}€ + CAUTION: ${product.caution}€ +
+
+
+ +
+ `; + + card.onclick = () => { + this.fillFormLine(product); + modal.classList.add('hidden'); + }; + container.appendChild(card); + }); + } + + fillFormLine(product) { + const row = this.closest('.form-repeater__row'); + if (row) { + row.querySelector('input[name*="[product]"]').value = product.name; + row.querySelector('input[name*="[price_ht]"]').value = product.price1day; + row.querySelector('input[name*="[price_sup_ht]"]').value = product.priceSup; + + const fieldset = row.querySelector('fieldset'); + fieldset.classList.add('border-emerald-500/50', 'bg-emerald-500/5'); + setTimeout(() => fieldset.classList.remove('border-emerald-500/50', 'bg-emerald-500/5'), 800); + } + } +} + diff --git a/migrations/Version20260127091504.php b/migrations/Version20260127091504.php new file mode 100644 index 0000000..63c6df7 --- /dev/null +++ b/migrations/Version20260127091504.php @@ -0,0 +1,38 @@ +addSql('ALTER TABLE devis_line DROP CONSTRAINT fk_9ec6d5294584665a'); + $this->addSql('DROP INDEX idx_9ec6d5294584665a'); + $this->addSql('ALTER TABLE devis_line ADD product VARCHAR(255) NOT NULL'); + $this->addSql('ALTER TABLE devis_line DROP product_id'); + } + + 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 devis_line ADD product_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE devis_line DROP product'); + $this->addSql('ALTER TABLE devis_line ADD CONSTRAINT fk_9ec6d5294584665a FOREIGN KEY (product_id) REFERENCES product (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + $this->addSql('CREATE INDEX idx_9ec6d5294584665a ON devis_line (product_id)'); + } +} diff --git a/src/Command/DeployConfigCommand.php b/src/Command/DeployConfigCommand.php new file mode 100644 index 0000000..a1ad7b5 --- /dev/null +++ b/src/Command/DeployConfigCommand.php @@ -0,0 +1,189 @@ += 2 ? implode('.', array_slice($parts, -2)) : $host; + + $io->success(sprintf('Hôte principal détecté : %s', $mainHost)); + + // 1. Gestion du cache local + $io->section('Gestion du cache local'); + $filesystem = new Filesystem(); + $cachePath = sys_get_temp_dir() . '/esycms-cache'; + + if ($filesystem->exists($cachePath)) { + $filesystem->remove($cachePath); + $io->note('Dossier esycms-cache local supprimé.'); + } + + // 2. Configuration Cloudflare + $io->section('Configuration Cloudflare (Rulesets)'); + $cfToken = $_ENV['CLOUDFLARE_DEPLOY'] ?? null; + + if (!$cfToken) { + $io->error('La clé API Cloudflare (CLOUDFLARE_DEPLOY) est manquante.'); + return Command::FAILURE; + } + + try { + // A. Récupération de la Zone + $response = $this->httpClient->request('GET', 'https://api.cloudflare.com/client/v4/zones', [ + 'headers' => ['Authorization' => 'Bearer ' . $cfToken], + 'query' => ['name' => $fqdn, 'status' => 'active'], + ]); + + $data = $response->toArray(); + if (empty($data['result'])) { + $io->error(sprintf('Zone introuvable pour : %s', $fqdn)); + return Command::FAILURE; + } + + $zoneId = $data['result'][0]['id']; + + // B. Récupération/Création du Ruleset + $rulesetsResponse = $this->httpClient->request('GET', "https://api.cloudflare.com/client/v4/zones/$zoneId/rulesets", [ + 'headers' => ['Authorization' => 'Bearer ' . $cfToken] + ]); + + $rulesets = $rulesetsResponse->toArray()['result'] ?? []; + $rulesetId = null; + foreach ($rulesets as $rs) { + if ($rs['phase'] === self::CACHE_PHASE) { + $rulesetId = $rs['id']; + break; + } + } + + if (!$rulesetId) { + $createResponse = $this->httpClient->request('POST', "https://api.cloudflare.com/client/v4/zones/$zoneId/rulesets", [ + 'headers' => ['Authorization' => 'Bearer ' . $cfToken, 'Content-Type' => 'application/json'], + 'json' => [ + 'name' => 'EsyCMS Cache Ruleset', + 'kind' => 'zone', + 'phase' => self::CACHE_PHASE + ] + ]); + $rulesetId = $createResponse->toArray()['result']['id']; + } + + // C. Récupération des règles actuelles pour nettoyage + $rulesResponse = $this->httpClient->request('GET', "https://api.cloudflare.com/client/v4/zones/$zoneId/rulesets/$rulesetId", [ + 'headers' => ['Authorization' => 'Bearer ' . $cfToken] + ]); + $currentRules = $rulesResponse->toArray()['result']['rules'] ?? []; + + // D. Nettoyage des anciennes règles gérées par ce script + $sanitizedRules = []; + foreach ($currentRules as $rule) { + $desc = $rule['description'] ?? ''; + if ($desc === self::LIBRARY_RULE_NAME || $desc === self::PDF_RULE_NAME) { + continue; + } + $sanitizedRules[] = [ + 'expression' => $rule['expression'], + 'description' => $rule['description'] ?? '', + 'action' => $rule['action'], + 'action_parameters' => $rule['action_parameters'] ?? null, + 'enabled' => $rule['enabled'] ?? true, + ]; + } + + $hostPart = sprintf('(http.host in {"%s", "%s"})', $hostIntranet, $hostReservation); + + // --- RÈGLE 1 : DESACTIVER LE CACHE POUR LES PDF --- + $sanitizedRules[] = [ + 'expression' => "$hostPart and (http.request.uri.path.extension eq \"pdf\")", + 'description' => self::PDF_RULE_NAME, + 'action' => 'set_cache_settings', + 'action_parameters' => [ + 'cache' => false // Désactive explicitement le cache + ], + 'enabled' => true + ]; + + // --- RÈGLE 2 : CACHE LONGUE DURÉE POUR MÉDIAS (Images/Vidéos) --- + $paths = ['/storage', '/media', '/image', '/provider']; + $pathPrefixes = array_map(fn($p) => "starts_with(http.request.uri.path, \"$p/\")", $paths); + $pathPart = "(" . implode(" or ", $pathPrefixes) . ")"; + + $extensions = [ + 'jpg', 'jpeg', 'png', 'gif', 'webp', 'avif', 'svg', 'ico', + 'mp4', 'webm', 'ogg', 'mov', 'm4v' + ]; + $extensionPart = '(http.request.uri.path.extension in {"' . implode('", "', $extensions) . '"})'; + + $sanitizedRules[] = [ + 'expression' => "$hostPart and $pathPart and $extensionPart", + 'description' => self::LIBRARY_RULE_NAME, + 'action' => 'set_cache_settings', + 'action_parameters' => [ + 'cache' => true, + 'edge_ttl' => [ + 'mode' => 'override_origin', + 'default' => 31536000 + ], + 'browser_ttl' => [ + 'mode' => 'override_origin', + 'default' => 31536000 + ] + ], + 'enabled' => true + ]; + + // F. Mise à jour Cloudflare + $this->httpClient->request('PUT', "https://api.cloudflare.com/client/v4/zones/$zoneId/rulesets/$rulesetId", [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $cfToken, + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'rules' => $sanitizedRules + ] + ]); + + $io->success('Configuration Cloudflare déployée : Cache désactivé pour les PDF, activé 1 an pour les médias.'); + + } catch (\Exception $e) { + $io->error('Erreur Cloudflare : ' . $e->getMessage()); + return Command::FAILURE; + } + + return Command::SUCCESS; + } +} diff --git a/src/Controller/Dashboard/DevisController.php b/src/Controller/Dashboard/DevisController.php index 8df8f36..eebc812 100644 --- a/src/Controller/Dashboard/DevisController.php +++ b/src/Controller/Dashboard/DevisController.php @@ -197,7 +197,7 @@ class DevisController extends AbstractController 'form' => $form->createView(), 'lines' => [ [ - 'product_id' => '', + 'product' => '', 'days'=>'', 'price_ht' => '', 'price_sup_ht' =>'' @@ -205,7 +205,7 @@ class DevisController extends AbstractController ], 'options' => [ [ - 'product_id' => '', + 'product' => '', 'price_ht' => '', ] ] diff --git a/src/Entity/Devis.php b/src/Entity/Devis.php index 0d7ff78..445fa6b 100644 --- a/src/Entity/Devis.php +++ b/src/Entity/Devis.php @@ -65,9 +65,6 @@ class Devis #[ORM\Column(length: 255, nullable: true)] private ?string $signatureId = null; - /** - * @var Collection - */ #[ORM\OneToMany(targetEntity: DevisLine::class, mappedBy: 'devi')] private Collection $devisLines; diff --git a/src/Entity/DevisLine.php b/src/Entity/DevisLine.php index 2c8a317..9f64307 100644 --- a/src/Entity/DevisLine.php +++ b/src/Entity/DevisLine.php @@ -30,8 +30,8 @@ class DevisLine private ?int $day = null; - #[ORM\ManyToOne(inversedBy: 'devisLines')] - private ?Product $product = null; + #[ORM\Column] + private string $product = ""; public function getId(): ?int { @@ -99,12 +99,12 @@ class DevisLine return $this; } - public function getProduct(): ?Product + public function getProduct(): ?string { return $this->product; } - public function setProduct(?Product $product): static + public function setProduct(?string $product): static { $this->product = $product; diff --git a/src/Entity/Product.php b/src/Entity/Product.php index 1cad335..244fc6c 100644 --- a/src/Entity/Product.php +++ b/src/Entity/Product.php @@ -54,12 +54,6 @@ class Product #[ORM\Column(length: 255, nullable: true)] private ?string $productId = null; - /** - * @var Collection - */ - #[ORM\OneToMany(targetEntity: DevisLine::class, mappedBy: 'product')] - private Collection $devisLines; - /** * @var Collection */ @@ -89,7 +83,6 @@ class Product public function __construct() { - $this->devisLines = new ArrayCollection(); $this->productReserves = new ArrayCollection(); $this->productDocs = new ArrayCollection(); } @@ -234,35 +227,6 @@ class Product return $this; } - /** - * @return Collection - */ - public function getDevisLines(): Collection - { - return $this->devisLines; - } - - public function addDevisLine(DevisLine $devisLine): static - { - if (!$this->devisLines->contains($devisLine)) { - $this->devisLines->add($devisLine); - $devisLine->setProduct($this); - } - - return $this; - } - - public function removeDevisLine(DevisLine $devisLine): static - { - if ($this->devisLines->removeElement($devisLine)) { - // set the owning side to null (unless already changed) - if ($devisLine->getProduct() === $this) { - $devisLine->setProduct(null); - } - } - - return $this; - } /** * @return Collection diff --git a/templates/dashboard/devis/add.twig b/templates/dashboard/devis/add.twig index d7a0897..94d7ba8 100644 --- a/templates/dashboard/devis/add.twig +++ b/templates/dashboard/devis/add.twig @@ -113,16 +113,15 @@
{# 1. PRODUIT #}
- - + +
+ + + {# BOUTON RECHERCHER #} + +
{# 3. PRIX HT J1 #} @@ -174,16 +173,15 @@ {% endif %} {# 1. PRODUIT #}
- - + +
+ + + {# BOUTON RECHERCHER #} + +
{# 3. PRIX HT J1 #}