```
✨ 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:
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
10
templates/dashboard/devis/edit.twig
Normal file
10
templates/dashboard/devis/edit.twig
Normal file
@@ -0,0 +1,10 @@
|
||||
{% extends 'dashboard/base.twig' %}
|
||||
|
||||
{% block actions %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user