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 `
`;
+ },
+ 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 %}