feat: services inclus dans les contrats avec quantites et prix

Entite Contrat:
- Champ JSON services (service, quantity, priceHt)
- Catalogue SERVICE_CATALOG avec 11 services et prix
- getTotalHt() calcule le total

Controller:
- Formulaire accepte service_key[] et service_qty[] en repeater
- Associe les prix depuis le catalogue

Template index (modal creation):
- Repeater dynamique : select service + champ quantite + bouton X
- Bouton "+ Ajouter un service"
- JS avec nonce CSP

Template show:
- Tableau services inclus (service, quantite, prix unitaire, sous-total)
- Total HT en pied de tableau

PDF ContratMigrationSiteconseilPdf:
- Section "SERVICES INCLUS DANS LE CONTRAT" avec tableau
  (service, qte, prix unitaire HT, sous-total HT)
- Total HT en bas

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-09 15:20:21 +02:00
parent d9073944e0
commit 22bb3c71be
5 changed files with 226 additions and 1 deletions

View File

@@ -56,7 +56,26 @@ class ContratController extends AbstractController
return $this->redirectToRoute('app_admin_contrats_index');
}
// Recuperer les services
$serviceKeys = $request->request->all('service_key');
$serviceQtys = $request->request->all('service_qty');
$services = [];
foreach ($serviceKeys as $i => $key) {
if ('' !== $key && isset(Contrat::SERVICE_CATALOG[$key])) {
$qty = (int) ($serviceQtys[$i] ?? 1);
if ($qty > 0) {
$services[] = [
'service' => $key,
'quantity' => $qty,
'priceHt' => Contrat::SERVICE_CATALOG[$key]['price'],
];
}
}
}
$contrat = new Contrat($email, $raisonSociale, $type);
$contrat->setServices($services);
$this->em->persist($contrat);
$this->em->flush();

View File

@@ -21,6 +21,20 @@ class Contrat
self::TYPE_MIGRATION_SITECONSEIL => 'Contrat de Service - Migration SARL SITECONSEIL',
];
public const SERVICE_CATALOG = [
'esite_vitrine' => ['label' => 'E-Site Vitrine', 'price' => '100.00', 'unit' => '/mois'],
'esite_ecommerce' => ['label' => 'E-Site E-Commerce', 'price' => '150.00', 'unit' => '/mois'],
'esite_hors_cms' => ['label' => 'Site hors CMS Esy-Web (hebergement seul)', 'price' => '100.00', 'unit' => '/mois'],
'email_3go' => ['label' => 'E-Mail 3 Go', 'price' => '1.00', 'unit' => '/mois'],
'email_50go' => ['label' => 'E-Mail 50 Go', 'price' => '5.00', 'unit' => '/mois'],
'ndd_gestion' => ['label' => 'Nom de domaine - Gestion', 'price' => '30.00', 'unit' => '/an'],
'ndd_renouvellement' => ['label' => 'Nom de domaine - Renouvellement', 'price' => '20.00', 'unit' => '/an'],
'eprotect' => ['label' => 'E-Protect Pro', 'price' => '60.00', 'unit' => '/trimestre'],
'ecalendar' => ['label' => 'E-Calendar', 'price' => '30.00', 'unit' => '/mois'],
'echat' => ['label' => 'E-Chat', 'price' => '15.00', 'unit' => '/mois'],
'emailer' => ['label' => 'E-Mailer', 'price' => '30.00', 'unit' => '/mois'],
];
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
@@ -35,6 +49,10 @@ class Contrat
#[ORM\Column(length: 50)]
private string $type;
/** @var list<array{service: string, quantity: int, priceHt: string}> */
#[ORM\Column(type: 'json')]
private array $services = [];
#[ORM\Column(length: 20, options: ['default' => 'draft'])]
private string $state = self::STATE_DRAFT;
@@ -152,6 +170,30 @@ class Contrat
return $this;
}
/** @return list<array{service: string, quantity: int, priceHt: string}> */
public function getServices(): array
{
return $this->services;
}
/** @param list<array{service: string, quantity: int, priceHt: string}> $services */
public function setServices(array $services): static
{
$this->services = $services;
return $this;
}
public function getTotalHt(): float
{
$total = 0.0;
foreach ($this->services as $s) {
$total += (float) $s['priceHt'] * $s['quantity'];
}
return $total;
}
public function getSubmissionId(): ?string
{
return $this->submissionId;

View File

@@ -24,6 +24,7 @@ class ContratMigrationSiteconseilPdf extends Fpdi
$this->writeHeader();
$this->writePreambule();
$this->writeServicesTable();
$this->writeArticles();
$this->writeSignatures();
}
@@ -166,6 +167,61 @@ class ContratMigrationSiteconseilPdf extends Fpdi
$this->Ln(5);
}
/** @codeCoverageIgnore */
private function writeServicesTable(): void
{
$services = $this->contrat->getServices();
if ([] === $services) {
return;
}
if ($this->GetY() + 30 > $this->GetPageHeight() - 25) {
$this->AddPage();
}
$this->SetFont('Arial', 'B', 11);
$this->SetFillColor(250, 191, 4);
$this->Cell(0, 8, $this->enc(' SERVICES INCLUS DANS LE CONTRAT'), 0, 1, 'L', true);
$this->Ln(4);
// Header tableau
$this->SetFont('Arial', 'B', 9);
$this->SetFillColor(35, 35, 35);
$this->SetTextColor(255, 255, 255);
$this->Cell(80, 7, $this->enc(' Service'), 1, 0, 'L', true);
$this->Cell(20, 7, $this->enc('Qte'), 1, 0, 'C', true);
$this->Cell(40, 7, $this->enc('Prix unitaire HT'), 1, 0, 'R', true);
$this->Cell(40, 7, $this->enc('Sous-total HT'), 1, 1, 'R', true);
$this->SetTextColor(0, 0, 0);
$this->SetFont('Arial', '', 9);
$fill = false;
$catalog = \App\Entity\Contrat::SERVICE_CATALOG;
foreach ($services as $s) {
$this->SetFillColor(245, 245, 240);
$label = $catalog[$s['service']]['label'] ?? $s['service'];
$unit = $catalog[$s['service']]['unit'] ?? '';
$qty = $s['quantity'];
$price = (float) $s['priceHt'];
$subtotal = $price * $qty;
$this->Cell(80, 6, $this->enc(' '.$label), 'B', 0, 'L', $fill);
$this->Cell(20, 6, (string) $qty, 'B', 0, 'C', $fill);
$this->Cell(40, 6, number_format($price, 2, ',', ' ').' '.EURO.' '.$unit, 'B', 0, 'R', $fill);
$this->Cell(40, 6, number_format($subtotal, 2, ',', ' ').' '.EURO, 'B', 1, 'R', $fill);
$fill = !$fill;
}
// Total
$this->SetFont('Arial', 'B', 10);
$this->Cell(140, 8, $this->enc('TOTAL HT'), 0, 0, 'R');
$this->Cell(40, 8, number_format($this->contrat->getTotalHt(), 2, ',', ' ').' '.EURO, 0, 1, 'R');
$this->Ln(5);
}
/** @codeCoverageIgnore */
private function writeArticles(): void
{

View File

@@ -62,7 +62,7 @@
{# Modal creation #}
<div id="modal-contrat" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="glass-heavy p-6 w-full max-w-lg">
<div class="glass-heavy p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h2 class="text-lg font-bold uppercase mb-4">Nouveau contrat</h2>
<form method="post" action="{{ path('app_admin_contrats_create') }}">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
@@ -82,6 +82,33 @@
</select>
</div>
</div>
{# Services #}
<div class="mb-4">
<label class="block text-[9px] font-bold uppercase tracking-wider text-gray-400 mb-2">Services inclus dans le contrat</label>
<div id="services-container" class="space-y-2">
<div class="flex gap-2 items-center">
<select name="service_key[]" class="input-glass flex-1 px-3 py-2 text-xs font-bold">
<option value="">— Selectionner un service —</option>
<option value="esite_vitrine">E-Site Vitrine (100 EUR/mois)</option>
<option value="esite_ecommerce">E-Site E-Commerce (150 EUR/mois)</option>
<option value="esite_hors_cms">Site hors CMS Esy-Web (100 EUR/mois)</option>
<option value="email_3go">E-Mail 3 Go (1 EUR/mois)</option>
<option value="email_50go">E-Mail 50 Go (5 EUR/mois)</option>
<option value="ndd_gestion">Nom de domaine - Gestion (30 EUR/an)</option>
<option value="ndd_renouvellement">Nom de domaine - Renouvellement (20 EUR/an)</option>
<option value="eprotect">E-Protect Pro (60 EUR/trimestre)</option>
<option value="ecalendar">E-Calendar (30 EUR/mois)</option>
<option value="echat">E-Chat (15 EUR/mois)</option>
<option value="emailer">E-Mailer (30 EUR/mois)</option>
</select>
<input type="number" name="service_qty[]" value="1" min="1" class="input-glass w-16 px-2 py-2 text-xs font-bold text-center">
<button type="button" class="remove-service px-2 py-1 bg-red-500/20 text-red-700 font-bold text-xs hidden">X</button>
</div>
</div>
<button type="button" id="add-service-btn" class="mt-2 px-3 py-1 glass text-xs font-bold uppercase tracking-wider hover:bg-gray-900 hover:text-white transition-all">+ Ajouter un service</button>
</div>
<div class="flex justify-end gap-2">
<button type="button" data-modal-close="modal-contrat" class="px-4 py-2 glass font-bold uppercase text-[10px] tracking-widest">Annuler</button>
<button type="submit" class="btn-gold px-4 py-2 font-bold uppercase text-[10px] tracking-wider text-gray-900">Creer</button>
@@ -90,4 +117,37 @@
</div>
</div>
</div>
<script nonce="{{ csp_nonce('script') }}">
(function() {
var container = document.getElementById('services-container');
var addBtn = document.getElementById('add-service-btn');
if (!container || !addBtn) return;
var optionsHtml = container.querySelector('select').innerHTML;
function updateRemove() {
var rows = container.querySelectorAll('.flex');
rows.forEach(function(r) {
var btn = r.querySelector('.remove-service');
if (btn) btn.classList.toggle('hidden', rows.length <= 1);
});
}
addBtn.addEventListener('click', function() {
var div = document.createElement('div');
div.className = 'flex gap-2 items-center';
div.innerHTML = '<select name="service_key[]" class="input-glass flex-1 px-3 py-2 text-xs font-bold">' + optionsHtml + '</select>'
+ '<input type="number" name="service_qty[]" value="1" min="1" class="input-glass w-16 px-2 py-2 text-xs font-bold text-center">'
+ '<button type="button" class="remove-service px-2 py-1 bg-red-500/20 text-red-700 font-bold text-xs">X</button>';
container.appendChild(div);
div.querySelector('.remove-service').addEventListener('click', function() { div.remove(); updateRemove(); });
updateRemove();
});
container.addEventListener('click', function(e) {
if (e.target.classList.contains('remove-service')) { e.target.parentElement.remove(); updateRemove(); }
});
})();
</script>
{% endblock %}

View File

@@ -54,6 +54,54 @@
</div>
</div>
{# Services #}
{% if contrat.services|length > 0 %}
<div class="glass p-5 mb-6">
<h2 class="text-sm font-bold uppercase tracking-wider mb-3">Services inclus</h2>
<div class="glass overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="glass-dark text-white">
<th class="px-4 py-2 text-left font-bold uppercase text-[10px] tracking-widest">Service</th>
<th class="px-4 py-2 text-center font-bold uppercase text-[10px] tracking-widest">Quantite</th>
<th class="px-4 py-2 text-right font-bold uppercase text-[10px] tracking-widest">Prix unitaire HT</th>
<th class="px-4 py-2 text-right font-bold uppercase text-[10px] tracking-widest">Sous-total HT</th>
</tr>
</thead>
<tbody>
{% set catalogLabels = {
'esite_vitrine': 'E-Site Vitrine',
'esite_ecommerce': 'E-Site E-Commerce',
'esite_hors_cms': 'Site hors CMS Esy-Web',
'email_3go': 'E-Mail 3 Go',
'email_50go': 'E-Mail 50 Go',
'ndd_gestion': 'Nom de domaine - Gestion',
'ndd_renouvellement': 'Nom de domaine - Renouvellement',
'eprotect': 'E-Protect Pro',
'ecalendar': 'E-Calendar',
'echat': 'E-Chat',
'emailer': 'E-Mailer'
} %}
{% for s in contrat.services %}
<tr class="border-b border-white/20 {{ loop.index is odd ? 'bg-white/30' : '' }}">
<td class="px-4 py-2 font-bold text-xs">{{ catalogLabels[s.service] ?? s.service }}</td>
<td class="px-4 py-2 text-center text-xs">{{ s.quantity }}</td>
<td class="px-4 py-2 text-right text-xs font-mono">{{ s.priceHt|number_format(2, ',', ' ') }} &euro;</td>
<td class="px-4 py-2 text-right text-xs font-mono font-bold">{{ (s.priceHt * s.quantity)|number_format(2, ',', ' ') }} &euro;</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr class="glass-dark text-white">
<td colspan="3" class="px-4 py-2 text-right font-bold uppercase text-[10px] tracking-widest">Total HT</td>
<td class="px-4 py-2 text-right font-mono font-bold">{{ contrat.totalHt|number_format(2, ',', ' ') }} &euro;</td>
</tr>
</tfoot>
</table>
</div>
</div>
{% endif %}
{# Actions #}
<div class="flex flex-wrap gap-2 mb-6">
{% if contrat.state == 'draft' %}