From 84180d95610d3aaacc96d026e2070fc78365b1bb Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Fri, 16 Jan 2026 16:00:00 +0100 Subject: [PATCH] =?UTF-8?q?```=20=E2=9C=A8=20feat(Devis):=20Ajoute=20l'ent?= =?UTF-8?q?it=C3=A9=20DevisLine=20et=20le=20formulaire=20de=20cr=C3=A9atio?= =?UTF-8?q?n=20de=20devis=20non=20terminer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute l'entité DevisLine, le formulaire NewDevisType et la route pour la création de devis. ``` --- assets/admin.js | 24 +- assets/admin.scss | 334 ++++++++++++++++++- assets/tom.scss | 0 migrations/Version20260116143611.php | 35 ++ src/Controller/Dashboard/DevisController.php | 16 +- src/Entity/Devis.php | 43 +++ src/Entity/DevisLine.php | 96 ++++++ src/Form/NewDevisType.php | 56 ++++ src/Repository/DevisLineRepository.php | 43 +++ templates/dashboard/devis/add.twig | 107 ++++++ 10 files changed, 749 insertions(+), 5 deletions(-) create mode 100644 assets/tom.scss create mode 100644 migrations/Version20260116143611.php create mode 100644 src/Entity/DevisLine.php create mode 100644 src/Form/NewDevisType.php create mode 100644 src/Repository/DevisLineRepository.php create mode 100644 templates/dashboard/devis/add.twig diff --git a/assets/admin.js b/assets/admin.js index 356d34a..6f7aa11 100644 --- a/assets/admin.js +++ b/assets/admin.js @@ -1,7 +1,8 @@ import './admin.scss' + import * as Sentry from "@sentry/browser"; import * as Turbo from "@hotwired/turbo"; - +import TomSelect from "tom-select"; // --- INITIALISATION SENTRY (En premier !) --- Sentry.init({ dsn: "https://803814be6540031b1c37bf92ba9c0f79@sentry.esy-web.dev/24", @@ -22,7 +23,26 @@ Sentry.init({ */ function initAdminLayout() { - + document.querySelectorAll('select').forEach((el) => { + if (!el.tomselect) { // Éviter la double initialisation avec Turbo + 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 `
+
${escape(data.text)}
+
`; + }, + item: function(data, escape) { + return `
${escape(data.text)}
`; + } + } + }); + } + }); const imageInput = document.getElementById('product_image_input'); const previewImage = document.getElementById('product-image-preview'); const placeholderIcon = document.getElementById('product-image-placeholder'); diff --git a/assets/admin.scss b/assets/admin.scss index 5657766..5538096 100644 --- a/assets/admin.scss +++ b/assets/admin.scss @@ -1,12 +1,12 @@ @import "tailwindcss"; +@import "tom-select/dist/css/tom-select.css"; form { label { - color: white !important; + color: white; } } - .animate-fadeIn { animation: fadeIn 0.3s ease-in-out; } @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } .custom-scrollbar::-webkit-scrollbar { width: 5px; height: 5px; } @@ -23,3 +23,333 @@ form { /* Menu Accordion sans JS */ details summary::-webkit-details-marker { display:none; } details[open] .arrow-icon { transform: rotate(180deg); } + +.ts-control { + border: 1px solid rgba(255, 255, 255, 0.05); /* Dark border */ + padding: 8px 8px; + width: 100%; + overflow: hidden; + position: relative; + z-index: 1; + box-sizing: border-box; + box-shadow: none; /* Removed inset shadow */ + border-radius: 3px; + display: flex; + flex-wrap: wrap; + background-color: rgba(15, 23, 42, 0.6) !important; /* Slate-900 transparent */ +} +.ts-wrapper.multi.has-items .ts-control { + padding: calc(8px - 2px - 1px) 8px calc(8px - 2px - 3px - 1px); +} +.full .ts-control { + background-color: #0f172a; /* Slate-900 */ +} +.disabled .ts-control, .disabled .ts-control * { + cursor: default !important; +} +.focus .ts-control { + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); /* Blue halo */ + border-color: #3b82f6; +} +.ts-control > * { + vertical-align: baseline; + display: inline-block; +} +.ts-wrapper.multi .ts-control > div { + cursor: pointer; + margin: 0 3px 3px 0; + padding: 2px 6px; + background: #2563eb; /* Blue-600 */ + color: #fff; + border: 1px solid #1d4ed8; +} +.ts-wrapper.multi .ts-control > div.active { + background: #1e40af; /* Deeper blue */ + color: #fff; + border: 1px solid #1e3a8a; +} +.ts-wrapper.multi.disabled .ts-control > div, .ts-wrapper.multi.disabled .ts-control > div.active { + color: #475569; + background: #1e293b; + border: 1px solid #334155; +} +.ts-control > input { + flex: 1 1 auto; + min-width: 7rem; + display: inline-block !important; + padding: 0 !important; + min-height: 0 !important; + max-height: none !important; + max-width: 100% !important; + margin: 0 !important; + text-indent: 0 !important; + border: 0 none !important; + background: none !important; + line-height: inherit !important; + -webkit-user-select: auto !important; + -moz-user-select: auto !important; + -ms-user-select: auto !important; + user-select: auto !important; + box-shadow: none !important; + color: #f8fafc !important; /* Slate-50 text */ +} +.ts-control > input::-ms-clear { + display: none; +} +.ts-control > input:focus { + outline: none !important; +} +.has-items .ts-control > input { + margin: 0 4px !important; +} +.ts-control.rtl { + text-align: right; +} +.ts-control.rtl.single .ts-control:after { + left: 15px; + right: auto; +} +.ts-control.rtl .ts-control > input { + margin: 0 4px 0 -2px !important; +} +.disabled .ts-control { + opacity: 0.5; + background-color: #0f172a; +} +.input-hidden .ts-control > input { + opacity: 0; + position: absolute; + left: -10000px; +} + +.ts-dropdown { + position: absolute; + top: 100%; + left: 0; + width: 100%; + z-index: 10; + border: 1px solid rgba(255, 255, 255, 0.1); + background: #0f172a; /* Dark background */ + margin: 0.25rem 0 0; + border-top: 0 none; + box-sizing: border-box; + box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5); + border-radius: 0 0 3px 3px; + color: #f8fafc; +} +.ts-dropdown [data-selectable] { + cursor: pointer; + overflow: hidden; +} +.ts-dropdown [data-selectable] .highlight { + background: rgba(59, 130, 246, 0.3); /* Blue highlight */ + border-radius: 1px; +} +.ts-dropdown .option, +.ts-dropdown .optgroup-header, +.ts-dropdown .no-results, +.ts-dropdown .create { + padding: 5px 8px; +} +.ts-dropdown .option, .ts-dropdown [data-disabled], .ts-dropdown [data-disabled] [data-selectable].option { + cursor: inherit; + opacity: 0.5; +} +.ts-dropdown [data-selectable].option { + opacity: 1; + cursor: pointer; +} +.ts-dropdown .optgroup:first-child .optgroup-header { + border-top: 0 none; +} +.ts-dropdown .optgroup-header { + color: #94a3b8; /* Slate-400 */ + background: #0f172a; + cursor: default; +} +.ts-dropdown .active { + background-color: #1e293b; /* Slate-800 */ + color: #3b82f6; /* Blue-500 */ +} +.ts-dropdown .active.create { + color: #3b82f6; +} +.ts-dropdown .create { + color: rgba(248, 248, 248, 0.5); +} +.ts-dropdown .spinner { + display: inline-block; + width: 30px; + height: 30px; + margin: 5px 8px; +} +.ts-dropdown .spinner::after { + content: " "; + display: block; + width: 24px; + height: 24px; + margin: 3px; + border-radius: 50%; + border: 5px solid #1e293b; + border-color: #3b82f6 transparent #3b82f6 transparent; + animation: lds-dual-ring 1.2s linear infinite; +} +@keyframes lds-dual-ring { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.ts-dropdown-content { + overflow: hidden auto; + max-height: 200px; + scroll-behavior: smooth; +} + +.ts-wrapper.plugin-drag_drop .ts-dragging { + color: transparent !important; +} +.ts-wrapper.plugin-drag_drop .ts-dragging > * { + visibility: hidden !important; +} + +.plugin-checkbox_options:not(.rtl) .option input { + margin-right: 0.5rem; +} + +.plugin-checkbox_options.rtl .option input { + margin-left: 0.5rem; +} + +/* stylelint-disable function-name-case */ +.plugin-clear_button { + --ts-pr-clear-button: 1em; +} +.plugin-clear_button .clear-button { + opacity: 0; + position: absolute; + top: 50%; + transform: translateY(-50%); + right: calc(8px - 6px); + margin-right: 0 !important; + background: transparent !important; + transition: opacity 0.5s; + cursor: pointer; + color: #ef4444; /* Red for clear */ +} +.plugin-clear_button.focus.has-items .clear-button, .plugin-clear_button:not(.disabled):hover.has-items .clear-button { + opacity: 1; +} + +.ts-wrapper .dropdown-header { + position: relative; + padding: 10px 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + background: #1e293b; + border-radius: 3px 3px 0 0; +} +.ts-wrapper .dropdown-header-close { + position: absolute; + right: 8px; + top: 50%; + color: #f8fafc; + opacity: 0.4; + margin-top: -12px; + line-height: 20px; + font-size: 20px !important; +} +.ts-wrapper .dropdown-header-close:hover { + color: #fff; +} + +.plugin-dropdown_input.focus.dropdown-active .ts-control { + box-shadow: none; + border: 1px solid #3b82f6; +} +.plugin-dropdown_input .dropdown-input { + border: 1px solid rgba(255, 255, 255, 0.1); + border-width: 0 0 1px; + display: block; + padding: 8px 8px; + box-shadow: none; + width: 100%; + background: #0f172a; + color: #fff; +} + +.ts-dropdown.plugin-optgroup_columns .optgroup { + border-right: 1px solid rgba(255, 255, 255, 0.05); + border-top: 0 none; + flex-grow: 1; + flex-basis: 0; + min-width: 0; +} + +.ts-wrapper.plugin-remove_button .item .remove { + color: inherit; + text-decoration: none; + vertical-align: middle; + display: inline-block; + padding: 0 6px; + border-radius: 0 2px 2px 0; + box-sizing: border-box; +} +.ts-wrapper.plugin-remove_button .item .remove:hover { + background: rgba(255, 255, 255, 0.1); +} + +.ts-wrapper { + position: relative; +} + +.ts-dropdown, +.ts-control, +.ts-control input { + color: #f8fafc; /* Global text color */ + font-family: inherit; + font-size: 13px; + line-height: 18px; +} + +.ts-control, +.ts-wrapper.single.input-active .ts-control { + background: rgba(15, 23, 42, 0.6) !important; + cursor: text; +} + +.ts-wrapper.single .ts-control::after { + border-color: #64748b transparent transparent transparent; /* Slate-500 arrow */ +} + +.ts-wrapper.single.dropdown-active .ts-control::after { + border-color: transparent transparent #3b82f6 transparent; +} + +.ts-wrapper.multi .ts-control [data-value] { + text-shadow: none; + border-radius: 3px; + background-color: #3b82f6; + background-image: none; + box-shadow: none; +} +.ts-wrapper.multi.disabled .ts-control [data-value] { + color: #475569; + background: #1e293b; +} + +.ts-wrapper.single .ts-control { + box-shadow: none; + background-color: rgba(15, 23, 42, 0.6) !important; + background-image: none; +} + +.ts-wrapper.single .ts-control, .ts-dropdown.single { + border-color: rgba(255, 255, 255, 0.05); +} + +.ts-dropdown .optgroup { + border-top: 1px solid rgba(255, 255, 255, 0.05); +} diff --git a/assets/tom.scss b/assets/tom.scss new file mode 100644 index 0000000..e69de29 diff --git a/migrations/Version20260116143611.php b/migrations/Version20260116143611.php new file mode 100644 index 0000000..1736b77 --- /dev/null +++ b/migrations/Version20260116143611.php @@ -0,0 +1,35 @@ +addSql('CREATE TABLE devis_line (id SERIAL NOT NULL, devi_id INT DEFAULT NULL, pos INT NOT NULL, title VARCHAR(255) NOT NULL, content TEXT NOT NULL, price_ht DOUBLE PRECISION NOT NULL, PRIMARY KEY(id))'); + $this->addSql('CREATE INDEX IDX_9EC6D529131098A5 ON devis_line (devi_id)'); + $this->addSql('ALTER TABLE devis_line ADD CONSTRAINT FK_9EC6D529131098A5 FOREIGN KEY (devi_id) REFERENCES devis (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE devis_line DROP CONSTRAINT FK_9EC6D529131098A5'); + $this->addSql('DROP TABLE devis_line'); + } +} diff --git a/src/Controller/Dashboard/DevisController.php b/src/Controller/Dashboard/DevisController.php index 1c69fe8..9335705 100644 --- a/src/Controller/Dashboard/DevisController.php +++ b/src/Controller/Dashboard/DevisController.php @@ -2,6 +2,8 @@ namespace App\Controller\Dashboard; +use App\Entity\Devis; +use App\Form\NewDevisType; use App\Logger\AppLogger; use App\Repository\AccountRepository; use App\Repository\DevisRepository; @@ -26,9 +28,21 @@ class DevisController extends AbstractController ]); } #[Route(path: '/crm/devis/add', name: 'app_crm_devis_add', options: ['sitemap' => false], methods: ['GET'])] - public function devisAdd(AccountRepository $accountRepository, AppLogger $appLogger): Response + public function devisAdd(DevisRepository $devisRepository, AppLogger $appLogger): Response { + $devisNumber ="DEVIS-".sprintf('%05d',$devisRepository->count()+1); + $appLogger->record('VIEW', 'Consultation de la création d\'un devis'); + $devis = new Devis(); + $devis->setNum($devisNumber); + $devis->setState("draft"); + $devis->setCreateA(new \DateTimeImmutable()); + $devis->setUpdateAt(new \DateTimeImmutable()); + + $form = $this->createForm(NewDevisType::class,$devis); + return $this->render('dashboard/devis/add.twig',[ + 'form' => $form->createView(), + ]); } } diff --git a/src/Entity/Devis.php b/src/Entity/Devis.php index cbc29e8..4ee6ca2 100644 --- a/src/Entity/Devis.php +++ b/src/Entity/Devis.php @@ -3,6 +3,8 @@ namespace App\Entity; use App\Repository\DevisRepository; +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; @@ -63,6 +65,17 @@ class Devis #[ORM\Column(length: 255, nullable: true)] private ?string $signatureId = null; + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: DevisLine::class, mappedBy: 'devi')] + private Collection $devisLines; + + public function __construct() + { + $this->devisLines = new ArrayCollection(); + } + public function getId(): ?int { return $this->id; @@ -352,4 +365,34 @@ class Devis return $this; } + /** + * @return Collection + */ + public function getDevisLines(): Collection + { + return $this->devisLines; + } + + public function addDevisLine(DevisLine $devisLine): static + { + if (!$this->devisLines->contains($devisLine)) { + $this->devisLines->add($devisLine); + $devisLine->setDevi($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->getDevi() === $this) { + $devisLine->setDevi(null); + } + } + + return $this; + } + } diff --git a/src/Entity/DevisLine.php b/src/Entity/DevisLine.php new file mode 100644 index 0000000..7e11526 --- /dev/null +++ b/src/Entity/DevisLine.php @@ -0,0 +1,96 @@ +id; + } + + public function getDevi(): ?Devis + { + return $this->devi; + } + + public function setDevi(?Devis $devi): static + { + $this->devi = $devi; + + return $this; + } + + public function getPos(): ?int + { + return $this->pos; + } + + public function setPos(int $pos): static + { + $this->pos = $pos; + + 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 + { + return $this->priceHt; + } + + public function setPriceHt(float $priceHt): static + { + $this->priceHt = $priceHt; + + return $this; + } +} diff --git a/src/Form/NewDevisType.php b/src/Form/NewDevisType.php new file mode 100644 index 0000000..60c9fd2 --- /dev/null +++ b/src/Form/NewDevisType.php @@ -0,0 +1,56 @@ +add('num', TextType::class, [ + 'label' => 'Numéro du devis', + 'required' => true, + 'attr' => [ + 'readonly' => true, + ] + ]) + ->add('createA', DateType::class, [ + 'label' => 'Date du devis', + 'required' => true, + 'widget' => 'single_text', // Recommandé pour un meilleur rendu HTML5 + 'attr' => [ + 'readonly' => true, + ] + ]) + ->add('customer', EntityType::class, [ + 'label' => 'Client', + 'required' => true, + 'class' => Customer::class, + // Utilisation d'une fonction anonyme pour concaténer Nom et Prénom + 'choice_label' => function (Customer $customer) { + return sprintf('%s - %s', + strtoupper($customer->getSurname()), // Nom en majuscules + $customer->getName() // Prénom + ); + }, + 'placeholder' => 'Sélectionnez un client...', + 'attr' => [ + 'class' => 'select2' // Optionnel : si tu utilises Select2 ou TomSelect + ] + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefault('data_class', Devis::class); + } +} diff --git a/src/Repository/DevisLineRepository.php b/src/Repository/DevisLineRepository.php new file mode 100644 index 0000000..fe09ed9 --- /dev/null +++ b/src/Repository/DevisLineRepository.php @@ -0,0 +1,43 @@ + + */ +class DevisLineRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, DevisLine::class); + } + + // /** + // * @return DevisLine[] Returns an array of DevisLine objects + // */ + // public function findByExampleField($value): array + // { + // return $this->createQueryBuilder('d') + // ->andWhere('d.exampleField = :val') + // ->setParameter('val', $value) + // ->orderBy('d.id', 'ASC') + // ->setMaxResults(10) + // ->getQuery() + // ->getResult() + // ; + // } + + // public function findOneBySomeField($value): ?DevisLine + // { + // return $this->createQueryBuilder('d') + // ->andWhere('d.exampleField = :val') + // ->setParameter('val', $value) + // ->getQuery() + // ->getOneOrNullResult() + // ; + // } +} diff --git a/templates/dashboard/devis/add.twig b/templates/dashboard/devis/add.twig new file mode 100644 index 0000000..a04a8d7 --- /dev/null +++ b/templates/dashboard/devis/add.twig @@ -0,0 +1,107 @@ +{% extends 'dashboard/base.twig' %} + +{% block title %}Création Devis{% endblock %} +{% block title_header %}Nouveau Devis{% endblock %} + +{% block actions %} + + + + + Annuler + +{% endblock %} + +{% block body %} +
+ + {{ form_start(form) }} + +
+ + {# Décoration de fond #} +
+ + {# Header #} +
+
+
+ + + +
+
+

Création d'un devis

+

Configuration des informations d'entête

+
+
+
+ +
+ {# GRILLE À 3 COLONNES #} +
+ + {# COLONNE 1 : NUMÉRO #} +
+ {{ form_label(form.num) }} +
+ {{ form_widget(form.num) }} +
+
+ + {# COLONNE 2 : DATE #} +
+ {{ form_label(form.createA) }} +
+ {{ form_widget(form.createA) }} +
+
+ + {# COLONNE 3 : CLIENT #} +
+ {{ form_label(form.customer) }} +
+ {{ form_widget(form.customer) }} +
+
+ +
+ + {# BOUTON LARGEUR TOTALE #} +
+ +
+
+
+ + {{ form_end(form) }} + +
+ + +{% endblock %}