feat(Formules.php): Ajoute relation OneToOne avec FormulesRestriction.
 feat(Dashboard/FormulesController.php): Gère restrictions formules et formulaire.
🎨 refactor(template/formules): Améliore interface configuration restriction formule.
🐛 fix(assets/RepeatLine.js): Corrige réinitialisation TomSelect et selects "Type".
 feat(assets/initTomSelect.js): Gère cache options et init TomSelect.
```
This commit is contained in:
Serreau Jovann
2026-01-28 15:59:49 +01:00
parent 17cf110cf0
commit 8a8f39d975
11 changed files with 491 additions and 94 deletions

View File

@@ -42,39 +42,41 @@ export class RepeatLine extends HTMLDivElement {
addRow() {
if (!this.rowHTML || this.$refs.rows.children.length >= this.$props.maxRows) return;
// Création de la nouvelle ligne
let newRow = this.createFromHTML(this.rowHTML);
newRow.removeAttribute('id');
// Nettoyage spécifique pour TomSelect avant insertion
// Si on clone une ligne qui avait déjà TomSelect, on reset le select
newRow.querySelectorAll('select').forEach(select => {
// Supprimer les classes et éléments injectés par TomSelect si présents dans le template
select.classList.remove('tomselect', 'ts-hidden-visually');
select.innerHTML = '<option value="">Sélectionner...</option>';
// Supprimer le wrapper TomSelect s'il a été cloné par erreur
const wrapper = select.nextElementSibling;
if (wrapper && wrapper.classList.contains('ts-wrapper')) {
wrapper.remove();
// 1. Si c'est un select TomSelect (sans attribut 'is')
if (!select.hasAttribute('ds')) {
select.classList.remove('tomselect', 'ts-hidden-visually');
select.innerHTML = '<option value="">Sélectionner...</option>';
const wrapper = select.nextElementSibling;
if (wrapper && wrapper.classList.contains('ts-wrapper')) {
wrapper.remove();
}
}
// 2. Si c'est votre select "Type" (avec l'attribut 'is')
else {
// On ne touche PAS au innerHTML pour garder les options (Structure, etc.)
select.value = ""; // On remet juste à zéro la sélection
}
});
this.setUpRow(newRow);
this.$refs.rows.appendChild(newRow);
// Réinitialisation des valeurs
newRow.querySelectorAll('input,textarea,select').forEach(el => {
// Réinitialisation des autres champs
newRow.querySelectorAll('input,textarea').forEach(el => {
el.value = "";
if (el.tagName === 'SELECT') el.selectedIndex = 0;
});
// --- INITIALISATION TOMSELECT SUR LA NOUVELLE LIGNE ---
// Initialisation TomSelect uniquement sur ce qui doit l'être
initTomSelect(newRow);
this.updateFieldNames();
this.updateAddButton();
// Focus sur le premier élément de la nouvelle ligne
const firstInput = newRow.querySelector('input,textarea,select');
if (firstInput) firstInput.focus();
}

View File

@@ -1,14 +1,17 @@
// Cache pour éviter les requêtes HTTP répétitives
import TomSelect from "tom-select";
// Cache séparé pour éviter les conflits entre produits et options
let productCache = null;
let optionsCache = null;
/**
* Initialise TomSelect sur un élément ou un groupe d'éléments
*/
export function initTomSelect(parent = document) {
parent.querySelectorAll('select').forEach((el) => {
if (el.tomselect) return;
// --- CLAUSES DE GARDE ---
// On ignore si déjà initialisé OU si l'élément possède l'attribut "is"
if (el.tomselect || el.hasAttribute('ds')) return;
// --- CONFIGURATION PRODUITS ---
if (el.getAttribute('data-load') === "product") {
@@ -19,34 +22,23 @@ export function initTomSelect(parent = document) {
searchField: 'name',
options: data,
maxOptions: null,
// LORSQU'ON SÉLECTIONNE UN PRODUIT
// Dans admin.js, section onChange de TomSelect :
onChange: (id) => {
if (!id) return;
// On s'assure de trouver le produit (id peut être string ou int)
const product = data.find(p => String(p.id) === String(id));
if (product) {
// On remonte au parent le plus proche (le bloc de ligne du devis)
const row = el.closest('.form-repeater__row') || el.closest('fieldset');
if (!row) return;
// Ciblage précis des inputs
const priceInput = row.querySelector('input[name*="[price_ht]"]');
const priceSupInput = row.querySelector('input[name*="[price_sup_ht]"]');
if (priceInput) {
priceInput.value = product.price1day;
// Indispensable pour que d'autres scripts (calcul totaux) voient le changement
priceInput.dispatchEvent(new Event('input', { bubbles: true }));
priceInput.dispatchEvent(new Event('change', { bubbles: true }));
}
if (priceSupInput) {
priceSupInput.value = product.priceSup;
priceSupInput.dispatchEvent(new Event('input', { bubbles: true }));
priceSupInput.dispatchEvent(new Event('change', { bubbles: true }));
}
}
},
@@ -59,16 +51,11 @@ export function initTomSelect(parent = document) {
<div class="text-[10px] text-slate-400">J1: ${escape(data.price1day)}€ | Sup: ${escape(data.priceSup)}€</div>
</div>
</div>`,
item: (data, escape) => `
<div class="text-blue-400 font-bold flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span>
${escape(data.name)}
</div>`
item: (data, escape) => `<div class="text-blue-400 font-bold flex items-center gap-2"><span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span>${escape(data.name)}</div>`
}
});
};
// Utilisation du cache ou fetch
if (productCache) {
setupSelect(productCache);
} else {
@@ -80,6 +67,7 @@ export function initTomSelect(parent = document) {
});
}
}
// --- CONFIGURATION OPTIONS ---
else if (el.getAttribute('data-load') === "options") {
const setupSelect = (data) => {
new TomSelect(el, {
@@ -88,12 +76,8 @@ export function initTomSelect(parent = document) {
searchField: 'name',
options: data,
maxOptions: null,
// LORSQU'ON SÉLECTIONNE UN PRODUIT
// Dans admin.js, section onChange de TomSelect :
onChange: (id) => {
if (!id) return;
// On s'assure de trouver le produit (id peut être string ou int)
const product = data.find(p => String(p.id) === String(id));
const row = el.closest('.form-repeater__row') || el.closest('fieldset');
if (!row) return;
@@ -101,11 +85,8 @@ export function initTomSelect(parent = document) {
if (priceInput) {
priceInput.value = product.price;
// Indispensable pour que d'autres scripts (calcul totaux) voient le changement
priceInput.dispatchEvent(new Event('input', { bubbles: true }));
priceInput.dispatchEvent(new Event('change', { bubbles: true }));
}
},
render: {
option: (data, escape) => `
@@ -116,28 +97,23 @@ export function initTomSelect(parent = document) {
<div class="text-[10px] text-slate-400">${escape(data.price)}€</div>
</div>
</div>`,
item: (data, escape) => `
<div class="text-blue-400 font-bold flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span>
${escape(data.name)}
</div>`
item: (data, escape) => `<div class="text-blue-400 font-bold flex items-center gap-2"><span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span>${escape(data.name)}</div>`
}
});
};
// Utilisation du cache ou fetch
if (productCache) {
setupSelect(productCache);
if (optionsCache) {
setupSelect(optionsCache);
} else {
fetch("/crm/options/json")
.then(r => r.json())
.then(data => {
productCache = data;
optionsCache = data;
setupSelect(data);
});
}
}
// --- AUTRES SELECTS ---
// --- AUTRES SELECTS STANDARDS ---
else {
new TomSelect(el, {
controlInput: null,

View File

@@ -0,0 +1,36 @@
<?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 Version20260128142222 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 formules_restriction (id SERIAL NOT NULL, formule_id INT DEFAULT NULL, nb_structure_max INT NOT NULL, restriction_config TEXT DEFAULT NULL, PRIMARY KEY(id))');
$this->addSql('CREATE UNIQUE INDEX UNIQ_D3436D392A68F4D1 ON formules_restriction (formule_id)');
$this->addSql('COMMENT ON COLUMN formules_restriction.restriction_config IS \'(DC2Type:array)\'');
$this->addSql('ALTER TABLE formules_restriction ADD CONSTRAINT FK_D3436D392A68F4D1 FOREIGN KEY (formule_id) REFERENCES formules (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
}
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 formules_restriction DROP CONSTRAINT FK_D3436D392A68F4D1');
$this->addSql('DROP TABLE formules_restriction');
}
}

View File

@@ -0,0 +1,34 @@
<?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 Version20260128144635 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 formules_restriction ADD nb_alimentaire_max INT DEFAULT NULL');
$this->addSql('ALTER TABLE formules_restriction ADD nb_barhums_max INT DEFAULT NULL');
}
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 formules_restriction DROP nb_alimentaire_max');
$this->addSql('ALTER TABLE formules_restriction DROP nb_barhums_max');
}
}

View File

@@ -8,29 +8,45 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Process\Process;
// Utilisation des namespaces que tu as fournis
use Symfony\Component\HttpKernel\KernelInterface; // Pour le dossier projet
use Symfony\Contracts\HttpClient\HttpClientInterface;
use GeminiAPI\Client;
use GeminiAPI\Resources\Parts\TextPart;
#[AsCommand(
name: 'app:git-log-update',
description: 'Archive le dernier commit avec reformulation IA pour le client.',
description: 'Archive le dernier commit avec reformulation IA et notification Discord.',
)]
class GitSyncLogCommand extends Command
{
private HttpClientInterface $httpClient;
private string $projectDir;
// On injecte le Kernel pour le chemin du projet et HttpClient pour Discord
public function __construct(HttpClientInterface $httpClient, KernelInterface $kernel)
{
parent::__construct();
$this->httpClient = $httpClient;
$this->projectDir = $kernel->getProjectDir();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$filePath = 'var/update.json';
$projectDir = '/srv/app';
// Chemins dynamiques basés sur ProjectDir
$filePath = $this->projectDir . '/var/update.json';
$discordWebhook = 'https://discord.com/api/webhooks/1466084672306941952/G8rTd1AU0ytp18PWJ2tF6urIG_vTrc242kqGNUAGxy-SCGtLr3eCI0e-ZnbnKj4JP5CE';
// 1. Récupération des infos Git
// On utilise $this->projectDir pour le safe.directory
$gitCmd = sprintf(
'git config --global --add safe.directory %s && git log -1 --format="%%s|%%ci|%%h"',
$projectDir
$this->projectDir
);
$process = Process::fromShellCommandline($gitCmd);
$process->setWorkingDirectory($this->projectDir); // On force le dossier de travail
$process->run();
if (!$process->isSuccessful()) {
@@ -46,60 +62,41 @@ class GitSyncLogCommand extends Command
// 2. Détermination du TYPE (feature, fix, optimise, new)
$type = 'new';
$lowerMsg = strtolower($rawMessage);
if (preg_match('/(fix|bug|patch|correct)/', $lowerMsg)) $type = 'fix';
elseif (preg_match('/(feat|add|create|nouveau|new)/', $lowerMsg)) $type = 'feature';
elseif (preg_match('/(perf|opti|refactor|clean|speed)/', $lowerMsg)) $type = 'optimise';
if (preg_match('/(fix|bug|patch|correct)/', $lowerMsg)) {
$type = 'fix';
} elseif (preg_match('/(feat|add|create|nouveau|new)/', $lowerMsg)) {
$type = 'feature';
} elseif (preg_match('/(perf|opti|refactor|clean|speed)/', $lowerMsg)) {
$type = 'optimise';
}
// 3. Vérification anti-doublon (Basée sur le Hash)
// 3. Vérification anti-doublon
$data = [];
if (file_exists($filePath)) {
$data = json_decode(file_get_contents($filePath), true) ?? [];
}
if (!empty($data) && $data[0]['hash'] === $commitHash) {
$io->info("Le commit [$commitHash] est déjà dans le journal client.");
$io->info("Le commit [$commitHash] est déjà à jour.");
return Command::SUCCESS;
}
// 4. Appel IA Gemini-3-Pro-Preview pour la reformulation
// 4. Appel IA Gemini
$friendlyMessage = $rawMessage;
try {
// Ta clé API
$client = new Client("AIzaSyDTPJERlUC47bcvhZU51Lwpqb1uxXS8SIg");
$model = 'gemini-3-pro-preview';
$prompt = "Tu es un expert en communication web pour Ludik Event. Ta mission est de transformer
un message de commit technique en une note de mise à jour élégante pour ton client.
MESSAGE TECHNIQUE : \"$rawMessage\"
DIRECTIVES : Court, positif, pas de 'Voici la phrase', uniquement le résultat final.";
DIRECTIVES :
1. Reformule pour un propriétaire de site non-technique.
2. Sois court, positif et rassurant.
3. Ne commence JAMAIS par 'Voici la phrase' ou 'Mise à jour'.
4. Donne uniquement le texte final prêt à être affiché.
RÉSULTAT ATTENDU :";
$response = $client->withV1BetaVersion()->generativeModel($model)->generateContent(
new TextPart($prompt)
);
// Adaptation selon la structure de retour du SDK
$aiText = $response->text();
if ($aiText) {
$friendlyMessage = trim($aiText);
$response = $client->withV1BetaVersion()->generativeModel($model)->generateContent(new TextPart($prompt));
if ($response->text()) {
$friendlyMessage = trim($response->text());
}
} catch (\Exception $e) {
$io->warning("L'IA n'a pas pu traiter le message. Utilisation du texte brut.");
$io->warning("L'IA n'a pas pu traiter le message.");
}
// 5. Création de l'entrée JSON
// 5. Mise à jour du fichier JSON
$newEntry = [
'type' => $type,
'message' => $friendlyMessage,
@@ -107,18 +104,29 @@ class GitSyncLogCommand extends Command
'hash' => $commitHash
];
// 6. Sauvegarde et rotation (5 max)
array_unshift($data, $newEntry);
$data = array_slice($data, 0, 6);
if (!is_dir('var')) {
mkdir('var', 0777, true);
$varDir = $this->projectDir . '/var';
if (!is_dir($varDir)) {
mkdir($varDir, 0777, true);
}
file_put_contents($filePath, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
$io->success("Journal client mis à jour avec succès (Type: $type).");
// 6. Envoi Discord
try {
$discordMessage = "📢 **Mise à jour sera prochaine mise en ligne sur votre intranet**\n\n> " . $friendlyMessage;
$this->httpClient->request('POST', $discordWebhook, [
'json' => ['content' => $discordMessage]
]);
$io->note("Notification Discord envoyée.");
} catch (\Exception $e) {
$io->error("Erreur Discord : " . $e->getMessage());
}
$io->success("Journal client mis à jour avec succès.");
return Command::SUCCESS;
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Controller\Dashboard;
use App\Entity\Formules;
use App\Entity\FormulesOptionsInclus;
use App\Entity\FormulesProductInclus;
use App\Entity\FormulesRestriction;
use App\Entity\Options;
use App\Entity\Product;
use App\Entity\ProductDoc;
@@ -59,6 +60,8 @@ class FormulesController extends AbstractController
if ($this->isCsrfTokenValid('delete' . $formules->getId(), $request->query->get('_token'))) {
$nomFormule = $formules->getName();
if($formules->getFormulesRestriction() instanceof FormulesRestriction)
$entityManager->remove($formules->getFormulesRestriction());
// 3. Suppression
foreach ($formules->getFormulesProductIncluses() as $formulesProductInclus)
$entityManager->remove($formulesProductInclus);
@@ -88,6 +91,15 @@ class FormulesController extends AbstractController
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($formules);
if($formules->getType() == "free") {
$formuleRestriction = new FormulesRestriction();
$formuleRestriction->setFormule($formules);
$formuleRestriction->setNbStructureMax(0);
$formuleRestriction->setNbAlimentaireMax(0);
$formuleRestriction->setNbBarhumsMax(0);
$formuleRestriction->setRestrictionConfig([]);
$entityManager->persist($formuleRestriction);
}
$entityManager->flush();
$appLogger->record('CREATED', "Création de la formule : " . $formules->getName());
$this->addFlash("success", "La formule a été créée avec succès.");
@@ -159,6 +171,17 @@ class FormulesController extends AbstractController
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formules->getId()]);
}
if ($request->isMethod('POST') && $request->request->has('rest')) {
$rest = $request->request->all('rest');
$f = $formules->getFormulesRestriction()->setRestrictionConfig($rest);
$entityManager->persist($f);
$entityManager->flush();
$appLogger->record('UPDATE', "Mise à jour des restriction pour : " . $formules->getName());
$this->addFlash("success", "Les restriction ont été mis à jour.");
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formules->getId()]);
}
// 2. GESTION DES PRIX (Formulaire Manuel price[])
// On vérifie si le tableau 'price' existe dans la requête POST
@@ -183,8 +206,41 @@ class FormulesController extends AbstractController
$form = $this->createForm(FormulesType::class, $formules);
$form->handleRequest($request);
if ($request->isMethod('POST') && $request->request->has('nbStructureMax')) {
// Vérification du type de formule
if ($formules->getType() === "free") {
$nbStructureMax = $request->request->get('nbStructureMax');
$nbBarhumsMax = $request->request->get('nbBarhumsMax');
$nbAlimentaireMax = $request->request->get('nbAlimentaireMax');
$rc = $formules->getFormulesRestriction();
// Si la restriction existe, on met à jour la valeur
if ($rc) {
$rc->setNbStructureMax((int)$nbStructureMax);
$rc->setNbBarhumsMax((int)$nbBarhumsMax);
$rc->setNbAlimentaireMax((int)$nbAlimentaireMax);
// Persistance des modifications
$entityManager->persist($rc);
$entityManager->flush();
// Log de l'activité
$appLogger->record('UPDATE', "Modification de la formule (restriction) : " . $formules->getName());
$this->addFlash("success", "La restriction de la formule a été modifiée avec succès.");
} else {
$this->addFlash("error", "Aucune restriction trouvée pour cette formule.");
}
} else {
$this->addFlash("warning", "Le nombre de structures max n'est modifiable que pour les formules gratuites.");
}
return $this->redirectToRoute('app_crm_formules_view', ['id' => $formules->getId()]);
}
if ($form->isSubmitted() && $form->isValid()) {
$entityManager->persist($formules);
// 2. Sauvegarde globale
$entityManager->flush();
$appLogger->record('UPDATE', "Modification de la formule (infos) : " . $formules->getName());
@@ -211,12 +267,22 @@ class FormulesController extends AbstractController
$options[$key]['product'] = $fc->getName();
$options[$key]['id'] = $fc->getId();
}
$restriction =[
[
'type' => 'structure',
'product' => ''
]
];
if(!empty($formules->getFormulesRestriction()->getRestrictionConfig())){
$restriction = $formules->getFormulesRestriction()->getRestrictionConfig();
}
return $this->render('dashboard/formules/view.twig', [
'formule' => $formules,
'form' => $form->createView(),
'type' => $formules->getType(),
'lines' => $lines,
'option' => $options,
'restriction' => $restriction,
]);
}

View File

@@ -69,6 +69,9 @@ class Formules
#[ORM\OneToMany(targetEntity: FormulesOptionsInclus::class, mappedBy: 'formule')]
private Collection $formulesOptionsIncluses;
#[ORM\OneToOne(mappedBy: 'formule', cascade: ['persist', 'remove'])]
private ?FormulesRestriction $formulesRestriction = null;
public function __construct()
{
$this->formulesProductIncluses = new ArrayCollection();
@@ -307,4 +310,26 @@ class Formules
return $this;
}
public function getFormulesRestriction(): ?FormulesRestriction
{
return $this->formulesRestriction;
}
public function setFormulesRestriction(?FormulesRestriction $formulesRestriction): static
{
// unset the owning side of the relation if necessary
if ($formulesRestriction === null && $this->formulesRestriction !== null) {
$this->formulesRestriction->setFormule(null);
}
// set the owning side of the relation if necessary
if ($formulesRestriction !== null && $formulesRestriction->getFormule() !== $this) {
$formulesRestriction->setFormule($this);
}
$this->formulesRestriction = $formulesRestriction;
return $this;
}
}

View File

@@ -0,0 +1,96 @@
<?php
namespace App\Entity;
use App\Repository\FormulesRestrictionRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: FormulesRestrictionRepository::class)]
class FormulesRestriction
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\OneToOne(inversedBy: 'formulesRestriction', cascade: ['persist', 'remove'])]
private ?Formules $formule = null;
#[ORM\Column]
private ?int $nbStructureMax = null;
#[ORM\Column(type: Types::ARRAY, nullable: true)]
private ?array $restrictionConfig = null;
#[ORM\Column(nullable: true)]
private ?int $nbAlimentaireMax = null;
#[ORM\Column(nullable: true)]
private ?int $nbBarhumsMax = null;
public function getId(): ?int
{
return $this->id;
}
public function getFormule(): ?Formules
{
return $this->formule;
}
public function setFormule(?Formules $formule): static
{
$this->formule = $formule;
return $this;
}
public function getNbStructureMax(): ?int
{
return $this->nbStructureMax;
}
public function setNbStructureMax(int $nbStructureMax): static
{
$this->nbStructureMax = $nbStructureMax;
return $this;
}
public function getRestrictionConfig(): ?array
{
return $this->restrictionConfig;
}
public function setRestrictionConfig(?array $restrictionConfig): static
{
$this->restrictionConfig = $restrictionConfig;
return $this;
}
public function getNbAlimentaireMax(): ?int
{
return $this->nbAlimentaireMax;
}
public function setNbAlimentaireMax(?int $nbAlimentaireMax): static
{
$this->nbAlimentaireMax = $nbAlimentaireMax;
return $this;
}
public function getNbBarhumsMax(): ?int
{
return $this->nbBarhumsMax;
}
public function setNbBarhumsMax(?int $nbBarhumsMax): static
{
$this->nbBarhumsMax = $nbBarhumsMax;
return $this;
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\FormulesRestriction;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<FormulesRestriction>
*/
class FormulesRestrictionRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, FormulesRestriction::class);
}
// /**
// * @return FormulesRestriction[] Returns an array of FormulesRestriction objects
// */
// public function findByExampleField($value): array
// {
// return $this->createQueryBuilder('f')
// ->andWhere('f.exampleField = :val')
// ->setParameter('val', $value)
// ->orderBy('f.id', 'ASC')
// ->setMaxResults(10)
// ->getQuery()
// ->getResult()
// ;
// }
// public function findOneBySomeField($value): ?FormulesRestriction
// {
// return $this->createQueryBuilder('f')
// ->andWhere('f.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@@ -1 +1,113 @@
dazdazdazdaz
<form action="{{ path('app_crm_formules_view', {id: formule.id}) }}" method="POST" class="w-full backdrop-blur-2xl bg-white/5 border border-white/10 rounded-[2.5rem] p-10 shadow-2xl relative overflow-hidden mt-6">
<div class="absolute -top-24 -right-24 w-48 h-48 bg-blue-500/10 rounded-full blur-3xl pointer-events-none"></div>
<div class="relative z-10 flex flex-col md:flex-row md:items-start gap-8">
{# STRUCTURES #}
<div class="flex-1">
<label for="nbStructureMax" class="block text-lg font-medium text-white/80 mb-3 ml-2">Nombre max structures</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-5 flex items-center pointer-events-none text-blue-400/60 group-focus-within:text-blue-400 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<input type="number" name="nbStructureMax" id="nbStructureMax" value="{{ formule.formulesRestriction.nbStructureMax|default(0) }}" min="0" class="block w-full pl-14 pr-6 py-4 bg-white/10 border border-white/10 rounded-2xl text-white text-xl font-semibold focus:ring-2 focus:ring-blue-500/50 outline-none transition-all">
</div>
<p class="mt-4 text-sm text-white/40 italic leading-relaxed">Limite totale de structures gonflables ou rigides.</p>
</div>
{# ALIMENTAIRE #}
<div class="flex-1">
<label for="nbAlimentaireMax" class="block text-lg font-medium text-white/80 mb-3 ml-2">Nombre max alimentaire</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-5 flex items-center pointer-events-none text-orange-400/60 group-focus-within:text-orange-400 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18 18.246 18.477 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
</div>
<input type="number" name="nbAlimentaireMax" id="nbAlimentaireMax" value="{{ formule.formulesRestriction.nbAlimentaireMax|default(0) }}" min="0" class="block w-full pl-14 pr-6 py-4 bg-white/10 border border-white/10 rounded-2xl text-white text-xl font-semibold focus:ring-2 focus:ring-orange-500/50 outline-none transition-all">
</div>
<p class="mt-4 text-sm text-white/40 italic leading-relaxed">Machines à barbe à papa, popcorn, buffets, etc.</p>
</div>
{# BARNUMS #}
<div class="flex-1">
<label for="nbBarhumsMax" class="block text-lg font-medium text-white/80 mb-3 ml-2">Nombre max barnums</label>
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-5 flex items-center pointer-events-none text-purple-400/60 group-focus-within:text-purple-400 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" 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" />
</svg>
</div>
<input type="number" name="nbBarhumsMax" id="nbBarhumsMax" value="{{ formule.formulesRestriction.nbBarhumsMax|default(0) }}" min="0" class="block w-full pl-14 pr-6 py-4 bg-white/10 border border-white/10 rounded-2xl text-white text-xl font-semibold focus:ring-2 focus:ring-purple-500/50 outline-none transition-all">
</div>
<p class="mt-4 text-sm text-white/40 italic leading-relaxed">Tentes de réception et protections extérieures.</p>
</div>
<div class="flex items-center self-center md:self-auto pt-2">
<button type="submit" class="px-8 py-4 bg-blue-600 hover:bg-blue-500 text-white font-bold rounded-2xl transition-all shadow-lg active:scale-95">
Enregistrer
</button>
</div>
</div>
</form>
<form action="{{ path('app_crm_formules_view', {id: formule.id}) }}" method="POST" class="w-full backdrop-blur-2xl bg-white/5 border border-white/10 rounded-[2.5rem] p-8 shadow-2xl relative overflow-hidden mt-6">
<div class="w-full form-repeater" data-component="repeater" is="repeat-line">
<div class="flex items-center justify-between mb-6 px-4">
<h4 class="text-sm font-black text-white uppercase tracking-widest">Détail des produits autorisés</h4>
</div>
<ol class="form-repeater__rows space-y-4" data-ref="rows">
{% for key,line in restriction %}
<li class="form-repeater__row group animate-in slide-in-from-right-5 duration-300">
<fieldset class="backdrop-blur-md bg-white/5 border border-white/10 rounded-[2.5rem] p-6 hover:border-blue-500/30 transition-all shadow-xl">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-5 items-end">
{# 1. PRODUIT #}
<div class="lg:col-span-7">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-2 block">Produit / Prestation</label>
<div class="relative flex items-center">
<input readonly type="text" name="rest[{{ key }}][product]" value="{{ line.product }}" required class="w-full bg-slate-950/50 border border-white/10 rounded-2xl text-white focus:border-purple-500 transition-all py-3 pl-5 pr-24 text-sm outline-none">
<div class="absolute right-2 flex items-center gap-1.5">
<button is="search-productformule" type="button" class="p-2 bg-blue-500/10 hover:bg-blue-500 text-blue-400 hover:text-white rounded-xl transition-all"><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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg></button>
<button is="search-optionsformule" type="button" class="p-2 bg-purple-500/10 hover:bg-purple-500 text-purple-400 hover:text-white rounded-xl transition-all"><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="M8 16l2.879-2.879m0 0a3 3 0 104.243-4.242 3 3 0 00-4.243 4.242zM21 21l-6-6" /></svg></button>
</div>
</div>
</div>
{# 2. TYPE #}
<div class="lg:col-span-4">
<label class="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1 mb-2 block">Catégorie de restriction</label>
<select ds required name="rest[{{ key }}][type]" class="w-full bg-slate-950/50 border border-white/10 rounded-2xl text-white focus:border-blue-500 transition-all py-3 px-4 text-sm outline-none appearance-none cursor-pointer">
<option {% if line.type =="structure" %}selected{% endif%} value="structure">Structure</option>
<option {% if line.type =="alimentaire" %}selected{% endif%} value="alimentaire">Alimentaire</option>
<option {% if line.type =="barhnums" %}selected{% endif%} value="barhnums">Barnums</option>
</select>
</div>
{# 3. SUPPRIMER #}
<div class="lg:col-span-1 flex justify-center">
<button type="button" data-ref="removeButton" class="p-3 bg-red-500/10 hover:bg-red-500 text-red-500 hover:text-white rounded-xl transition-all">
<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>
</button>
</div>
</div>
</fieldset>
</li>
{% endfor %}
</ol>
<div class="mt-6 px-4 space-y-4">
<button type="button" data-ref="addButton" class="w-full py-4 border-2 border-dashed border-white/10 hover:border-purple-500/50 bg-white/5 hover:bg-purple-500/10 rounded-3xl text-purple-400 text-[10px] font-black uppercase tracking-[0.4em] transition-all flex items-center justify-center space-x-3 group">
<span class="text-xl group-hover:rotate-90 transition-transform">+</span>
<span>Ajouter une prestation</span>
</button>
<button type="submit" class="group w-full py-5 bg-blue-600 hover:bg-blue-500 text-white font-black text-[11px] uppercase tracking-[0.3em] rounded-2xl shadow-xl transition-all flex items-center justify-center">
<span>Sauvegarder les modifications</span>
<svg class="w-4 h-4 ml-3 transform group-hover:translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6" /></svg>
</button>
</div>
</div>
</form>

View File

@@ -77,8 +77,7 @@
</div>
</form>
<form action="{{ path('app_crm_formules_view', {id: formule.id}) }}" method="POST"
class="w-full backdrop-blur-2xl bg-white/5 border border-white/10 rounded-[2.5rem] p-8 shadow-2xl relative overflow-hidden mt-6">
<form action="{{ path('app_crm_formules_view', {id: formule.id}) }}" method="POST" class="w-full backdrop-blur-2xl bg-white/5 border border-white/10 rounded-[2.5rem] p-8 shadow-2xl relative overflow-hidden mt-6">
<div class="w-full form-repeater" data-component="options" is="repeat-line">
<div class="flex items-center justify-between mb-6 px-4">
<h4 class="text-sm font-black text-white uppercase tracking-widest">Détail des options inclus</h4>