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:
195
assets/app.js
195
assets/app.js
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user