feat: gestion complete Devis + Avis de paiement + DocuSeal signature + mails

Devis :
- Entity DevisLine (pos, title, description, priceHt) liee a Devis (OneToMany cascade/orphanRemoval)
- Champs ajoutes sur Devis : customer (ManyToOne), submissionId, state machine (created/send/accepted/refused/cancel), raisonMessage, totaux HT/TVA/TTC, updatedAt, setUpdatedAt public
- Relation Devis <-> Advert changee de ManyToOne a OneToOne nullable
- Vich Attribute (migration Annotation -> Attribute) pour unsignedPdf/signedPdf/auditPdf
- DevisController CRUD complet : create (form repeater lignes + boutons rapides TarificationService), edit, cancel (libere OrderNumber), generate-pdf, send, resend, create-advert, events
- DevisPdf (FPDF/FPDI) : header legacy (logo, num, date, client), body lignes, summary totaux, footer SITECONSEIL + pagination, champ signature DocuSeal sur page devis + derniere page CGV
- OrderNumberService : preview() et generate() reutilisent les OrderNumber non utilises (isUsed=false) en priorite
- OrderNumber::markAsUnused() ajoute

DocuSeal integration devis :
- DocuSealService : sendDevisForSignature (avec completed_redirect_url), resendDevisSignature (archive ancienne submission), getSubmitterSlug, downloadSignedDevis (sauvegarde via Vich UploadedFile test=true)
- WebhookDocuSealController : dispatch par doc_type devis/attestation, handleDevisEvent (form.completed -> STATE_ACCEPTED + download PDF signe/audit, form.declined -> STATE_REFUSED + raison)
- DocusealEvent entity pour tracer form.viewed/started/completed/declined en temps reel
- Page evenements admin /admin/devis/{id}/events avec badges et payload JSON

Signature client :
- DevisProcessController : page publique /devis/process/{id}/{hmac} securisee par HMAC, boutons Signer (redirect DocuSeal) / Refuser (motif optionnel)
- Pages confirmation : signed.html.twig (merci + recap) et refused.html.twig (confirmation refus + motif)
- Nelmio whitelist : signature.esy-web.dev + signature.siteconseil.fr

Avis de paiement :
- Entity AdvertLine (pos, title, description, priceHt) liee a Advert
- Advert refactorise : customer, state, totaux, raisonMessage, submissionId, advertFile (Vich mapping advert_pdf), lines collection, updatedAt
- AdvertController : generate-pdf, send (mail + PJ + lien paiement), resend (rappel), cancel (delie devis, libere OrderNumber), search Meilisearch
- AdvertPdf (FPDF/FPDI) : QR code Endroid pointant vers /order/{numOrder}, texte "Scannez pour payer"
- OrderPaymentController : page publique /order/{numOrder} avec detail prestations, totaux, options paiement (placeholder)
- Creation auto depuis devis signe : copie client, totaux, lignes, meme OrderNumber

Meilisearch :
- Index customer_devis et customer_advert avec searchable (numOrder, customerName, customerEmail, state) et filterable (customerId, state)
- CRUD indexation sur chaque action (create, edit, send, cancel, create-advert)
- Recherche AJAX dans tabs Devis et Avis avec debounce + dropdown glassmorphism
- Sync admin : boutons syncDevis / syncAdverts + compteurs dans /admin/sync

Emails :
- MailerService : VCF auto (fiche contact SARL SITECONSEIL) en PJ sur tous les mails, bloc HTML pieces jointes injecte automatiquement (exclut .asc/.p7z/smime) avec icone trombone + taille fichier
- Templates : devis_to_sign, devis_signed_client/admin (PJ signed+audit), devis_refused_client/admin, advert_send (PJ + bouton paiement), ndd_expiration
- TestMailCommand : option --force-dsn pour envoyer via un DSN SMTP specifique (test prod depuis dev)

Commande NDD :
- app:ndd:check : verifie expiration domaines <= 30j, envoie mail groupe a monitor@siteconseil.fr
- Cron quotidien 8h (docker + ansible)

Divers :
- Titles templates : CRM SITECONSEIL -> SARL SITECONSEIL (52 fichiers)
- VAULT_URL dev = https://kms.esy-web.dev (comme prod)
- app.js : initDevisLines (repeater + drag & drop), initTabSearch, toggle refus devis
- app.scss : styles drag & drop
- setasign/fpdi-fpdf installe pour fusion PDF
- 5 migrations Doctrine

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-07 09:44:35 +02:00
parent 3870713412
commit 95d33a9a6d
105 changed files with 4883 additions and 75 deletions

View File

@@ -273,4 +273,199 @@ document.addEventListener('DOMContentLoaded', () => {
if (e.key === 'Escape') globalResults.classList.add('hidden');
});
}
// ──────── Tab search devis / avis ────────
initTabSearch('search-devis', 'search-devis-results');
initTabSearch('search-adverts', 'search-adverts-results');
// ──────── Devis lines repeater + drag & drop ────────
initDevisLines();
// ──────── Devis process : toggle formulaire de refus ────────
const refuseBtn = document.getElementById('refuse-toggle-btn');
const refuseForm = document.getElementById('refuse-form');
if (refuseBtn && refuseForm) {
refuseBtn.addEventListener('click', () => refuseForm.classList.toggle('hidden'));
}
});
function initTabSearch(inputId, resultsId) {
const input = document.getElementById(inputId);
const results = document.getElementById(resultsId);
if (!input || !results) return;
const url = input.dataset.url;
let timeout = null;
const stateLabels = {
created: 'Cree', send: 'Envoye', accepted: 'Accepte', refused: 'Refuse', cancel: 'Annule'
};
const stateColors = {
created: 'bg-yellow-100 text-yellow-800', send: 'bg-blue-500/20 text-blue-700',
accepted: 'bg-green-500/20 text-green-700', refused: 'bg-red-500/20 text-red-700',
cancel: 'bg-gray-100 text-gray-600'
};
input.addEventListener('input', () => {
clearTimeout(timeout);
const q = input.value.trim();
if (q.length < 2) { results.classList.add('hidden'); return; }
timeout = setTimeout(async () => {
const resp = await fetch(url + '?q=' + encodeURIComponent(q));
const hits = await resp.json();
if (hits.length === 0) {
results.innerHTML = '<div class="px-4 py-3 text-xs text-gray-400">Aucun resultat.</div>';
} else {
results.innerHTML = hits.map(h =>
`<div class="flex items-center justify-between px-4 py-2 border-b border-white/10 hover:bg-white/50">
<div>
<span class="font-mono font-bold text-xs">${h.numOrder}</span>
<span class="text-[10px] text-gray-400 ml-2">${h.customerName || ''}</span>
</div>
<div class="flex items-center gap-2">
<span class="font-mono text-xs">${h.totalTtc || '0.00'} €</span>
<span class="px-2 py-0.5 ${stateColors[h.state] || 'bg-gray-100'} font-bold uppercase text-[9px] rounded">${stateLabels[h.state] || h.state}</span>
</div>
</div>`
).join('');
}
results.classList.remove('hidden');
}, 250);
});
document.addEventListener('click', (e) => {
if (!results.contains(e.target) && e.target !== input) results.classList.add('hidden');
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape') results.classList.add('hidden');
});
}
function initDevisLines() {
const container = document.getElementById('lines-container');
const addBtn = document.getElementById('add-line-btn');
const tplEl = document.getElementById('line-template');
const totalEl = document.getElementById('total-ht');
if (!container || !addBtn || !tplEl || !totalEl) return;
const template = tplEl.innerHTML;
let counter = 0;
function renumber() {
container.querySelectorAll('.line-row').forEach((row, idx) => {
row.querySelector('.line-pos').textContent = '#' + (idx + 1);
row.querySelector('.line-pos-input').value = idx;
});
}
function recalc() {
let total = 0;
container.querySelectorAll('.line-price').forEach(input => {
const v = parseFloat(input.value);
if (!isNaN(v)) total += v;
});
totalEl.textContent = total.toFixed(2) + ' EUR';
}
function addLine() {
const html = template.replaceAll('__INDEX__', counter);
const wrapper = document.createElement('div');
wrapper.innerHTML = html.trim();
const node = wrapper.firstChild;
container.appendChild(node);
counter++;
renumber();
recalc();
}
container.addEventListener('click', e => {
if (e.target.classList.contains('remove-line-btn')) {
e.target.closest('.line-row').remove();
renumber();
recalc();
}
});
container.addEventListener('input', e => {
if (e.target.classList.contains('line-price')) recalc();
});
addBtn.addEventListener('click', () => addLine());
// Boutons prestations rapides : ajoute une ligne pre-remplie
document.querySelectorAll('.quick-price-btn').forEach(btn => {
btn.addEventListener('click', () => {
addLine();
const lastRow = container.querySelector('.line-row:last-child');
if (!lastRow) return;
lastRow.querySelector('input[name$="[title]"]').value = btn.dataset.title || '';
lastRow.querySelector('textarea[name$="[description]"]').value = btn.dataset.description || '';
const priceInput = lastRow.querySelector('.line-price');
priceInput.value = btn.dataset.price || '0.00';
recalc();
});
});
// Drag & drop reordering
let draggedRow = null;
container.addEventListener('dragstart', e => {
const row = e.target.closest('.line-row');
if (!row) return;
draggedRow = row;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
container.addEventListener('dragend', e => {
const row = e.target.closest('.line-row');
if (row) row.classList.remove('dragging');
container.querySelectorAll('.line-row').forEach(r => r.classList.remove('drag-over'));
draggedRow = null;
renumber();
});
container.addEventListener('dragover', e => {
e.preventDefault();
const target = e.target.closest('.line-row');
if (!target || target === draggedRow) return;
container.querySelectorAll('.line-row').forEach(r => r.classList.remove('drag-over'));
target.classList.add('drag-over');
});
container.addEventListener('drop', e => {
e.preventDefault();
const target = e.target.closest('.line-row');
if (!target || !draggedRow || target === draggedRow) return;
const rows = Array.from(container.querySelectorAll('.line-row'));
const draggedIdx = rows.indexOf(draggedRow);
const targetIdx = rows.indexOf(target);
if (draggedIdx < targetIdx) {
target.after(draggedRow);
} else {
target.before(draggedRow);
}
target.classList.remove('drag-over');
renumber();
});
// Prefill en mode edition
const initial = container.dataset.initialLines;
if (initial) {
try {
const arr = JSON.parse(initial);
arr.sort((a, b) => (a.pos || 0) - (b.pos || 0));
arr.forEach(l => {
addLine();
const row = container.querySelector('.line-row:last-child');
if (!row) return;
row.querySelector('input[name$="[title]"]').value = l.title || '';
row.querySelector('textarea[name$="[description]"]').value = l.description || '';
row.querySelector('.line-price').value = l.priceHt || '0.00';
});
recalc();
} catch (e) { /* ignore */ }
}
}

View File

@@ -266,3 +266,7 @@ body.glass-bg {
* {
scroll-behavior: smooth;
}
/* ─── Devis lines drag & drop ─── */
.line-row.dragging { opacity: 0.4; }
.line-row.drag-over { border-top: 2px solid #fabf04; }