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

@@ -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' %}