feat(contrats/add.twig): Ajoute le détail des options au formulaire

 feat(SearchProduct.js): Implémente la recherche et la sélection d'options

🐛 fix(DevisController.php): Corrige la création/édition de devis et supprime un devis

 feat(admin.js): Enregistre les custom elements SearchOptions

 feat(templates/dashboard/devis): Permet l'édition et la suppression d'un devis
```
This commit is contained in:
Serreau Jovann
2026-01-22 11:05:29 +01:00
parent 7dc2978094
commit 4f253bc03f
9 changed files with 473 additions and 47 deletions

View File

@@ -5,7 +5,7 @@ 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 } from "./libs/SearchProduct.js";
import { SearchProduct,SearchOptions } from "./libs/SearchProduct.js";
// --- INITIALISATION SENTRY ---
Sentry.init({
dsn: "https://803814be6540031b1c37bf92ba9c0f79@sentry.esy-web.dev/24",
@@ -40,6 +40,9 @@ function initAdminLayout() {
if (!customElements.get('search-product')) {
customElements.define('search-product', SearchProduct, { extends: 'button' });
}
if (!customElements.get('search-options')) {
customElements.define('search-options', SearchOptions, { extends: 'button' });
}
// Sidebar & UI
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');

View File

@@ -1,3 +1,149 @@
export class SearchOptions 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 = '<div class="text-white p-8 text-center animate-pulse tracking-widest text-[10px] uppercase font-black">Chargement des options...</div>';
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 = '<div class="text-red-400 p-8 text-center">Erreur 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-slate-950/90 backdrop-blur-md hidden animate-in fade-in duration-300';
div.innerHTML = `
<div class="bg-slate-900 border border-white/10 w-full max-w-2xl max-h-[85vh] rounded-[3rem] 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"></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" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="relative">
<input type="text" id="modal-search-input-options" placeholder="RECHERCHER UNE OPTION..."
class="w-full bg-slate-950/50 border border-white/10 rounded-2xl py-4 pl-12 pr-5 text-white text-xs font-black tracking-widest focus:ring-blue-500/20 focus:border-blue-500 transition-all uppercase placeholder:text-slate-600">
<svg class="w-5 h-5 absolute left-4 top-3.5 text-slate-500" 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">
<p class="text-[9px] text-slate-500 font-black uppercase tracking-widest line-clamp-1">Appuyez sur Échap pour fermer</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-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
? `<img src="${option.image}" class="w-14 h-14 rounded-xl object-cover shadow-lg group-hover:scale-110 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-400 text-[10px] font-mono">PRIX HT: ${option.price}€</span>
</div>
</div>
<div class="w-10 h-10 rounded-full bg-blue-500/0 group-hover:bg-blue-500/20 flex items-center justify-center text-blue-500 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="3" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/></svg>
</div>
`;
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*="[name]"]');
const priceInput = row.querySelector('input[name*="[priceHt]"]');
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 SearchProduct extends HTMLButtonElement {
constructor() {
super();

View File

@@ -45,9 +45,9 @@ class ContratsController extends AbstractController
]
];
if($devis instanceof Devis){
$line = $devis->getDevisLines()[0];
$c->setDateAt($line->getStartAt());
$c->setEndAt($line->getEndAt());
$c->setDateAt($devis->getStartAt());
$c->setEndAt($devis->getEndAt());
$c->setCustomer($devis->getCustomer());
$c->setDevis($devis);
$c->setAddressEvent($devis->getAddressShip()->getAddress());
@@ -56,6 +56,7 @@ class ContratsController extends AbstractController
$c->setZipCodeEvent($devis->getAddressShip()->getZipcode());
$c->setTownEvent($devis->getAddressShip()->getCity());
$lines = [];
$options = [];
foreach ($devis->getDevisLines() as $line){
$lines[] =[
'id' => $line->getId(),
@@ -65,6 +66,13 @@ class ContratsController extends AbstractController
'caution' => $line->getProduct()->getCaution(),
];
}
foreach ($devis->getDevisOptions() as $line){
$options[] =[
'id' => $line->getId(),
'name' => $line->getOption()->getName(),
'priceHt' => $line->getPriceHt(),
];
}
}
$form = $this->createForm(ContratsType::class,$c);
@@ -76,6 +84,7 @@ class ContratsController extends AbstractController
'devis' => $devis,
'form'=> $form->createView(),
'lines' => $lines,
'options' => $options,
]);
}

View File

@@ -12,6 +12,8 @@ use App\Logger\AppLogger;
use App\Repository\AccountRepository;
use App\Repository\CustomerAddressRepository;
use App\Repository\CustomerRepository;
use App\Repository\DevisLineRepository;
use App\Repository\DevisOptionsRepository;
use App\Repository\DevisRepository;
use App\Repository\OptionsRepository;
use App\Repository\ProductRepository;
@@ -38,14 +40,15 @@ class DevisController extends AbstractController
#[Route(path: '/crm/devis', name: 'app_crm_devis', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function devis(
EventDispatcherInterface $eventDispatcher,
EntityManagerInterface $entityManager,
DevisRepository $devisRepository,
AppLogger $appLogger,
PaginatorInterface $paginator,
Request $request,
KernelInterface $kernel,
EntityManagerInterface $entityManager,
DevisRepository $devisRepository,
AppLogger $appLogger,
PaginatorInterface $paginator,
Request $request,
KernelInterface $kernel,
): Response {
): Response
{
// Gestion du renvoi de la signature
if ($request->query->has('resend')) {
@@ -80,10 +83,45 @@ class DevisController extends AbstractController
'quotes' => $pagination,
]);
}
#[Route(path: '/crm/devis/add', name: 'app_crm_devis_add', options: ['sitemap' => false], methods: ['GET','POST'])]
public function devisAdd(Client $client,OptionsRepository $optionsRepository,EventDispatcherInterface $eventDispatcher,KernelInterface $kernel,CustomerAddressRepository $customerAddress,ProductRepository $productRepository,EntityManagerInterface $entityManager,CustomerRepository $customerRepository,DevisRepository $devisRepository, AppLogger $appLogger,Request $request): Response
#[Route(path: '/crm/devis/delete/{id}', name: 'app_crm_devis_delete', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function devisDelete(string $id, AppLogger $appLogger, Client $client, Request $request, DevisRepository $devisRepository, EntityManagerInterface $entityManager): Response
{
$devisNumber ="DEVIS-".sprintf('%05d',$devisRepository->count()+1);
$customer = $devisRepository->find($id);
if (!$customer) {
$this->addFlash('error', 'Le devis demandé n\'existe pas.');
return $this->redirectToRoute('app_crm_devis');
}
$token = $request->query->get('_token');
if ($this->isCsrfTokenValid('delete' . $customer->getId(), $token)) {
$appLogger->record('DELETE', sprintf('Suppression définitive du devis : %s', $customer->getNum()));
if ($customer->getSignatureId() != "") {
$client->cancelSign($customer->getSignatureId());
}
foreach ($customer->getDevisLines() as $devisLine) {
$entityManager->remove($devisLine);
}
foreach ($customer->getDevisOptions() as $devisOption) {
$entityManager->remove($devisOption);
}
$entityManager->remove($customer);
$entityManager->flush();
$this->addFlash('success', sprintf('Le devis %s a été supprimé définitivement.', $customer->getNum()));
} else {
$this->addFlash('error', 'Jeton de sécurité invalide. La suppression a été annulée.');
}
return $this->redirectToRoute('app_crm_devis');
}
#[Route(path: '/crm/devis/add', name: 'app_crm_devis_add', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function devisAdd(Client $client, OptionsRepository $optionsRepository, EventDispatcherInterface $eventDispatcher, KernelInterface $kernel, CustomerAddressRepository $customerAddress, ProductRepository $productRepository, EntityManagerInterface $entityManager, CustomerRepository $customerRepository, DevisRepository $devisRepository, AppLogger $appLogger, Request $request): Response
{
$devisNumber = "DEVIS-" . sprintf('%05d', $devisRepository->count() + 1);
$appLogger->record('VIEW', 'Consultation de la création d\'un devis');
$devis = new Devis();
@@ -92,24 +130,24 @@ class DevisController extends AbstractController
$devis->setCreateA(new \DateTimeImmutable());
$devis->setUpdateAt(new \DateTimeImmutable());
$form = $this->createForm(NewDevisType::class,$devis);
if($request->isMethod('POST')){
$form = $this->createForm(NewDevisType::class, $devis);
if ($request->isMethod('POST')) {
$devis->setStartAt( new DateTimeImmutable($_POST['new_devis']['startAt']));
$devis->setEndAt( new DateTimeImmutable($_POST['new_devis']['endAt']));
$devis->setStartAt(new DateTimeImmutable($_POST['new_devis']['startAt']));
$devis->setEndAt(new DateTimeImmutable($_POST['new_devis']['endAt']));
$devis->setBillAddress($customerAddress->find($_POST['devis']['bill_address']));
$devis->setAddressShip($customerAddress->find($_POST['devis']['ship_address']));
$devis->setCustomer($customerRepository->find($_POST['new_devis']['customer']));
foreach ($_POST['lines'] as $cd=>$line) {
$rLine = new DevisLine();
$rLine->setDevi($devis);
$rLine->setPos($cd);
$rLine->setProduct($productRepository->find($line['product_id']));
$rLine->setDay($line['days']);
$rLine->setPriceHt(floatval($line['price_ht']));
$rLine->setPriceHtSup(floatval($line['price_sup_ht']));
$entityManager->persist($rLine);
}
foreach ($_POST['lines'] as $cd => $line) {
$rLine = new DevisLine();
$rLine->setDevi($devis);
$rLine->setPos($cd);
$rLine->setProduct($productRepository->find($line['product_id']));
$rLine->setDay($line['days']);
$rLine->setPriceHt(floatval($line['price_ht']));
$rLine->setPriceHtSup(floatval($line['price_sup_ht']));
$entityManager->persist($rLine);
}
foreach ($_POST['options'] as $line) {
$rLineOptions = new DevisOptions();
$rLineOptions->setDevis($devis);
@@ -117,7 +155,7 @@ class DevisController extends AbstractController
$rLineOptions->setPriceHt(floatval($line['price_ht']));
$entityManager->persist($rLineOptions);
}
$entityManager->persist($devis);
$entityManager->persist($devis);
$entityManager->flush();
$docusealService = new DevisPdfService($kernel, $devis, true);
@@ -127,7 +165,7 @@ class DevisController extends AbstractController
$tmpPathDocuseal = sys_get_temp_dir() . '/docuseal_' . uniqid() . '.pdf';
file_put_contents($tmpPathDocuseal, $contentDocuseal);
$fileDocuseal = new UploadedFile($tmpPathDocuseal,'dc_'.$devis->getNum() . '.pdf','application/pdf',0,true);
$fileDocuseal = new UploadedFile($tmpPathDocuseal, 'dc_' . $devis->getNum() . '.pdf', 'application/pdf', 0, true);
$devis->setDevisDocuSealFile($fileDocuseal);
@@ -137,7 +175,7 @@ class DevisController extends AbstractController
$tmpPathDevis = sys_get_temp_dir() . '/devis_' . uniqid() . '.pdf';
file_put_contents($tmpPathDevis, $contentDevis);
$fileDevis = new UploadedFile($tmpPathDevis,'devis_'.$devis->getNum() . '.pdf','application/pdf',0,true);
$fileDevis = new UploadedFile($tmpPathDevis, 'devis_' . $devis->getNum() . '.pdf', 'application/pdf', 0, true);
$devis->setDevisFile($fileDevis);
@@ -148,11 +186,139 @@ class DevisController extends AbstractController
$event = new DevisSend($devis);
$eventDispatcher->dispatch($event);
$this->addFlash('success', sprintf('Le devis %s a été crée.', $devis->getNum()));
return $this->redirectToRoute('app_crm_devis');
}
return $this->render('dashboard/devis/add.twig',[
return $this->render('dashboard/devis/add.twig', [
'form' => $form->createView(),
'lines' => [
[
'product_id' => '',
'days'=>'',
'price_ht' => '',
'price_sup_ht' =>''
],
],
'options' => [
[
'product_id' => '',
'price_ht' => '',
]
]
]);
}
#[Route(path: '/crm/devis/{id}', name: 'app_crm_devis_edit', options: ['sitemap' => false], methods: ['GET', 'POST'])]
public function devisEdit(?Devis $devis,Client $client,DevisLineRepository $devisLineRepository,DevisOptionsRepository $devisOptionsRepository, OptionsRepository $optionsRepository, EventDispatcherInterface $eventDispatcher, KernelInterface $kernel, CustomerAddressRepository $customerAddress, ProductRepository $productRepository, EntityManagerInterface $entityManager, CustomerRepository $customerRepository, DevisRepository $devisRepository, AppLogger $appLogger, Request $request): Response
{
$appLogger->record('VIEW', 'Consultation pour modifier le devis '.$devis->getNum());
$form = $this->createForm(NewDevisType::class, $devis);
if ($request->isMethod('POST')) {
$devis->setStartAt(new DateTimeImmutable($_POST['new_devis']['startAt']));
$devis->setEndAt(new DateTimeImmutable($_POST['new_devis']['endAt']));
$devis->setBillAddress($customerAddress->find($_POST['devis']['bill_address']));
$devis->setAddressShip($customerAddress->find($_POST['devis']['ship_address']));
$devis->setCustomer($customerRepository->find($_POST['new_devis']['customer']));
foreach ($_POST['lines'] as $cd => $line) {
$rLine = $devisLineRepository->find($line['id']);
$rLine->setDevi($devis);
$rLine->setPos($cd);
$rLine->setProduct($productRepository->find($line['product_id']));
$rLine->setDay($line['days']);
$rLine->setPriceHt(floatval($line['price_ht']));
$rLine->setPriceHtSup(floatval($line['price_sup_ht']));
$entityManager->persist($rLine);
}
foreach ($_POST['options'] as $line) {
$rLineOptions = $devisOptionsRepository->find($line['id']);
$rLineOptions->setOption($optionsRepository->find($line['product_id']));
$rLineOptions->setPriceHt(floatval($line['price_ht']));
$entityManager->persist($rLineOptions);
}
$entityManager->persist($devis);
$entityManager->flush();
$docusealService = new DevisPdfService($kernel, $devis, true);
$contentDocuseal = $docusealService->generate();
$tmpPathDocuseal = sys_get_temp_dir() . '/docuseal_' . uniqid() . '.pdf';
file_put_contents($tmpPathDocuseal, $contentDocuseal);
$fileDocuseal = new UploadedFile($tmpPathDocuseal, 'dc_' . $devis->getNum() . '.pdf', 'application/pdf', 0, true);
$devis->setDevisDocuSealFile($fileDocuseal);
$devisService = new DevisPdfService($kernel, $devis, false);
$contentDevis = $devisService->generate();
$tmpPathDevis = sys_get_temp_dir() . '/devis_' . uniqid() . '.pdf';
file_put_contents($tmpPathDevis, $contentDevis);
$fileDevis = new UploadedFile($tmpPathDevis, 'devis_' . $devis->getNum() . '.pdf', 'application/pdf', 0, true);
$devis->setDevisFile($fileDevis);
$devis->setUpdateAt(new \DateTimeImmutable());
$entityManager->flush();
$client->createSubmissionDevis($devis);
$event = new DevisSend($devis);
$eventDispatcher->dispatch($event);
$this->addFlash('success', sprintf('Le devis %s a été modifiée.', $devis->getNum()));
return $this->redirectToRoute('app_crm_devis');
}
$lines =[
[
'id' => '',
'product_id' => '',
'days'=>'',
'price_ht' => '',
'price_sup_ht' =>''
]
];
$options = [
[
'id' => '',
'product_id' => '',
'price_ht' => '',
]
];
$shipAddress = [];
$billAddress =[];
foreach ($devis->getDevisLines() as $key => $line) {
$lines[$key] = [
'id' => $line->getId(),
'product_id' => $line->getProduct()->getId(),
'days' => $line->getDay(),
'price_ht' => $line->getPriceHt(),
'price_sup_ht' => $line->getPriceHtSup()
];
}
foreach ($devis->getDevisOptions() as $key => $line) {
$options[$key] = [
'id' => $line->getId(),
'product_id' => $line->getOption()->getId(),
'price_ht' => $line->getPriceHt(),
];
}
foreach ($devis->getCustomer()->getCustomerAddresses() as $customerAddress) {
$shipAddress[$customerAddress->getId()] = $customerAddress->getAddress()." ".$customerAddress->getZipcode()." ".$customerAddress->getCity();
$billAddress[$customerAddress->getId()] = $customerAddress->getAddress()." ".$customerAddress->getZipcode()." ".$customerAddress->getCity();
}
return $this->render('dashboard/devis/add.twig', [
'form' => $form->createView(),
'devis' => $devis,
'lines' => $lines,
'options' => $options,
'products' => $productRepository->findAll(),
'optionsList' => $optionsRepository->findAll(),
'shipAddress' => $shipAddress,
'billAddress' => $billAddress,
]);
}
}

View File

@@ -157,4 +157,10 @@ class Client
{
return $this->docuseal->getSubmission($submission_id);
}
public function cancelSign(?string $getSignatureId)
{
$result = $this->docuseal->getSubmitter($getSignatureId);
$this->docuseal->archiveSubmission($result['submission_id']);
}
}

View File

@@ -208,6 +208,61 @@
</button>
</div>
</div>
<div class="form-repeater mt-12" data-component="repeater2" is="repeat-line">
<div class="flex items-center justify-between mb-8 px-8">
<h3 class="text-sm font-black text-purple-500 uppercase tracking-widest flex items-center">
<span class="w-6 h-6 bg-purple-600/20 rounded-lg flex items-center justify-center mr-3 text-[10px]">05</span>
Détail des options
</h3>
</div>
<ol class="form-repeater__rows space-y-6" data-ref="rows">
{% for key, line in options %}
<li class="form-repeater__row group animate-in slide-in-from-right-5 duration-300">
<input type="hidden" value="{{ line.id }}" name="options[{{ key }}][id]">
<fieldset class="backdrop-blur-md bg-white/5 border border-white/10 rounded-[2.5rem] p-8 hover:border-purple-500/30 transition-all shadow-xl">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-6 items-end">
{# 1. PRODUIT AVEC BOUTON RECHERCHE #}
<div class="lg:col-span-8">
<label class="text-[9px] font-black text-slate-500 uppercase tracking-widest ml-1 mb-2 block">Produit / Prestation</label>
<div class="relative flex items-center">
<input type="text" name="options[{{ key }}][name]" value="{{ line.name }}" required class="w-full bg-slate-950/50 border-white/5 rounded-2xl text-white focus:ring-purple-500/20 focus:border-purple-500 transition-all py-3 pl-5 pr-12 text-sm">
{# BOUTON RECHERCHER #}
<button is="search-options" type="button" class="absolute right-2 p-2 bg-purple-500/10 hover:bg-purple-500 text-purple-400 hover:text-white rounded-xl transition-all duration-300 group/search" title="Rechercher un produit">
Rechercher une options
</button>
</div>
</div>
{# 2. PRIX 1J #}
<div class="lg:col-span-3">
<label class="text-[9px] font-black text-slate-500 uppercase tracking-widest ml-1 mb-2 block">Prix 1J HT</label>
<input type="text" name="options[{{ key }}][priceHt]" value="{{ line.priceHt }}" required class="w-full bg-slate-950/50 border-white/5 rounded-2xl text-white focus:ring-purple-500/20 focus:border-purple-500 transition-all py-3 px-5 text-sm font-mono">
</div>
{# 5. SUPPRIMER #}
<div class="lg:col-span-1 flex justify-end">
<button type="button" data-ref="removeButton" class="w-12 h-12 flex items-center justify-center bg-red-500/10 hover:bg-red-500 text-red-500 hover:text-white rounded-xl transition-all shadow-lg group/del">
<svg class="w-5 h-5 group-hover/del:scale-110 transition-transform" 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-8 px-4">
<button type="button" data-ref="addButton" class="w-full py-5 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-4 group">
<span class="text-2xl group-hover:rotate-180 transition-transform duration-500">+</span>
<span>Ajouter une prestation</span>
</button>
</div>
</div>
{# --- BARRE D'ACTIONS FINALE --- #}
<div class="mt-16 flex items-center justify-end backdrop-blur-xl bg-slate-900/40 p-6 rounded-[3rem] border border-white/5 shadow-xl">

View File

@@ -64,11 +64,23 @@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<div class="space-y-3">
<label for="billAddress" class="{{ label_class }}">Adresse de facturation</label>
<select id="billAddress" name="devis[bill_address]" class="{{ input_class }}"></select>
<select id="billAddress" name="devis[bill_address]" class="{{ input_class }}">
{% if billAddress is defined %}
{% for key,shipAdd in billAddress %}
<option {% if key == devis.billAddress.id%}selected{% endif%} value="{{ key }}">{{ shipAdd }}</option>
{% endfor %}
{% endif %}
</select>
</div>
<div class="space-y-3">
<label for="shipAddress" class="{{ label_class }}">Adresse de livraison</label>
<select id="shipAddress" name="devis[ship_address]" class="{{ input_class }}"></select>
<select id="shipAddress" name="devis[ship_address]" class="{{ input_class }}">
{% if shipAddress is defined %}
{% for key,shipAdd in shipAddress %}
<option {% if key == devis.addressShip.id%}selected{% endif%} value="{{ key }}">{{ shipAdd }}</option>
{% endfor %}
{% endif %}
</select>
</div>
</div>
{# SECTION ADRESSES #}
@@ -92,35 +104,43 @@
</div>
<ol class="form-repeater__rows space-y-4" data-ref="rows">
<li class="form-repeater__row group animate-in slide-in-from-right-5 duration-300">
{% for key,line in lines %}
<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-8 hover:border-blue-500/30 transition-all shadow-xl">
{% if line.id is defined %}
<input type="hidden" name="lines[{{ key }}][id]" value="{{ line.id }}">
{% endif %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-5 items-end">
{# 1. PRODUIT #}
<div class="lg:col-span-5">
<label class="{{ label_class }}">Produit / Prestation</label>
<select data-load="product" name="lines[0][product_id]" class="{{ input_class }}" required>
<select data-load="product" name="lines[{{ key }}][product_id]" class="{{ input_class }}" required>
{% if line.id is not defined%}
<option value="">Sélectionner...</option>
{% else %}
{% for product in products %}
<option {% if product.id == line.product_id %}selected{% endif %} value="{{ product.id }}">{{ product.name }} - {{ product.ref }}</option>
{% endfor %}
{% endif %}
</select>
</div>
{# 2. DURÉE #}
<div class="lg:col-span-2">
<label class="{{ label_class }}">Jours</label>
<input type="number" name="lines[0][days]" min="1" placeholder="1" class="{{ input_class }} [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" required />
<input type="number" name="lines[{{ key }}][days]" min="1" placeholder="1" class="{{ input_class }} [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" required value="{{ line.days }}" />
</div>
{# 3. PRIX HT J1 #}
<div class="lg:col-span-2">
<label class="{{ label_class }}">HT J1 (€)</label>
<input type="number" step="0.01" name="lines[0][price_ht]" placeholder="0.00" class="{{ input_class }} text-right font-mono" required />
<input type="number" step="0.01" name="lines[{{ key }}][price_ht]" placeholder="0.00" class="{{ input_class }} text-right font-mono" required value="{{ line.price_ht }}" />
</div>
{# 4. PRIX HT SUP #}
<div class="lg:col-span-2">
<label class="{{ label_class }}">HT Sup</label>
<input type="number" step="0.01" name="lines[0][price_sup_ht]" placeholder="0.00" class="{{ input_class }} text-right font-mono" required />
<input type="number" step="0.01" name="lines[{{ key }}][price_sup_ht]" placeholder="0.00" class="{{ input_class }} text-right font-mono" required value="{{ line.price_sup_ht }}" />
</div>
{# 6. SUPPRIMER #}
@@ -134,6 +154,7 @@
</div>
</fieldset>
</li>
{% endfor %}
</ol>
<div class="mt-6 px-4">
@@ -149,22 +170,31 @@
<h4 class="text-sm font-black text-white uppercase tracking-widest">Détail des options</h4>
</div>
<ol class="form-repeater__rows space-y-4" data-ref="rows">
{% for key,option in options %}
<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-8 hover:border-blue-500/30 transition-all shadow-xl">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-12 gap-5 items-end">
{% if option.id is defined %}
<input type="hidden" name="options[{{ key }}][id]" value="{{ option.id }}">
{% endif %}
{# 1. PRODUIT #}
<div class="lg:col-span-9">
<label class="{{ label_class }}">Options</label>
<select data-load="options" name="options[0][product_id]" class="{{ input_class }}" required>
<option value="">Sélectionner...</option>
<select data-load="options" name="options[{{ key }}][product_id]" class="{{ input_class }}" required >
{% if option.id is not defined%}
<option value="">Sélectionner...</option>
{% else %}
{% for optionItem in optionsList %}
<option {% if optionItem.id == option.product_id %}selected{% endif %} value="{{ optionItem.id }}">{{ optionItem.name }}</option>
{% endfor %}
{% endif %}
</select>
</div>
{# 3. PRIX HT J1 #}
<div class="lg:col-span-2">
<label class="{{ label_class }}">Prix Ht (€)</label>
<input type="number" step="0.01" name="options[0][price_ht]" placeholder="0.00" class="{{ input_class }} text-right font-mono" required />
<input type="number" step="0.01" value="{{ option.price_ht }}" name="options[{{ key }}][price_ht]" placeholder="0.00" class="{{ input_class }} text-right font-mono" required />
</div>
{# 6. SUPPRIMER #}
<div class="lg:col-span-1 flex justify-center pb-1">
@@ -177,6 +207,7 @@
</div>
</fieldset>
</li>
{% endfor %}
</ol>
<div class="mt-6 px-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">

View File

@@ -0,0 +1,10 @@
{% extends 'dashboard/base.twig' %}
{% block actions %}
{% endblock %}
{% block body %}
{% endblock %}

View File

@@ -114,7 +114,7 @@
{# Modifier : Interdit si signé #}
{% if quote.state != "signed" and quote.state != "signée" %}
<a href="{{ path('app_crm_devis_add', {id: quote.id}) }}" class="p-2 bg-blue-600/10 hover:bg-blue-600 text-blue-500 hover:text-white rounded-xl transition-all border border-blue-500/20 shadow-lg shadow-blue-600/5">
<a href="{{ path('app_crm_devis_edit', {id: quote.id}) }}" class="p-2 bg-blue-600/10 hover:bg-blue-600 text-blue-500 hover:text-white rounded-xl transition-all border border-blue-500/20 shadow-lg shadow-blue-600/5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
</a>
{% endif %}
@@ -146,7 +146,7 @@
{# Delete : Interdit si signé #}
{% if quote.state != "signed" and quote.state != "signée" %}
<a href="{{ path('app_crm_devis_add', {id: quote.id}) }}?_token={{ csrf_token('delete' ~ quote.id) }}"
<a data-turbo="false" href="{{ path('app_crm_devis_delete', {id: quote.id}) }}?_token={{ csrf_token('delete' ~ quote.id) }}"
data-turbo-method="post"
data-turbo-confirm="Confirmer la suppression du devis {{ quote.num }} ?"
class="p-2 bg-rose-500/10 hover:bg-rose-600 text-rose-500 hover:text-white rounded-xl transition-all border border-rose-500/20">