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:
@@ -56,7 +56,26 @@ class ContratController extends AbstractController
|
|||||||
return $this->redirectToRoute('app_admin_contrats_index');
|
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 = new Contrat($email, $raisonSociale, $type);
|
||||||
|
$contrat->setServices($services);
|
||||||
$this->em->persist($contrat);
|
$this->em->persist($contrat);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,20 @@ class Contrat
|
|||||||
self::TYPE_MIGRATION_SITECONSEIL => 'Contrat de Service - Migration SARL SITECONSEIL',
|
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\Id]
|
||||||
#[ORM\GeneratedValue]
|
#[ORM\GeneratedValue]
|
||||||
#[ORM\Column]
|
#[ORM\Column]
|
||||||
@@ -35,6 +49,10 @@ class Contrat
|
|||||||
#[ORM\Column(length: 50)]
|
#[ORM\Column(length: 50)]
|
||||||
private string $type;
|
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'])]
|
#[ORM\Column(length: 20, options: ['default' => 'draft'])]
|
||||||
private string $state = self::STATE_DRAFT;
|
private string $state = self::STATE_DRAFT;
|
||||||
|
|
||||||
@@ -152,6 +170,30 @@ class Contrat
|
|||||||
return $this;
|
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
|
public function getSubmissionId(): ?string
|
||||||
{
|
{
|
||||||
return $this->submissionId;
|
return $this->submissionId;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class ContratMigrationSiteconseilPdf extends Fpdi
|
|||||||
|
|
||||||
$this->writeHeader();
|
$this->writeHeader();
|
||||||
$this->writePreambule();
|
$this->writePreambule();
|
||||||
|
$this->writeServicesTable();
|
||||||
$this->writeArticles();
|
$this->writeArticles();
|
||||||
$this->writeSignatures();
|
$this->writeSignatures();
|
||||||
}
|
}
|
||||||
@@ -166,6 +167,61 @@ class ContratMigrationSiteconseilPdf extends Fpdi
|
|||||||
$this->Ln(5);
|
$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 */
|
/** @codeCoverageIgnore */
|
||||||
private function writeArticles(): void
|
private function writeArticles(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
|
|
||||||
{# Modal creation #}
|
{# Modal creation #}
|
||||||
<div id="modal-contrat" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<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>
|
<h2 class="text-lg font-bold uppercase mb-4">Nouveau contrat</h2>
|
||||||
<form method="post" action="{{ path('app_admin_contrats_create') }}">
|
<form method="post" action="{{ path('app_admin_contrats_create') }}">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
|
||||||
@@ -82,6 +82,33 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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="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>
|
<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>
|
</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 %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -54,6 +54,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</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, ',', ' ') }} €</td>
|
||||||
|
<td class="px-4 py-2 text-right text-xs font-mono font-bold">{{ (s.priceHt * s.quantity)|number_format(2, ',', ' ') }} €</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, ',', ' ') }} €</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# Actions #}
|
{# Actions #}
|
||||||
<div class="flex flex-wrap gap-2 mb-6">
|
<div class="flex flex-wrap gap-2 mb-6">
|
||||||
{% if contrat.state == 'draft' %}
|
{% if contrat.state == 'draft' %}
|
||||||
|
|||||||
Reference in New Issue
Block a user