feat(Devis): Ajoute l'entité DevisLine et le formulaire de création de devis non terminer

Ajoute l'entité DevisLine, le formulaire NewDevisType et la route pour la création de devis.
```
This commit is contained in:
Serreau Jovann
2026-01-16 16:00:00 +01:00
parent c952f2487a
commit 84180d9561
10 changed files with 749 additions and 5 deletions

View File

@@ -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 `<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');

View File

@@ -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);
}

0
assets/tom.scss Normal file
View File

View File

@@ -0,0 +1,35 @@
<?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 Version20260116143611 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('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');
}
}

View File

@@ -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(),
]);
}
}

View File

@@ -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<int, DevisLine>
*/
#[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<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->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;
}
}

96
src/Entity/DevisLine.php Normal file
View File

@@ -0,0 +1,96 @@
<?php
namespace App\Entity;
use App\Repository\DevisLineRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: DevisLineRepository::class)]
class DevisLine
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\ManyToOne(inversedBy: 'devisLines')]
private ?Devis $devi = null;
#[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;
public function getId(): ?int
{
return $this->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;
}
}

56
src/Form/NewDevisType.php Normal file
View File

@@ -0,0 +1,56 @@
<?php
namespace App\Form;
use App\Entity\Customer;
use App\Entity\Devis;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\DateType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class NewDevisType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->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);
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Repository;
use App\Entity\DevisLine;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<DevisLine>
*/
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()
// ;
// }
}

View File

@@ -0,0 +1,107 @@
{% extends 'dashboard/base.twig' %}
{% block title %}Création Devis{% endblock %}
{% block title_header %}Nouveau <span class="text-blue-500">Devis</span>{% endblock %}
{% block actions %}
<a href="{{ path('app_crm_devis') }}" class="flex items-center px-4 py-2 text-[10px] font-black text-slate-400 hover:text-white uppercase tracking-widest transition-all group">
<svg class="w-4 h-4 mr-2 transform group-hover:-translate-x-1 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
Annuler
</a>
{% endblock %}
{% block body %}
<div class="w-full animate-in fade-in zoom-in-95 duration-500">
{{ form_start(form) }}
<div class="backdrop-blur-xl bg-[#1e293b]/40 border border-white/5 rounded-[3rem] p-10 shadow-2xl relative overflow-hidden w-full">
{# Décoration de fond #}
<div class="absolute top-0 right-0 -mr-16 -mt-16 w-96 h-96 bg-blue-600/5 rounded-full blur-3xl pointer-events-none"></div>
{# Header #}
<div class="relative mb-12">
<div class="flex items-center space-x-6">
<div class="flex items-center justify-center w-12 h-12 rounded-2xl bg-blue-600 shadow-lg shadow-blue-600/30 text-white">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
</div>
<div>
<h3 class="text-2xl font-black text-white tracking-tight">Création d'un devis</h3>
<p class="text-[9px] text-slate-500 uppercase tracking-[0.3em] font-bold mt-1">Configuration des informations d'entête</p>
</div>
</div>
</div>
<div class="relative space-y-10">
{# GRILLE À 3 COLONNES #}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
{# COLONNE 1 : NUMÉRO #}
<div class="space-y-3">
{{ form_label(form.num) }}
<div class="relative">
{{ form_widget(form.num) }}
</div>
</div>
{# COLONNE 2 : DATE #}
<div class="space-y-3">
{{ form_label(form.createA) }}
<div class="relative">
{{ form_widget(form.createA) }}
</div>
</div>
{# COLONNE 3 : CLIENT #}
<div class="space-y-3">
{{ form_label(form.customer) }}
<div class="relative">
{{ form_widget(form.customer) }}
</div>
</div>
</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">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 5l7 7m0 0l-7 7m7-7H3"/>
</svg>
</button>
</div>
</div>
</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 %}