feat(Product.php): Ajoute relation DevisLine et méthodes associées en français.
 feat(DevisLine.php): Ajoute propriétés et relations pour ligne de devis en français.
 feat(DevisController.php): Intègre génération PDF et ajout de lignes de devis en français.
🎨 style: Améliore la mise en page et l'esthétique de l'interface admin en français.
 feat: Initialise TomSelect et gère les adresses client dans DevisManager en français.
🐛 fix: Corrige l'initialisation de TomSelect et la gestion des lignes répétées en français.
 test: Ajoute génération du bon pour accord et signature en français.
```
This commit is contained in:
Serreau Jovann
2026-01-19 17:56:57 +01:00
parent 44d619d659
commit 5d6c0fdde7
13 changed files with 716 additions and 401 deletions

View File

@@ -1,11 +1,11 @@
import './admin.scss'
import './admin.scss';
import * as Sentry from "@sentry/browser";
import * as Turbo from "@hotwired/turbo";
import TomSelect from "tom-select";
import {RepeatLine} from "./libs/RepeatLine.js";
import {DevisManager} from "./libs/DevisManager.js";
// --- INITIALISATION SENTRY (En premier !) ---
import { RepeatLine } from "./libs/RepeatLine.js";
import { DevisManager } from "./libs/DevisManager.js";
import { initTomSelect } from "./libs/initTomSelect.js";
// --- INITIALISATION SENTRY ---
Sentry.init({
dsn: "https://803814be6540031b1c37bf92ba9c0f79@sentry.esy-web.dev/24",
tunnel: "/sentry-tunnel",
@@ -15,171 +15,73 @@ Sentry.init({
Sentry.replayIntegration()
],
tracesSampleRate: 1.0,
tracePropagationTargets: ["localhost", "esy-web.dev"], // Remplace par ton domaine réel
tracePropagationTargets: ["localhost", "esy-web.dev"],
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1.0
});
// Cache global pour éviter de fetch les produits à chaque nouvelle ligne
let productCache = null;
/**
* Initialise les composants de l'interface d'administration.
*/
function initAdminLayout() {
if (!customElements.get('copy-text')) {
customElements.define('repeat-line', RepeatLine, {extends: 'div'})
customElements.define('devis-manager', DevisManager, {extends: 'div'})
// Enregistrement des Custom Elements
if (!customElements.get('repeat-line')) {
customElements.define('repeat-line', RepeatLine, { extends: 'div' });
}
if (!customElements.get('devis-manager')) {
customElements.define('devis-manager', DevisManager, { extends: 'div' });
}
document.querySelectorAll('select').forEach((el) => {
if (!el.tomselect) { // Éviter la double initialisation avec Turbo
if(el.getAttribute('data-load') == "product") {
fetch("/crm/product/json")
.then(r=>r.json())
.then(products=>{
})
} else {
new TomSelect(el, {
controlInput: null,
allowEmptyOption: true,
highlight: true,
plugins: ['dropdown_input'], // Permet d'avoir la recherche dans le dropdown
render: {
option: function (data, escape) {
return `<div class="py-2 px-3">
<div class="text-[13px] font-bold text-white">${escape(data.text)}</div>
</div>`;
},
item: function (data, escape) {
return `<div class="text-blue-400 font-bold">${escape(data.text)}</div>`;
}
}
});
}
}
});
const imageInput = document.getElementById('product_image_input');
const previewImage = document.getElementById('product-image-preview');
const placeholderIcon = document.getElementById('product-image-placeholder');
if (imageInput) {
imageInput.addEventListener('change', (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
previewImage.src = e.target.result;
previewImage.classList.remove('hidden');
if (placeholderIcon) {
placeholderIcon.classList.add('hidden');
}
};
reader.readAsDataURL(file);
}
});
}
// Sidebar & UI
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
const toggleBtn = document.getElementById('sidebar-toggle');
const settingsToggle = document.getElementById('settings-toggle');
const settingsSubmenu = document.getElementById('settings-submenu');
// --- 1. GESTION DE LA SIDEBAR (MOBILE) ---
if (toggleBtn && sidebar && overlay) {
toggleBtn.onclick = () => {
sidebar.classList.toggle('-translate-x-full');
overlay.classList.toggle('hidden');
};
overlay.onclick = () => {
sidebar.classList.add('-translate-x-full');
overlay.classList.add('hidden');
};
}
// --- 2. GESTION DU DROPDOWN (PARAMÈTRES) ---
// Dropdown Paramètres
const settingsToggle = document.getElementById('settings-toggle');
const settingsSubmenu = document.getElementById('settings-submenu');
if (settingsToggle && settingsSubmenu) {
const settingsChevron = settingsToggle.querySelector('svg:last-child');
const toggleDropdown = (show, animate = true) => {
if (!animate) settingsSubmenu.style.transition = 'none';
if (show) {
settingsSubmenu.classList.remove('hidden');
settingsSubmenu.style.maxHeight = settingsSubmenu.scrollHeight + "px";
settingsChevron?.classList.add('rotate-180');
localStorage.setItem('admin_settings_open', 'true');
} else {
settingsSubmenu.style.maxHeight = "0px";
settingsChevron?.classList.remove('rotate-180');
localStorage.setItem('admin_settings_open', 'false');
if (animate) {
setTimeout(() => {
if (settingsSubmenu.style.maxHeight === "0px") {
settingsSubmenu.classList.add('hidden');
}
}, 300);
} else {
settingsSubmenu.classList.add('hidden');
}
}
if (!animate) {
settingsSubmenu.offsetHeight; // force redraw
settingsSubmenu.style.transition = '';
}
};
settingsToggle.onclick = (e) => {
e.preventDefault();
const isClosed = settingsSubmenu.style.maxHeight === "0px" || settingsSubmenu.classList.contains('hidden');
toggleDropdown(isClosed);
settingsSubmenu.classList.toggle('hidden');
const isOpen = !settingsSubmenu.classList.contains('hidden');
localStorage.setItem('admin_settings_open', isOpen);
};
// PERSISTANCE
const isSettingsRoute = window.location.pathname.includes('/crm/administrateur') ||
window.location.pathname.includes('/crm/logs');
const wasOpen = localStorage.getItem('admin_settings_open') === 'true';
if (isSettingsRoute || wasOpen) {
toggleDropdown(true, false);
}
// HIGHLIGHT
settingsSubmenu.querySelectorAll('a').forEach(link => {
if (window.location.pathname === link.getAttribute('href')) {
link.classList.add('text-blue-600', 'dark:text-blue-400', 'font-semibold');
link.classList.remove('text-slate-500');
}
});
}
// --- 3. GESTION DES MESSAGES FLASH ---
// Flash messages
document.querySelectorAll('.flash-message').forEach((flash) => {
setTimeout(() => {
flash.classList.add('opacity-0', 'translate-x-10');
setTimeout(() => flash.remove(), 500);
}, 8000);
}, 5000);
});
}
// --- CORRECTIF DATA-TURBO-CONFIRM ---
// Turbo Hooks
document.addEventListener('turbo:load', () => {
initAdminLayout();
initTomSelect(); // Init au chargement de la page
});
document.addEventListener("turbo:click", (event) => {
const message = event.target.closest("[data-turbo-confirm]")?.getAttribute("data-turbo-confirm");
if (message && !confirm(message)) {
event.preventDefault();
}
});
document.addEventListener('turbo:load', initAdminLayout);
document.addEventListener('turbo:before-cache', () => {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
if (sidebar) sidebar.classList.add('-translate-x-full');
if (overlay) overlay.classList.add('hidden');
});

View File

@@ -353,3 +353,11 @@ details[open] .arrow-icon { transform: rotate(180deg); }
.ts-dropdown .optgroup {
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.form-repeater__row{
z-index: 90;
position: relative;
}
.ts-dropdown.single{
position: relative;
}

View File

@@ -1,46 +1,69 @@
// SUPPRIME l'import de postcss ici
export class DevisManager extends HTMLDivElement {
connectedCallback() {
this.customerSelect = this.querySelector('select');
// On remonte au parent pour trouver les selects d'adresse hors du bloc customer
this.billAddress = this.parentElement.querySelector('#billAddress');
this.shipAddress = this.parentElement.querySelector('#shipAddress');
this.billAddress = this.parentElement.parentElement.querySelector('#billAddress');
this.shipAddress = this.parentElement.parentElement.querySelector('#shipAddress');
// Initialisation du message par défaut au chargement
this.setAddressPlaceholder("Sélectionnez un client...");
if (this.customerSelect) {
this.customerSelect.addEventListener('change', (e) => this.updateCustomerInfo(e.target.value));
}
}
/**
* Met à jour le texte d'aide dans les champs TomSelect
*/
setAddressPlaceholder(text) {
[this.billAddress, this.shipAddress].forEach(el => {
if (el && el.tomselect) {
el.tomselect.settings.placeholder = text;
el.tomselect.inputState(); // Force la mise à jour visuelle
}
});
}
async updateCustomerInfo(customerId) {
if (!customerId) return;
// Purge et message d'attente
[this.billAddress, this.shipAddress].forEach(el => {
if (el.tomselect) {
el.tomselect.clear();
el.tomselect.clearOptions();
}
});
if (!customerId) {
this.setAddressPlaceholder("Sélectionnez un client...");
return;
}
// Pendant le chargement
this.setAddressPlaceholder("Chargement des adresses...");
try {
const resp = await fetch("/crm/customer/address/" + customerId);
const data = await resp.json();
// Vider les anciens selects
if (data.addressList && data.addressList.length > 0) {
this.setAddressPlaceholder("Choisir une adresse...");
data.addressList.forEach(itemList => {
this.billAddress.tomselect.addOption({
value: itemList.id,
text :itemList.label
})
this.shipAddress.tomselect.addOption({
value: itemList.id,
text :itemList.label
})
const option = { value: itemList.id, text: itemList.label };
if (this.billAddress.tomselect) this.billAddress.tomselect.addOption(option);
if (this.shipAddress.tomselect) this.shipAddress.tomselect.addOption(option);
});
// Optionnel : Sélectionner la première adresse par défaut
this.billAddress.tomselect.setValue(data.addressList[0].id);
this.shipAddress.tomselect.setValue(data.addressList[0].id);
} else {
this.setAddressPlaceholder("Aucune adresse trouvée");
}
} catch (error) {
console.error("Erreur lors de la récupération des adresses:", error);
console.error("Erreur adresses:", error);
this.setAddressPlaceholder("Erreur de chargement");
}
}
createOption(itemList) {
// Pas besoin d'import, 'document' est global dans le navigateur
const option = document.createElement('option');
option.value = itemList.id;
option.textContent = itemList.label;
return option;
}
}

View File

@@ -1,74 +1,99 @@
export class RepeatLine extends HTMLDivElement{
connectedCallback(){
import { initTomSelect } from "./initTomSelect.js" // Assure-toi que le chemin est correct
export class RepeatLine extends HTMLDivElement {
connectedCallback() {
this.$props = this.getProps(this, { maxRows: 20 });
this.$refs = this.getRefs(this);
// On stocke le HTML de la première ligne comme modèle
this.rowHTML = this.$refs.rows.children[0].outerHTML;
this.init();
}
// Hook up events for the row.
setUpRow(row) {
const rowRefs = this.getRefs(row);
if (rowRefs.removeButton) {
rowRefs.removeButton.onclick = (e) => {
e.preventDefault();
this.removeRow(row);
};
}
}
// Enable or disable addButton as necessary.
updateAddButton() {
if (this.$refs.rows.children.length >= this.$props.maxRows) {
this.$refs.addButton.setAttribute('disabled', '');
return;
}
this.$refs.addButton.removeAttribute('disabled');
}
// Update array key values to the row number
updateFieldNames() {
[...this.$refs.rows.children]
.forEach((el, index) => {
el.querySelectorAll('[name]')
.forEach(el => {
const newName = el.getAttribute('name').replace(/\[\d\]/gm, `[${index}]`);
el.setAttribute('name', newName);
[...this.$refs.rows.children].forEach((el, index) => {
el.querySelectorAll('[name]').forEach(input => {
const newName = input.getAttribute('name').replace(/\[\d+\]/gm, `[${index}]`);
input.setAttribute('name', newName);
});
});
}
addRow() {
if (
!this.rowHTML ||
this.$refs.rows.children.length >= this.$props.maxRows
) return;
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');
this.setUpRow(newRow);
// 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();
}
});
this.setUpRow(newRow);
this.$refs.rows.appendChild(newRow);
newRow.querySelector('input,textarea,select').focus();
newRow.querySelectorAll('input,textarea,select').forEach(el=>{
el.setAttribute('value','');
// Réinitialisation des valeurs
newRow.querySelectorAll('input,textarea,select').forEach(el => {
el.value = "";
})
if (el.tagName === 'SELECT') el.selectedIndex = 0;
});
// --- INITIALISATION TOMSELECT SUR LA NOUVELLE LIGNE ---
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();
}
removeRow(row) {
if (this.$refs.rows.children.length <= 1) return;
row.remove();
this.$refs.rows.focus();
this.updateFieldNames();
this.updateFieldNames();
// Détruire l'instance TomSelect pour libérer la mémoire avant de supprimer le DOM
row.querySelectorAll('select').forEach(select => {
if (select.tomselect) {
select.tomselect.destroy();
}
});
row.remove();
this.updateFieldNames();
this.updateAddButton();
}
init() {
this.setUpRow(this.$refs.rows.children[0]);
@@ -80,40 +105,21 @@ export class RepeatLine extends HTMLDivElement{
this.updateFieldNames();
}
// Return an object that contains references to DOM objects.
getRefs(el) {
let result = {};
[...el.querySelectorAll('[data-ref]')]
.forEach(ref => {
[...el.querySelectorAll('[data-ref]')].forEach(ref => {
result[ref.dataset.ref] = ref;
});
return result;
}
setDefaults(obj, defaults) {
let results = obj;
for (const prop in defaults) {
if (!obj.hasOwnProperty(prop)) {
results[prop] = defaults[prop];
}
getProps(el, defaults = {}) {
return Object.assign(defaults, JSON.parse(el.dataset.props ?? '{}'));
}
return results;
}
getProps(el, defaults={}) {
return this.setDefaults(
JSON.parse(el.dataset.props ?? '{}'),
defaults
);
}
createFromHTML(html='') {
let element = document.createElement(null);
element.innerHTML = html;
createFromHTML(html = '') {
let element = document.createElement('div');
element.innerHTML = html.trim();
return element.firstElementChild;
}
}

View File

@@ -0,0 +1,82 @@
// Cache pour éviter les requêtes HTTP répétitives
import TomSelect from "tom-select";
let productCache = 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;
// --- CONFIGURATION PRODUITS ---
if (el.getAttribute('data-load') === "product") {
const setupSelect = (data) => {
new TomSelect(el, {
valueField: 'id',
labelField: 'name',
searchField: 'name',
options: data,
maxOptions: null,
// LORSQU'ON SÉLECTIONNE UN PRODUIT
// Dans admin.js, section onChange de TomSelect :
onChange: (id) => {
if (!id) return;
const product = data.find(p => p.id == id);
if (product) {
const row = el.closest('.form-repeater__row') || el.closest('fieldset');
// Remplir Prix J1
const priceInput = row.querySelector('input[name*="[price_ht]"]');
if (priceInput) priceInput.value = product.price1day;
// Remplir Prix Sup (Nouveau champ)
const priceSupInput = row.querySelector('input[name*="[price_sup_ht]"]');
if (priceSupInput) priceSupInput.value = product.priceSup;
// Déclencher les events
[priceInput, priceSupInput].forEach(i => i?.dispatchEvent(new Event('change', { bubbles: true })));
}
},
render: {
option: (data, escape) => `
<div class="flex items-center gap-3 py-2 px-3 border-b border-slate-800/50">
<img src="${escape(data.image)}" class="w-8 h-8 object-cover rounded shadow-sm">
<div class="flex flex-col">
<div class="text-[13px] font-bold text-white">${escape(data.name)}</div>
<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>`
}
});
};
// Utilisation du cache ou fetch
if (productCache) {
setupSelect(productCache);
} else {
fetch("/crm/products/json")
.then(r => r.json())
.then(data => {
productCache = data;
setupSelect(data);
});
}
}
// --- AUTRES SELECTS ---
else {
new TomSelect(el, {
controlInput: null,
allowEmptyOption: true,
highlight: true,
plugins: ['dropdown_input'],
});
}
});
}

View File

@@ -0,0 +1,42 @@
<?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 Version20260119163447 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE devis_line ADD price_ht_sup DOUBLE PRECISION DEFAULT NULL');
$this->addSql('ALTER TABLE devis_line ADD day INT NOT NULL');
$this->addSql('ALTER TABLE devis_line ADD start_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL');
$this->addSql('ALTER TABLE devis_line ADD end_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL');
$this->addSql('ALTER TABLE devis_line DROP content');
$this->addSql('COMMENT ON COLUMN devis_line.start_at IS \'(DC2Type:datetime_immutable)\'');
$this->addSql('COMMENT ON COLUMN devis_line.end_at IS \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE devis_line ADD content TEXT NOT NULL');
$this->addSql('ALTER TABLE devis_line DROP price_ht_sup');
$this->addSql('ALTER TABLE devis_line DROP day');
$this->addSql('ALTER TABLE devis_line DROP start_at');
$this->addSql('ALTER TABLE devis_line DROP end_at');
}
}

View File

@@ -0,0 +1,38 @@
<?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 Version20260119163529 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE devis_line ADD product_id INT DEFAULT NULL');
$this->addSql('ALTER TABLE devis_line DROP title');
$this->addSql('ALTER TABLE devis_line ADD CONSTRAINT FK_9EC6D5294584665A FOREIGN KEY (product_id) REFERENCES product (id) NOT DEFERRABLE INITIALLY IMMEDIATE');
$this->addSql('CREATE INDEX IDX_9EC6D5294584665A ON devis_line (product_id)');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('CREATE SCHEMA public');
$this->addSql('ALTER TABLE devis_line DROP CONSTRAINT FK_9EC6D5294584665A');
$this->addSql('DROP INDEX IDX_9EC6D5294584665A');
$this->addSql('ALTER TABLE devis_line ADD title VARCHAR(255) NOT NULL');
$this->addSql('ALTER TABLE devis_line DROP product_id');
}
}

View File

@@ -9,6 +9,7 @@ use App\Logger\AppLogger;
use App\Repository\AccountRepository;
use App\Repository\CustomerRepository;
use App\Repository\DevisRepository;
use App\Repository\ProductRepository;
use App\Service\Pdf\DevisPdfService;
use Doctrine\ORM\EntityManagerInterface;
use Knp\Bundle\PaginatorBundle\KnpPaginatorBundle;
@@ -27,7 +28,10 @@ class DevisController extends AbstractController
#[Route(path: '/crm/devis', name: 'app_crm_devis', options: ['sitemap' => false], methods: ['GET'])]
public function devis(KernelInterface $kernel,DevisRepository $devisRepository,AppLogger $appLogger,PaginatorInterface $paginator,Request $request): Response
{
$d = $devisRepository->findAll()[0];
$f = new DevisPdfService($kernel,$d);
$f->generate();
$f->Output('I');
$appLogger->record('VIEW', 'Consultation de la liste des devis');
@@ -36,7 +40,7 @@ class DevisController extends AbstractController
]);
}
#[Route(path: '/crm/devis/add', name: 'app_crm_devis_add', options: ['sitemap' => false], methods: ['GET','POST'])]
public function devisAdd(EntityManagerInterface $entityManager,CustomerRepository $customerRepository,DevisRepository $devisRepository, AppLogger $appLogger,Request $request): Response
public function devisAdd(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');
@@ -54,9 +58,12 @@ class DevisController extends AbstractController
$rLine = new DevisLine();
$rLine->setDevi($devis);
$rLine->setPos($cd);
$rLine->setTitle($line['title']);
$rLine->setContent($line['description']);
$rLine->setPriceHt(floatval($line['price']));
$rLine->setProduct($productRepository->find($line['product_id']));
$rLine->setDay($line['days']);
$rLine->setPriceHt(floatval($line['price_ht']));
$rLine->setPriceHtSup(floatval($line['price_sup_ht']));
$rLine->setStartAt(\DateTimeImmutable::createFromFormat('Y-m-d',$line['date_start']));
$rLine->setEndAt(\DateTimeImmutable::createFromFormat('Y-m-d',$line['date_end']));
$entityManager->persist($rLine);
}
$entityManager->persist($devis);

View File

@@ -50,7 +50,7 @@ class SearchController extends AbstractController
$unifiedResults[] = [
'title' => $account->getName() . " " . $account->getSurname(),
'subtitle' => $account->getEmail(),
'link' => $this->generateUrl('app_crm_customer_show', ['id' => $account->getId()]),
'link' => $this->generateUrl('app_crm_customer_edit', ['id' => $account->getId()]),
'type' => 'Client',
'id' => $account->getId(),
'initials' => strtoupper(substr($account->getName(), 0, 1) . substr($account->getSurname(), 0, 1))

View File

@@ -20,15 +20,24 @@ class DevisLine
#[ORM\Column]
private ?int $pos = null;
#[ORM\Column(length: 255)]
private ?string $title = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $content = null;
#[ORM\Column]
private ?float $priceHt = null;
#[ORM\Column(nullable: true)]
private ?float $priceHtSup = null;
#[ORM\Column]
private ?int $day = null;
#[ORM\Column]
private ?\DateTimeImmutable $startAt = null;
#[ORM\Column]
private ?\DateTimeImmutable $endAt = null;
#[ORM\ManyToOne(inversedBy: 'devisLines')]
private ?Product $product = null;
public function getId(): ?int
{
return $this->id;
@@ -58,29 +67,6 @@ class DevisLine
return $this;
}
public function getTitle(): ?string
{
return $this->title;
}
public function setTitle(string $title): static
{
$this->title = $title;
return $this;
}
public function getContent(): ?string
{
return $this->content;
}
public function setContent(string $content): static
{
$this->content = $content;
return $this;
}
public function getPriceHt(): ?float
{
@@ -93,4 +79,64 @@ class DevisLine
return $this;
}
public function getPriceHtSup(): ?float
{
return $this->priceHtSup;
}
public function setPriceHtSup(?float $priceHtSup): static
{
$this->priceHtSup = $priceHtSup;
return $this;
}
public function getDay(): ?int
{
return $this->day;
}
public function setDay(int $day): static
{
$this->day = $day;
return $this;
}
public function getStartAt(): ?\DateTimeImmutable
{
return $this->startAt;
}
public function setStartAt(\DateTimeImmutable $startAt): static
{
$this->startAt = $startAt;
return $this;
}
public function getEndAt(): ?\DateTimeImmutable
{
return $this->endAt;
}
public function setEndAt(\DateTimeImmutable $endAt): static
{
$this->endAt = $endAt;
return $this;
}
public function getProduct(): ?Product
{
return $this->product;
}
public function setProduct(?Product $product): static
{
$this->product = $product;
return $this;
}
}

View File

@@ -3,6 +3,8 @@
namespace App\Entity;
use App\Repository\ProductRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\HttpFoundation\File\File;
use Vich\UploaderBundle\Mapping\Attribute\Uploadable;
@@ -53,6 +55,17 @@ class Product
#[ORM\Column(length: 255, nullable: true)]
private ?string $productId = null;
/**
* @var Collection<int, DevisLine>
*/
#[ORM\OneToMany(targetEntity: DevisLine::class, mappedBy: 'product')]
private Collection $devisLines;
public function __construct()
{
$this->devisLines = new ArrayCollection();
}
public function getId(): ?int
{
return $this->id;
@@ -189,4 +202,34 @@ class Product
return $this;
}
/**
* @return Collection<int, DevisLine>
*/
public function getDevisLines(): Collection
{
return $this->devisLines;
}
public function addDevisLine(DevisLine $devisLine): static
{
if (!$this->devisLines->contains($devisLine)) {
$this->devisLines->add($devisLine);
$devisLine->setProduct($this);
}
return $this;
}
public function removeDevisLine(DevisLine $devisLine): static
{
if ($this->devisLines->removeElement($devisLine)) {
// set the owning side to null (unless already changed)
if ($devisLine->getProduct() === $this) {
$devisLine->setProduct(null);
}
}
return $this;
}
}

View File

@@ -103,32 +103,65 @@ class DevisPdfService extends Fpdf
$this->SetY($yAddress + 35);
$this->Ln(10);
// --- TABLEAU DES PRESTATIONS ---
$this->SetFont('Arial', 'B', 8);
$this->SetFillColor(245, 247, 250);
$this->Cell(85, 10, $this->clean('Désignation'), 1, 0, 'L', true);
$this->Cell(70, 10, $this->clean('Désignation'), 1, 0, 'L', true);
$this->Cell(15, 10, $this->clean('Jours'), 1, 0, 'C', true);
$this->Cell(30, 10, $this->clean('Prix HT'), 1, 0, 'R', true);
$this->Cell(20, 10, $this->clean('TVA'), 1, 0, 'C', true);
$this->Cell(40, 10, $this->clean('Total TTC'), 1, 1, 'R', true);
$this->Cell(25, 10, $this->clean('Tarif J1'), 1, 0, 'R', true);
$this->Cell(25, 10, $this->clean('Tarif Sup'), 1, 0, 'R', true);
$this->Cell(15, 10, $this->clean('TVA'), 1, 0, 'C', true);
$this->Cell(40, 10, $this->clean('Total HT'), 1, 1, 'R', true); // Total HT car TVA 0%
$this->SetFont('Arial', '', 9);
$this->SetTextColor(0, 0, 0);
$totalCaution = 0;
$totalHT = 0;
foreach ($this->devis->getDevisLines() as $line) {
$ht = $line->getPriceHt();
$totalHT += $ht;
$nbJours = method_exists($line, 'getNbDays') ? $line->getNbDays() : 1;
$totalCaution = $totalCaution + $line->getProduct()->getCaution();
$price1Day = $line->getPriceHt();
$priceSupHT = $line->getPriceHtSup() ?? 0;
$nbDays = $line->getDay();
// Calcul : J1 + (Jours restants * Prix HT Sup)
$lineTotalHT = $price1Day + (max(0, $nbDays - 1) * $priceSupHT);
$totalHT += $lineTotalHT;
$productName = $line->getProduct()->getName();
$ref = $line->getProduct()->getRef();
$dateStart = $line->getStartAt() ? $line->getStartAt()->format('d/m/Y') : '';
$dateEnd = $line->getEndAt() ? $line->getEndAt()->format('d/m/Y') : '';
$currentY = $this->GetY();
$this->MultiCell(85, 8, $this->clean($line->getTitle()), 1, 'L');
$h = $this->GetY() - $currentY;
$this->SetXY(95, $currentY);
$this->Cell(15, $h, $nbJours, 1, 0, 'C');
$this->Cell(30, $h, number_format($ht, 2, ',', ' ') . $this->euro(), 1, 0, 'R');
$this->Cell(20, $h, '0 %', 1, 0, 'C');
$this->Cell(40, $h, number_format($ht, 2, ',', ' ') . $this->euro(), 1, 1, 'R');
// --- COLONNE DÉSIGNATION (NOM + REF / DATES) ---
$this->SetXY(10, $currentY);
$this->SetFont('Arial', 'B', 8);
$this->Cell(70, 5, $this->clean($productName . ' (Ref: ' . $ref . ')'), 0, 0, 'L');
$this->SetXY(10, $currentY + 5);
$this->SetFont('Arial', 'I', 7);
$this->SetTextColor(100, 100, 100);
$this->Cell(70, 4, $this->clean("Période : du $dateStart au $dateEnd"), 0, 0, 'L');
$this->SetTextColor(0, 0, 0);
// --- COLONNES NUMÉRIQUES ---
$this->SetXY(80, $currentY);
$this->SetFont('Arial', '', 8);
$this->Cell(15, 10, $nbDays, 'LRB', 0, 'C');
$this->Cell(25, 10, number_format($price1Day, 2, ',', ' ') . $this->euro(), 'RB', 0, 'R');
$this->Cell(25, 10, number_format($priceSupHT, 2, ',', ' ') . $this->euro(), 'RB', 0, 'R');
$this->Cell(15, 10, '0%', 'RB', 0, 'C');
$this->Cell(40, 10, number_format($lineTotalHT, 2, ',', ' ') . $this->euro(), 'RB', 1, 'R');
// Fermeture de la bordure pour la désignation
$this->Line(10, $currentY, 10, $currentY + 10);
$this->Line(10, $currentY + 10, 80, $currentY + 10);
if ($this->GetY() > 250) {
$this->AddPage();
}
}
// --- BLOC TOTAUX ---
@@ -150,6 +183,20 @@ class DevisPdfService extends Fpdf
$this->Cell(30, 10, ' TOTAL TTC', 0, 0, 'L', true);
$this->Cell(30, 10, number_format($totalHT, 2, ',', ' ') . $this->euro() . ' ', 0, 1, 'R', true);
// --- AJOUT : TOTAL DE LA CAUTION ---
$this->Ln(2);
$this->Cell(130);
$this->SetFont('Arial', 'B', 10);
$this->SetTextColor(80, 80, 80); // Gris pour distinguer de la vente
$this->SetDrawColor(200, 200, 200);
// On crée un petit encadré pour la caution
$this->Cell(30, 8, ' TOTAL CAUTION', 'B', 0, 'L');
$this->Cell(30, 8, number_format($totalCaution, 2, ',', ' ') . $this->euro(), 'B', 1, 'R');
$this->SetFont('Arial', 'I', 7);
$this->Cell(130);
$this->Cell(60, 4, $this->clean('(Caution non encaissée, voir CGV Article 6)'), 0, 1, 'R');
// Mention légale auto-entrepreneur
$this->Ln(5);
$this->SetTextColor(80, 80, 80);
@@ -167,43 +214,78 @@ class DevisPdfService extends Fpdf
$this->isExtraPage = true;
$this->AddPage();
$this->SetMargins(15, 15, 15);
$this->SetAutoPageBreak(true, 20);
$this->SetY(15);
// --- ENTÊTE ---
$this->SetFont('Arial', 'B', 12);
$this->SetTextColor(37, 99, 235); // Bleu Ludikevent
$this->Cell(0, 10, $this->clean('CONDITIONS GÉNÉRALES DE VENTE'), 0, 1, 'C');
$this->SetFont('Arial', 'B', 10);
$this->SetTextColor(0, 0, 0);
$this->Cell(0, 10, $this->clean('CONDITIONS GÉNÉRALES DE VENTE LILIAN SEGARD - LUDIKEVENT'), 0, 1, 'C');
$this->SetFont('Arial', '', 7);
$this->Cell(0, 5, $this->clean('SIRET : 930 488 408 00012 | 6 Rue du Château 02800 Danizy'), 0, 1, 'C');
$this->Ln(5);
$this->Cell(0, 5, $this->clean('Lilian SEGARD - Ludikevent'), 0, 1, 'C');
$this->SetFont('Arial', '', 8);
$cgv = [
"ARTICLE 1 OBJET ET CHAMP DAPPLICATION" => "Les présentes CGV régissent la location de structures gonflables professionnelles appartenant à Lilian SEGARD - Ludikevent ou la mise en relation avec des propriétaires privés. Dans ce second cas, Ludikevent est intermédiaire et le contrat est conclu entre particuliers. Ludikevent nassume aucune responsabilité liée à lutilisation.",
"ARTICLE 2 RÉSERVATION" => "La réservation devient définitive après confirmation écrite, paiement des arrhes de 25% et acceptation expresse des CGV. Ludikevent peut refuser une réservation si les conditions de sécurité ne sont pas adaptées.",
"ARTICLE 3 TARIFS & PAIEMENT" => "Tarifs en euros TTC. 25% d'arrhes à la réservation, solde dû au plus tard le jour de l'installation avant montage. En cas de non-paiement du solde, pas de livraison et arrhes acquises.",
"ARTICLE 4 DROIT DE RÉTRACTATION" => "Conformément à larticle L221-28 du Code de la consommation, aucun droit de rétractation pour une prestation datée et réservée pour un jour précis.",
"ARTICLE 5 ANNULATION" => "Par le Client : Arrhes non remboursables. Moins de 15 jours avant l'événement : arrhes non remboursables. Par Ludikevent : Arrhes remboursables si impossibilité totale (force majeure). Solution de report privilégiée.",
"ARTICLE 6 CAUTION" => "Une caution est exigée. Restitution après contrôle. Toute dégradation, salissure importante ou perte d'élément entraînera une déduction facturée.",
"ARTICLE 7 LIVRAISON / INSTALLATION / RESTITUTION" => "Structures pro : installation par Ludikevent sur terrain plat/propre, alimentation 220V. Mise en relation : installation/récupération pour le compte du propriétaire, surveillance permanente exigée du locataire.",
"ARTICLE 8 OBLIGATIONS DU CLIENT" => "Surveillance constante par un adulte, interdiction alcool/substances, retrait chaussures/bijoux, arrêt immédiat si vent > 40km/h ou orage. Ne pas déplacer le matériel.",
"ARTICLE 9 ASSURANCES & RESPONSABILITÉS" => "Le locataire doit disposer d'une RC couvrant l'événement. Ludikevent agit comme intermédiaire pour le matériel privé et décline toute responsabilité en cas d'accident durant l'utilisation.",
"ARTICLE 10 CONDITIONS MÉTÉO" => "La sécurité est prioritaire. Ludikevent peut interrompre la prestation si danger météo. Aucun remboursement si le matériel a déjà été installé.",
"ARTICLE 11 DOMMAGES & VOL" => "État des lieux contradictoire. Toute dégradation non signalée est imputable au locataire. Vol ou perte : facturation de la valeur de remplacement.",
"ARTICLE 12 DONNÉES PERSONNELLES" => "Utilisation limitée à la gestion du contrat (RGPD). Droit d'accès et rectification via contact@ludikevent.fr.",
"ARTICLE 13 RÉCLAMATIONS" => "Toute réclamation doit être adressée par email ou courrier à l'adresse en-tête.",
"ARTICLE 14 LOI APPLICABLE" => "Droit français. Tribunaux compétents du siège de Ludikevent. La réservation vaut acceptation pleine des CGV.",
"ARTICLE 15 LIMITATION DE RESPONSABILITÉ & SÉCURITÉ" => "Le locataire assume l'entière responsabilité de l'utilisation dès l'installation. Ludikevent décline toute responsabilité pour les dommages corporels ou matériels subis par les utilisateurs ou tiers."
$this->SetTextColor(80, 80, 80);
$this->Cell(0, 4, $this->clean('SIRET : 930 488 408 00012 | 6 Rue du Château 02800 Danizy France'), 0, 1, 'C');
$this->Cell(0, 4, $this->clean('Email : contact@ludikevent.fr | Téléphone : 06 14 17 24 47'), 0, 1, 'C');
$this->Cell(0, 4, $this->clean('Assurance RC Pro : [N° de police à compléter]'), 0, 1, 'C');
$this->Ln(5);
$this->SetFont('Arial', 'I', 9);
$this->SetTextColor(0, 0, 0);
$this->MultiCell(0, 5, $this->clean("Toute réservation implique l'acceptation sans réserve des présentes Conditions Générales de Vente."), 0, 'C');
$this->Ln(5);
// --- TEXTE INTÉGRAL ---
$fullText = [
"ARTICLE 1 OBJET ET CHAMP D'APPLICATION" => "Les présentes Conditions Générales de Vente (ci-après \"CGV\") régissent les relations contractuelles entre Lilian SEGARD - Ludikevent (ci-après \"le Prestataire\") et toute personne physique ou morale (ci-après \"le Client\" ou \"le Locataire\") dans le cadre de :\n• La location de structures gonflables professionnelles appartenant à Lilian SEGARD - Ludikevent\n• La mise en relation avec des propriétaires privés pour du matériel récréatif destiné à des événements privés (anniversaires, baptêmes, etc.)\nDans le cas d'une mise en relation entre particuliers : Lilian SEGARD - Ludikevent intervient uniquement en qualité d'intermédiaire. Le contrat de location est conclu directement entre le propriétaire du matériel et le locataire. Lilian SEGARD - Ludikevent n'assume aucune responsabilité liée à l'état, la conformité ou l'utilisation dudit matériel.",
"ARTICLE 2 RÉSERVATION" => "La réservation devient ferme et définitive après réunion des trois conditions cumulatives suivantes :\n✓ Confirmation écrite par Lilian SEGARD - Ludikevent (email ou courrier)\n✓ Paiement effectif des arrhes de 25% du montant total\n✓ Acceptation expresse et signature des présentes CGV\nLes prestations sont proposées sous réserve de disponibilité du matériel aux dates demandées. Lilian SEGARD - Ludikevent se réserve le droit de refuser toute réservation si les conditions d'installation ou de sécurité ne sont pas réunies, sans que sa responsabilité ne puisse être engagée.",
"ARTICLE 3 TARIFS ET CONDITIONS DE PAIEMENT" => "Les tarifs sont exprimés en euros (€) toutes taxes comprises (TTC) et sont ceux en vigueur au jour de la réservation, conformément au devis ou contrat signé.\nModalités de paiement :\n• Arrhes de 25% à la réservation (non remboursables sauf cas de force majeure dûment justifié)\n• Solde dû au plus tard le jour de l'installation, AVANT le montage des structures\nEn cas de non-paiement du solde à la date prévue, Lilian SEGARD - Ludikevent se réserve le droit de ne pas procéder à la livraison ni à l'installation, sans que le Client puisse prétendre à un quelconque remboursement des arrhes versées. Des frais de livraison, d'installation et de déplacement peuvent s'appliquer selon la distance et les conditions d'accès au lieu de l'événement. Modes de paiement acceptés : virement bancaire, chèque (à établir à l'ordre de Lilian SEGARD - Ludikevent).",
"ARTICLE 4 DROIT DE RÉTRACTATION" => "Conformément à l'article L221-28 du Code de la consommation, le droit de rétractation de 14 jours ne s'applique pas aux prestations de services pleinement exécutées avant la fin du délai de rétractation et dont l'exécution a commencé après accord préalable exprès du consommateur et renoncement exprès à son droit de rétractation. En outre, aucun droit de rétractation n'est applicable pour une prestation de services dont la date d'exécution est fixée à une date déterminée ou à période déterminée (événement daté).",
"ARTICLE 5 CONDITIONS D'ANNULATION" => "5.1 Annulation par le Client :\n• Annulation après versement des arrhes : les arrhes restent acquises au Prestataire à titre d'indemnité forfaitaire\n• Annulation moins de 15 jours calendaires avant la date de l'événement : arrhes non remboursables + facturation de 50% du solde restant dû\n• Annulation moins de 7 jours calendaires avant la date de l'événement : intégralité du montant reste due\n• Cas de force majeure dûment justifié (certificat médical, décès, catastrophe naturelle) : étude au cas par cas, possibilité de report de la prestation\n\n5.2 Annulation par Lilian SEGARD - Ludikevent :\nEn cas d'impossibilité totale d'assurer la prestation pour cause de force majeure (conditions météorologiques extrêmes, défaillance matérielle majeure, cas fortuit), Lilian SEGARD - Ludikevent s'engage à : informer le Client dans les meilleurs délais, proposer une solution de report ou de remplacement dans la mesure du possible, procéder au remboursement intégral des sommes versées si aucune solution alternative n'est acceptable. La responsabilité de Lilian SEGARD - Ludikevent ne saurait être engagée au-delà du remboursement des sommes effectivement perçues.",
"ARTICLE 6 CAUTION DE GARANTIE" => "Une caution de garantie peut être exigée selon le type et la valeur du matériel loué :\n• Structures professionnelles Lilian SEGARD - Ludikevent | Selon devis | Restitution immédiate après état des lieux de fin de location et contrôle de conformité\n• Matériel mis en relation (propriétaires privés) | Selon convention propriétaire | Restitution selon accord entre parties\nLa caution peut être conservée totalement ou partiellement en cas de : dégradation du matériel, salissure importante nécessitant un nettoyage approfondi, perte d'éléments ou d'accessoires, non-respect des conditions d'utilisation ayant entraîné des dommages.",
"ARTICLE 7 LIVRAISON, INSTALLATION ET RÉCUPÉRATION" => "7.1 Structures professionnelles Lilian SEGARD - Ludikevent :\nInstallation : L'installation est réalisée exclusivement par le personnel qualifié de Lilian SEGARD - Ludikevent. Le Client doit fournir un terrain plat, propre, dégagé, avec une alimentation électrique 220V à moins de 50 mètres. L'accès doit être praticable pour les véhicules.\nRécupération : La récupération du matériel est effectuée par Lilian SEGARD - Ludikevent aux horaires convenus.\n7.2 Matériel en mise en relation :\nLilian SEGARD - Ludikevent peut assurer l'installation et la récupération pour le compte du propriétaire privé. Le Client locataire reste tenu d'assurer une surveillance permanente et constante. Des frais de déplacement supplémentaires peuvent s'appliquer au-delà d'un rayon de 30 km depuis Danizy.",
"ARTICLE 8 ÉTATS DES LIEUX ET TRANSFERT DE RESPONSABILITÉ" => "8.1 État des lieux d'installation : Un état des lieux contradictoire est OBLIGATOIREMENT réalisé. La signature par le Client vaut reconnaissance de conformité et TRANSFERT COMPLET DE LA RESPONSABILITÉ DU MATÉRIEL AU CLIENT. Le Client assume l'ENTIÈRE et EXCLUSIVE responsabilité du matériel, de son utilisation, de sa surveillance et des dommages qui pourraient en résulter.\n8.2 État des lieux de fin de location : Un état des lieux contradictoire est réalisé lors de la récupération. Sa signature vaut RETOUR DE LA RESPONSABILITÉ À Lilian SEGARD - Ludikevent.\n8.3 Absence de signature : En cas d'absence ou de refus de signature, l'état des lieux établi par Lilian SEGARD - Ludikevent fera foi. Le matériel sera réputé avoir été livré en parfait état et la responsabilité du Client reste pleine et entière durant toute la période de location.",
"ARTICLE 9 OBLIGATIONS ET RESPONSABILITÉS DU CLIENT" => "Le Client s'engage IMPÉRATIVEMENT à :\n9.1 Surveillance obligatoire : Assurer une surveillance PERMANENTE, CONSTANTE et ACTIVE par un adulte majeur responsable.\n9.2 Respect des consignes : Ébriété interdite, retrait des chaussures, bijoux, objets pointus, pas de nourriture ou boissons.\n9.3 Conditions météorologiques : ARRÊTER IMMÉDIATEMENT l'utilisation en cas de vent > 40 km/h, pluie ou orage.\n9.4 Intégrité du matériel : Ne JAMAIS déplacer, démonter ou modifier les structures, ni débrancher la soufflerie. Signaler toute anomalie immédiatement.",
"ARTICLE 10 EXCLUSION ET LIMITATION DE RESPONSABILITÉ" => "10.1 Lilian SEGARD - Ludikevent garantit exclusivement la livraison d'un matériel conforme et une installation aux normes.\n10.2 Durant la période de location, Lilian SEGARD - Ludikevent décline FORMELLEMENT et INTÉGRALEMENT toute responsabilité concernant : les dommages corporels (blessures, accidents) subis par les utilisateurs, les dommages matériels causés aux tiers, les conséquences d'une surveillance insuffisante ou du non-respect des consignes.\n10.3 Le Client reconnaît être le SEUL responsable de la sécurité, assumer tous les risques et renoncer IRRÉVOCABLEMENT à tout recours contre Lilian SEGARD - Ludikevent.",
"ARTICLE 11 ASSURANCE OBLIGATOIRE DU CLIENT" => "Le Client doit OBLIGATOIREMENT disposer d'une assurance RC en cours de validité couvrant l'utilisation de structures gonflables. En l'absence d'assurance valide, Lilian SEGARD - Ludikevent se réserve le droit d'annuler la prestation sans remboursement possible.",
"ARTICLE 12 CONDITIONS MÉTÉOROLOGIQUES" => "La sécurité est prioritaire. Lilian SEGARD - Ludikevent peut refuser l'installation ou interrompre la prestation en cas de danger. Aucun remboursement ne pourra être réclamé si le matériel a déjà été installé conformément au contrat.",
"ARTICLE 13 DÉGRADATIONS, PERTES ET VOLS" => "Toute dégradation ou vol survenu durant la location sera INTÉGRALEMENT facturé au Client (réparation ou valeur de remplacement). Toute dégradation non signalée lors de l'état des lieux d'installation est irréfragablement imputable au Client.",
"ARTICLE 14 PROTECTION DES DONNÉES PERSONNELLES" => "Données collectées EXCLUSIVEMENT pour la gestion du contrat (RGPD). Droit d'accès et de rectification via contact@ludikevent.fr.",
"ARTICLE 15 RÉCLAMATIONS" => "Toute réclamation doit être adressée par écrit sous 8 jours à : Lilian SEGARD - Ludikevent, 6 Rue du Château, 02800 Danizy. Passé ce délai, aucune réclamation n'est recevable.",
"ARTICLE 16 MÉDIATION" => "Le Client consommateur peut recourir gratuitement à un médiateur de la consommation en vue de la résolution amiable du litige.",
"ARTICLE 17 DROIT APPLICABLE ET JURIDICTION COMPÉTENTE" => "Loi française. Compétence EXCLUSIVE aux tribunaux du ressort du siège de Lilian SEGARD - Ludikevent (Tribunal de Saint-Quentin).",
"ARTICLE 18 ACCEPTATION DES CONDITIONS GÉNÉRALES DE VENTE" => "Le fait de procéder à une réservation vaut ACCEPTATION PLEINE, ENTIÈRE et SANS RÉSERVE des présentes CGV. Le Client reconnaît en avoir pris connaissance et les avoir comprises."
];
foreach ($cgv as $titre => $texte) {
if ($this->GetY() > 260) $this->AddPage();
$this->SetFont('Arial', 'B', 8);
foreach ($fullText as $titre => $corps) {
$this->SetFont('Arial', 'B', 9);
$this->SetTextColor(37, 99, 235);
$this->Cell(0, 5, $this->clean($titre), 0, 1, 'L');
$this->SetFont('Arial', '', 7.5);
$this->MultiCell(0, 5, $this->clean($titre), 0, 'L');
$this->SetFont('Arial', '', 8.5);
$this->SetTextColor(0, 0, 0);
$this->MultiCell(0, 3.5, $this->clean($texte), 0, 'L');
$this->Ln(2);
$this->MultiCell(0, 4, $this->clean($corps), 0, 'L');
$this->Ln(3);
}
}
@@ -211,37 +293,79 @@ class DevisPdfService extends Fpdf
{
$this->isExtraPage = true;
$this->AddPage();
$this->SetY(40);
$this->SetY(30);
// Titre
$this->SetFont('Arial', 'B', 14);
$this->SetTextColor(37, 99, 235);
$this->Cell(0, 10, $this->clean("BON POUR ACCORD ET SIGNATURE"), 0, 1, 'C');
$this->Ln(10);
$this->SetFont('Arial', '', 11); $this->SetTextColor(0,0,0);
$this->MultiCell(0, 7, $this->clean("En signant ce document, le client reconnaît avoir pris connaissance du devis et accepte sans réserve les 15 articles des Conditions Générales de Vente ci-jointes.\n\nFait le : " . date('d/m/Y')), 0, 'L');
$this->SetFont('Arial', '', 10);
$this->SetTextColor(0, 0, 0);
$this->Cell(0, 7, $this->clean("Fait à Danizy, le : " . date('d/m/Y')), 0, 1, 'L');
$this->Ln(10);
// --- SECTION DES CASES À COCHER ---
$this->SetFont('Arial', '', 10);
$checkPoints = [
"J'accepte sans réserve les Conditions Générales de Vente ci-jointes." => "cgv",
"Je reconnais avoir pris connaissance de l'obligation de disposer d'une assurance RC." => "assurance",
"Je reconnais avoir pris connaissance des mesures de sécurité et de surveillance." => "securite",
"Je reconnais avoir pris connaissance du paiement des arrhes de 25% pour validation (non remboursables)." => "arrhes"
];
foreach ($checkPoints as $label => $role) {
$currentY = $this->GetY();
$this->Rect(15, $currentY + 1, 5, 5);
$this->SetX(25);
$this->MultiCell(0, 7, $this->clean($label), 0, 'L');
// Balise DocuSeal Check
$this->SetXY(15, $currentY + 1);
$this->SetTextColor(255, 255, 255);
$this->SetFont('Arial', '', 4);
$this->Cell(5, 5, '{{Check;required=true;role=Client;name='.$role.'}}', 0, 0, 'C');
$this->SetTextColor(0, 0, 0);
$this->SetFont('Arial', '', 10);
$this->Ln(4);
}
$this->Ln(15);
$y = $this->GetY();
$ySign = $this->GetY();
// Cadre Ludikevent
$this->SetXY(15, $y); $this->SetFont('Arial', 'B', 10);
$this->Cell(85, 8, $this->clean("Le Prestataire (Lilian SEGARD)"), 0, 1, 'C');
$this->SetX(15); $this->Cell(85, 40, "", 1, 0);
// --- BLOCS DE SIGNATURE ---
// BALISE DOCUSEAL CACHÉE (BLANC)
$this->SetXY(15, $y + 18);
// Cadre Prestataire (Lilian SEGARD)
$this->SetXY(15, $ySign);
$this->SetFont('Arial', 'B', 10);
$this->Cell(85, 8, $this->clean("Le Prestataire"), 0, 1, 'C');
$this->SetX(15);
$this->Cell(85, 45, "", 1, 0); // Rectangle de signature
// Texte à l'intérieur du rectangle du prestataire
$this->SetXY(17, $ySign + 12);
$this->SetFont('Arial', 'I', 8);
$this->SetTextColor(100, 100, 100);
// Balise Signature Ludikevent (Cachée pour DocuSeal)
$this->SetXY(15, $ySign + 25);
$this->SetTextColor(255, 255, 255);
$this->SetFont('Arial', '', 4);
$this->Cell(85, 5, '{{Sign;type=signature;role=Ludikevent}}', 0, 0, 'C');
// Cadre Client
$this->SetTextColor(0, 0, 0);
$this->SetXY(110, $y); $this->SetFont('Arial', 'B', 10);
$this->SetXY(110, $ySign);
$this->SetFont('Arial', 'B', 10);
$this->Cell(85, 8, $this->clean("Le Client (Lu et approuvé)"), 0, 1, 'C');
$this->SetX(110); $this->Cell(85, 40, "", 1, 0);
$this->SetX(110);
$this->Cell(85, 45, "", 1, 0);
// BALISE DOCUSEAL CACHÉE (BLANC)
$this->SetXY(110, $y + 18);
// Balise Signature Client (Cachée pour DocuSeal)
$this->SetXY(110, $ySign + 20);
$this->SetTextColor(255, 255, 255);
$this->SetFont('Arial', '', 4);
$this->Cell(85, 5, '{{Sign;type=signature;role=Client}}', 0, 0, 'C');

View File

@@ -38,99 +38,116 @@
</div>
<div class="relative space-y-10">
{# GRILLE À 3 COLONNES #}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
{# SECTION ENTÊTE #}
{% set input_class = "w-full bg-slate-900/60 border border-white/10 rounded-2xl px-5 py-4 text-sm text-white outline-none focus:border-blue-500/50 focus:bg-slate-900/90 transition-all duration-300" %}
{% set label_class = "block text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] mb-3 ml-2" %}
{# COLONNE 1 : NUMÉRO #}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
<div class="space-y-3">
{{ form_label(form.num) }}
<div class="relative">
{{ form_widget(form.num) }}
</div>
{{ form_label(form.num, null, {'label_attr': {'class': label_class}}) }}
{{ form_widget(form.num, {'attr': {'class': input_class}}) }}
</div>
{# COLONNE 2 : DATE #}
<div class="space-y-3">
{{ form_label(form.createA) }}
<div class="relative">
{{ form_widget(form.createA) }}
</div>
{{ form_label(form.createA, null, {'label_attr': {'class': label_class}}) }}
{{ form_widget(form.createA, {'attr': {'class': input_class}}) }}
</div>
{# COLONNE 3 : CLIENT #}
<div class="space-y-3" is="devis-manager">
{{ form_label(form.customer) }}
{{ form_widget(form.customer) }}
{{ form_label(form.customer, null, {'label_attr': {'class': label_class}}) }}
{{ form_widget(form.customer, {'attr': {'class': input_class}}) }}
</div>
<div>
<label for="billAddress">Adresse de facturation</label>
<select id="billAddress" name="devis[ship_address]">
</select>
</div>
<div>
<label for="shipAddress">Adresse de livraison</label>
<select id="shipAddress" name="devis[ship_address]">
</select>
</div>
{# SECTION ADRESSES #}
<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>
</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>
</div>
</div>
<hr class="border-white/5">
{# SECTION REPEATER #}
<div class="form-repeater" data-component="repeater" is="repeat-line">
<ol class="form-repeater__rows" data-ref="rows" tabindex="0">
<li class="form-repeater__row" style="border-bottom: 1px solid white">
<fieldset class="form-group form-group--horizontal">
<div class="flex space-x-4">
<div class="flex-1">
<div class="form-field">
<div class="mb-1">
<label for="product" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Produit</label>
<select data-load="product" type="text" name="lines[0][title]" id="lines[0][title]" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required>
<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 prestations</h4>
</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">
<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">
{# 1. PRODUIT #}
<div class="lg:col-span-3">
<label class="{{ label_class }}">Produit / Prestation</label>
<select data-load="product" name="lines[0][product_id]" class="{{ input_class }}" required>
<option value="">Sélectionner...</option>
</select>
</div>
<div class="mb-1">
<label for="product" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Nombre de jour</label>
<input type="number" step="1" name="lines[0][price]" id="lines[0][price]" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required />
{# 2. DURÉE #}
<div class="lg:col-span-1">
<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 />
</div>
<div class="mb-1">
<label for="product" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Prix Ht</label>
<input type="number" step="0.1" name="lines[0][price]" id="lines[0][price]" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required />
{# 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 />
</div>
<div class="mb-1">
<label for="product" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Date de début</label>
<input type="date" name="lines[0][price]" id="lines[0][price]" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required />
{# 4. PRIX HT SUP #}
<div class="lg:col-span-1">
<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 />
</div>
<div class="mb-1">
<label for="product" class="block mb-2 text-sm font-medium text-gray-900 dark:text-white">Date de fin</label>
<input type="date" name="lines[0][price]" id="lines[0][price]" class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500" required />
{# 5. DATES #}
<div class="lg:col-span-2">
<label class="{{ label_class }}">Date Début</label>
<input type="date" name="lines[0][date_start]" class="{{ input_class }} [color-scheme:dark]" required />
</div>
<button
class="w-full form-repeater__remove-button bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded"
data-ref="removeButton"
type="button"
>
Supprimer la ligne
<div class="lg:col-span-2">
<label class="{{ label_class }}">Date Fin</label>
<input type="date" name="lines[0][date_end]" class="{{ input_class }} [color-scheme:dark]" required />
</div>
{# 6. SUPPRIMER #}
<div class="lg:col-span-1 flex justify-center pb-1">
<button type="button" data-ref="removeButton" class="p-4 bg-red-500/10 hover:bg-red-500 text-red-500 hover:text-white rounded-2xl transition-all duration-300 shadow-lg hover:shadow-red-500/20">
<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>
</div>
</fieldset>
</li>
</ol>
<button
class="mt-2 form-repeater__add-button w-full bg-purple-600 hover:bg-purple-700 text-white px-3 py-1 rounded"
data-ref="addButton"
type="button"
>
+ Ajouter une ligne
<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">
<span class="text-xl group-hover:rotate-90 transition-transform duration-300">+</span>
<span>Ajouter une prestation</span>
</button>
</div>
{# BOUTON LARGEUR TOTALE #}
<div class="pt-4">
<button type="submit" class="w-full py-6 bg-blue-600 hover:bg-blue-500 text-white text-[11px] font-black uppercase tracking-[0.5em] rounded-2xl shadow-2xl shadow-blue-600/40 transition-all hover:scale-[1.005] active:scale-95 flex items-center justify-center group">
<span>Valider et créer le devis</span>
<svg class="w-5 h-5 ml-4 transform group-hover:translate-x-2 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
</div>
{# VALIDATION #}
<div class="pt-8 px-4">
<button type="submit" class="w-full py-6 bg-blue-600 hover:bg-blue-500 text-white text-[11px] font-black uppercase tracking-[0.5em] rounded-[2rem] shadow-2xl shadow-blue-600/30 transition-all hover:scale-[1.01] active:scale-95 flex items-center justify-center group">
<span>Valider et enregistrer le devis</span>
<svg class="w-5 h-5 ml-4 transform group-hover:translate-x-2 transition-transform duration-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7m0 0l-7 7m7-7H3"/>
</svg>
</button>
@@ -139,28 +156,5 @@
</div>
{{ form_end(form) }}
</div>
<style>
label {
@apply block text-[10px] font-black text-slate-500 uppercase tracking-[0.2em] mb-3 ml-2 !important;
}
input, select {
@apply w-full bg-slate-900/60 border border-white/5 rounded-2xl px-6 py-5 text-sm text-white outline-none focus:border-blue-500/50 focus:bg-slate-900/80 transition-all duration-500 !important;
}
input[readonly] {
@apply border-white/5 bg-white/5 cursor-not-allowed text-slate-500 !important;
}
select {
@apply appearance-none cursor-pointer;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%233b82f6'%3E%3Cpath stroke-linecap='round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 1.5rem center;
background-size: 1rem;
}
</style>
{% endblock %}