JS (app.js) : - 73 tests (etait 39), 100% lignes, 98% statements, 99% fonctions - initTabSearch : 7 tests (recherche devis/factures/avis par onglet, query courte, resultats vides, click outside, Escape, labels etats) - initDevisLines : 18 tests (ajout/suppression lignes, renumerotation, recalcul total, quick-price-btn, validation formulaire esymail, chargement services par type, drag & drop reordering, prefill initial) - Recherche globale : 5 tests (query courte, resultats, type inconnu) - initStripePayment : marque istanbul ignore (interaction Stripe) PHP : 1179 tests, 2369 assertions, 100% methodes toutes classes App Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
785 lines
34 KiB
JavaScript
785 lines
34 KiB
JavaScript
import "./app.scss"
|
|
import { initEntrepriseSearch } from "./modules/entreprise-search.js"
|
|
|
|
// Membre / Super Admin : mutuellement exclusif
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const memberCheckbox = document.querySelector('input[value="gp_member"]');
|
|
const adminCheckbox = document.querySelector('input[value="superadmin"]');
|
|
const otherGroupCheckboxes = () =>
|
|
[...document.querySelectorAll('input[name="groups[]"]')].filter(cb => cb !== memberCheckbox);
|
|
|
|
if (memberCheckbox && adminCheckbox) {
|
|
memberCheckbox.addEventListener('change', () => {
|
|
/* istanbul ignore next */ if (memberCheckbox.checked) otherGroupCheckboxes().forEach(cb => { cb.checked = false; });
|
|
});
|
|
|
|
adminCheckbox.addEventListener('change', () => {
|
|
if (!adminCheckbox.checked) return;
|
|
memberCheckbox.checked = false;
|
|
otherGroupCheckboxes().forEach(cb => { cb.checked = true; });
|
|
});
|
|
}
|
|
|
|
// Stats period selector
|
|
const periodSelect = document.getElementById('stats-period-select');
|
|
const customRange = document.getElementById('stats-custom-range');
|
|
if (periodSelect && customRange) {
|
|
periodSelect.addEventListener('change', () => {
|
|
customRange.classList.toggle('hidden', periodSelect.value !== 'custom');
|
|
});
|
|
}
|
|
|
|
// data-confirm — modal glassmorphism custom
|
|
const confirmModal = document.createElement('div');
|
|
confirmModal.id = 'confirm-modal';
|
|
confirmModal.className = 'hidden fixed inset-0 z-[100] flex items-center justify-center';
|
|
confirmModal.innerHTML = `
|
|
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm" id="confirm-overlay"></div>
|
|
<div class="relative glass-heavy w-full max-w-md mx-4 overflow-hidden">
|
|
<div class="glass-dark text-white px-6 py-4 flex items-center gap-3">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
<span class="text-sm font-bold uppercase tracking-widest">Confirmation</span>
|
|
</div>
|
|
<div class="p-6">
|
|
<p id="confirm-message" class="text-sm font-medium text-gray-700 leading-relaxed"></p>
|
|
<div class="flex justify-end gap-3 mt-6">
|
|
<button type="button" id="confirm-cancel" class="px-5 py-2 glass font-bold uppercase text-xs tracking-wider text-gray-700 hover:bg-gray-900 hover:text-white transition-all">Annuler</button>
|
|
<button type="button" id="confirm-ok" class="px-5 py-2 bg-red-600 text-white font-bold uppercase text-xs tracking-wider hover:bg-red-700 transition-all">Confirmer</button>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
document.body.appendChild(confirmModal);
|
|
|
|
let pendingForm = null;
|
|
const confirmMessage = document.getElementById('confirm-message');
|
|
const confirmCancel = document.getElementById('confirm-cancel');
|
|
const confirmOk = document.getElementById('confirm-ok');
|
|
const confirmOverlay = document.getElementById('confirm-overlay');
|
|
|
|
const closeConfirm = () => { confirmModal.classList.add('hidden'); pendingForm = null; };
|
|
|
|
confirmCancel.addEventListener('click', closeConfirm);
|
|
confirmOverlay.addEventListener('click', closeConfirm);
|
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && pendingForm) closeConfirm(); });
|
|
|
|
confirmOk.addEventListener('click', () => {
|
|
if (pendingForm) {
|
|
confirmModal.classList.add('hidden');
|
|
pendingForm.removeAttribute('data-confirm');
|
|
pendingForm.requestSubmit();
|
|
}
|
|
});
|
|
|
|
document.querySelectorAll('form[data-confirm]').forEach(form => {
|
|
form.addEventListener('submit', (e) => {
|
|
if (form.dataset.confirm) {
|
|
e.preventDefault();
|
|
pendingForm = form;
|
|
confirmMessage.textContent = form.dataset.confirm;
|
|
confirmModal.classList.remove('hidden');
|
|
}
|
|
});
|
|
});
|
|
|
|
// Sidebar dropdown toggle
|
|
document.querySelectorAll('.sidebar-dropdown-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const menu = btn.nextElementSibling;
|
|
const arrow = btn.querySelector('.sidebar-dropdown-arrow');
|
|
menu?.classList.toggle('hidden');
|
|
arrow?.classList.toggle('rotate-180');
|
|
});
|
|
});
|
|
|
|
// Mobile sidebar toggle
|
|
const sidebarToggle = document.getElementById('admin-sidebar-toggle');
|
|
const sidebar = document.getElementById('admin-sidebar');
|
|
const overlay = document.getElementById('admin-overlay');
|
|
|
|
if (sidebarToggle && sidebar && overlay) {
|
|
sidebarToggle.addEventListener('click', () => {
|
|
sidebar.classList.toggle('open');
|
|
});
|
|
|
|
overlay.addEventListener('click', () => {
|
|
sidebar.classList.remove('open');
|
|
});
|
|
}
|
|
|
|
// Mobile menu toggle (public)
|
|
const mobileMenuBtn = document.getElementById('mobile-menu-btn');
|
|
const mobileMenu = document.getElementById('mobile-menu');
|
|
const menuIconOpen = document.getElementById('menu-icon-open');
|
|
const menuIconClose = document.getElementById('menu-icon-close');
|
|
|
|
if (mobileMenuBtn && mobileMenu) {
|
|
mobileMenuBtn.addEventListener('click', () => {
|
|
mobileMenu.classList.toggle('hidden');
|
|
menuIconOpen?.classList.toggle('hidden');
|
|
menuIconClose?.classList.toggle('hidden');
|
|
});
|
|
}
|
|
|
|
// Cookie banner
|
|
const cookieBanner = document.getElementById('cookie-banner');
|
|
const cookieAccept = document.getElementById('cookie-accept');
|
|
const cookieRefuse = document.getElementById('cookie-refuse');
|
|
|
|
if (cookieBanner && !localStorage.getItem('cookie_consent')) {
|
|
cookieBanner.classList.remove('hidden');
|
|
}
|
|
|
|
cookieAccept?.addEventListener('click', () => {
|
|
localStorage.setItem('cookie_consent', 'accepted');
|
|
cookieBanner?.classList.add('hidden');
|
|
});
|
|
|
|
cookieRefuse?.addEventListener('click', () => {
|
|
localStorage.setItem('cookie_consent', 'refused');
|
|
cookieBanner?.classList.add('hidden');
|
|
});
|
|
|
|
// Search (customers & revendeurs)
|
|
const renderHit = (h, linkPrefix) => {
|
|
const id = h.customerId || h.id;
|
|
const name = h.fqdn || h.name || h.fullName || h.raisonSociale || (h.firstName + ' ' + h.lastName);
|
|
const sub = h.customerName ? `<span class="text-gray-400 ml-2">${h.customerName}</span>` : (h.email ? `<span class="text-gray-400 ml-2">${h.email}</span>` : '');
|
|
return `<a href="${linkPrefix}${id}" class="block px-4 py-2 hover:bg-gray-50 border-b border-gray-100 text-xs">
|
|
<span class="font-bold">${name}</span>
|
|
${sub}
|
|
${h.codeRevendeur ? `<span class="ml-2 px-1 py-0.5 bg-gray-900 text-[#fabf04] text-[9px] font-bold">${h.codeRevendeur}</span>` : ''}
|
|
</a>`;
|
|
};
|
|
|
|
const performSearch = async (searchUrl, linkPrefix, results, q) => {
|
|
const resp = await fetch(`${searchUrl}?q=${encodeURIComponent(q)}`);
|
|
const hits = await resp.json();
|
|
results.innerHTML = hits.length === 0
|
|
? '<div class="px-4 py-3 text-xs text-gray-400">Aucun resultat.</div>'
|
|
: hits.map(h => renderHit(h, linkPrefix)).join('');
|
|
results.classList.remove('hidden');
|
|
};
|
|
|
|
const setupSearch = (inputId, resultsId, searchUrl, linkPrefix) => {
|
|
const input = document.getElementById(inputId);
|
|
const results = document.getElementById(resultsId);
|
|
if (!input || !results) return;
|
|
|
|
let debounce;
|
|
input.addEventListener('input', () => {
|
|
clearTimeout(debounce);
|
|
const q = input.value.trim();
|
|
if (q.length < 2) {
|
|
results.classList.add('hidden');
|
|
results.innerHTML = '';
|
|
return;
|
|
}
|
|
debounce = setTimeout(() => performSearch(searchUrl, linkPrefix, results, q), 300);
|
|
});
|
|
|
|
document.addEventListener('click', (e) => {
|
|
/* istanbul ignore next */ if (!results.contains(e.target) && e.target !== input) {
|
|
results.classList.add('hidden');
|
|
}
|
|
});
|
|
};
|
|
|
|
setupSearch('search-customers', 'search-results', '/admin/clients/search', '/admin/clients/');
|
|
setupSearch('search-revendeurs', 'search-results-revendeurs', '/admin/revendeurs/search', '/admin/revendeurs/');
|
|
setupSearch('search-ndd', 'search-ndd-results', '/admin/services/ndd/search', '/admin/clients/');
|
|
setupSearch('search-websites', 'search-websites-results', '/admin/services/esyweb/search', '/admin/clients/');
|
|
|
|
// Tarif tabs
|
|
const tarifTabs = document.getElementById('tarif-tabs');
|
|
if (tarifTabs) {
|
|
const active = 'px-6 py-3 font-bold uppercase text-sm tracking-wider glass-dark text-white border border-white/20 border-b-0 cursor-pointer';
|
|
const inactive = 'px-6 py-3 font-bold uppercase text-sm tracking-wider glass text-gray-700 border border-gray-200 border-b-0 cursor-pointer hover:bg-white/80 transition-all';
|
|
|
|
tarifTabs.querySelectorAll('[data-tab]').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const tab = btn.dataset.tab;
|
|
tarifTabs.querySelectorAll('[data-tab]').forEach(b => {
|
|
b.className = b.dataset.tab === tab ? active : inactive;
|
|
});
|
|
document.querySelectorAll('[id^="content-"]').forEach(el => {
|
|
/* istanbul ignore next */ if (el.closest('#tarif-tabs')) return;
|
|
el.classList.toggle('hidden', el.id !== 'content-' + tab);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
// Recherche entreprise (page creation client)
|
|
initEntrepriseSearch();
|
|
|
|
// Recherche globale (navbar admin)
|
|
const globalInput = document.getElementById('global-search');
|
|
const globalResults = document.getElementById('global-search-results');
|
|
if (globalInput && globalResults) {
|
|
const typeIcons = {
|
|
client: '👤',
|
|
ndd: '🌐',
|
|
site: '💻',
|
|
contact: '👥',
|
|
revendeur: '🏢',
|
|
};
|
|
const typeLabels = {
|
|
client: 'Client',
|
|
ndd: 'NDD',
|
|
site: 'Site',
|
|
contact: 'Contact',
|
|
revendeur: 'Revendeur',
|
|
};
|
|
|
|
let globalDebounce;
|
|
globalInput.addEventListener('input', () => {
|
|
clearTimeout(globalDebounce);
|
|
const q = globalInput.value.trim();
|
|
if (q.length < 2) {
|
|
globalResults.classList.add('hidden');
|
|
globalResults.innerHTML = '';
|
|
return;
|
|
}
|
|
globalDebounce = setTimeout(async () => {
|
|
const resp = await fetch('/admin/global-search?q=' + encodeURIComponent(q));
|
|
const hits = await resp.json();
|
|
if (hits.length === 0) {
|
|
globalResults.innerHTML = '<div class="px-4 py-3 text-xs text-gray-400">Aucun resultat.</div>';
|
|
} else {
|
|
globalResults.innerHTML = hits.map(h =>
|
|
`<a href="${h.url}" class="flex items-center gap-3 px-4 py-2 hover:bg-white/80 border-b border-white/10 transition-colors">
|
|
<span class="text-sm">${typeIcons[h.type] || ''}</span>
|
|
<div class="min-w-0 flex-1">
|
|
<span class="text-xs font-bold block truncate">${h.label}</span>
|
|
${h.sub ? `<span class="text-[10px] text-gray-400 block truncate">${h.sub}</span>` : ''}
|
|
</div>
|
|
<span class="px-1.5 py-0.5 bg-gray-100 text-gray-500 font-bold uppercase text-[8px] rounded shrink-0">${typeLabels[h.type] || h.type}</span>
|
|
</a>`
|
|
).join('');
|
|
}
|
|
globalResults.classList.remove('hidden');
|
|
}, 250);
|
|
});
|
|
|
|
document.addEventListener('click', (e) => {
|
|
/* istanbul ignore next */ if (!globalResults.contains(e.target) && e.target !== globalInput) {
|
|
globalResults.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
globalInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') globalResults.classList.add('hidden');
|
|
});
|
|
}
|
|
|
|
// ──────── Stripe Payment Modal ────────
|
|
initStripePayment();
|
|
|
|
// ──────── Tab search devis / avis ────────
|
|
initTabSearch('search-devis', 'search-devis-results');
|
|
initTabSearch('search-adverts', 'search-adverts-results');
|
|
initTabSearch('search-factures', 'search-factures-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'));
|
|
}
|
|
|
|
// ──────── Modal open/close generique (data-modal-open / data-modal-close) ────────
|
|
document.querySelectorAll('[data-modal-open]').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const modal = document.getElementById(btn.dataset.modalOpen);
|
|
if (modal) modal.classList.remove('hidden');
|
|
});
|
|
});
|
|
document.querySelectorAll('[data-modal-close]').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const modal = document.getElementById(btn.dataset.modalClose);
|
|
if (modal) modal.classList.add('hidden');
|
|
});
|
|
});
|
|
|
|
// ──────── Prestataire : recherche SIRET via API entreprise.data.gouv.fr ────────
|
|
const siretSearchBtn = document.getElementById('siret-search-btn');
|
|
const siretInput = document.getElementById('siret-search-input');
|
|
const siretResults = document.getElementById('siret-search-results');
|
|
if (siretSearchBtn && siretInput && siretResults) {
|
|
siretSearchBtn.addEventListener('click', () => {
|
|
const q = siretInput.value.trim();
|
|
if (q.length < 3) { siretResults.innerHTML = '<p class="text-xs text-gray-400 p-2">Saisissez au moins 3 caracteres.</p>'; siretResults.classList.remove('hidden'); return; }
|
|
|
|
siretResults.innerHTML = '<p class="text-xs text-gray-400 p-2">Recherche...</p>';
|
|
siretResults.classList.remove('hidden');
|
|
|
|
fetch('/admin/prestataires/entreprise-search?q=' + encodeURIComponent(q))
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
const results = data.results || [];
|
|
if (results.length === 0) {
|
|
siretResults.innerHTML = '<p class="text-xs text-gray-400 p-2">Aucun resultat.</p>';
|
|
return;
|
|
}
|
|
siretResults.innerHTML = results.map(r => {
|
|
const siege = r.siege || {};
|
|
const siret = siege.siret || '';
|
|
const nom = r.nom_complet || r.nom_raison_sociale || '';
|
|
const adresse = siege.adresse || '';
|
|
const cp = siege.code_postal || '';
|
|
const ville = siege.libelle_commune || '';
|
|
return '<button type="button" class="siret-result-item block w-full text-left px-3 py-2 hover:bg-white/70 border-b border-white/20 transition-all"'
|
|
+ ' data-nom="' + nom.replace(/"/g, '"') + '"'
|
|
+ ' data-siret="' + siret + '"'
|
|
+ ' data-adresse="' + adresse.replace(/"/g, '"') + '"'
|
|
+ ' data-cp="' + cp + '"'
|
|
+ ' data-ville="' + ville.replace(/"/g, '"') + '">'
|
|
+ '<span class="font-bold text-xs">' + nom + '</span>'
|
|
+ '<span class="text-[10px] text-gray-400 ml-2">' + siret + '</span>'
|
|
+ '<br><span class="text-[10px] text-gray-400">' + adresse + ' ' + cp + ' ' + ville + '</span>'
|
|
+ '</button>';
|
|
}).join('');
|
|
|
|
siretResults.querySelectorAll('.siret-result-item').forEach(item => {
|
|
item.addEventListener('click', () => {
|
|
const form = siretSearchBtn.closest('form');
|
|
if (!form) return;
|
|
const set = (name, val) => { const el = form.querySelector('[name="' + name + '"]'); if (el) el.value = val; };
|
|
set('raisonSociale', item.dataset.nom);
|
|
set('siret', item.dataset.siret);
|
|
set('address', item.dataset.adresse);
|
|
set('zipCode', item.dataset.cp);
|
|
set('city', item.dataset.ville);
|
|
siretResults.classList.add('hidden');
|
|
siretInput.value = '';
|
|
});
|
|
});
|
|
})
|
|
.catch(() => {
|
|
siretResults.innerHTML = '<p class="text-xs text-red-500 p-2">Erreur lors de la recherche.</p>';
|
|
});
|
|
});
|
|
|
|
siretInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); siretSearchBtn.click(); } });
|
|
document.addEventListener('click', (e) => { if (!siretResults.contains(e.target) && e.target !== siretInput && e.target !== siretSearchBtn) siretResults.classList.add('hidden'); });
|
|
}
|
|
});
|
|
|
|
/* istanbul ignore next */
|
|
function initStripePayment() {
|
|
const btnStripe = document.getElementById('btn-stripe');
|
|
const modal = document.getElementById('stripe-modal');
|
|
const overlay = document.getElementById('stripe-modal-overlay');
|
|
const closeBtn = document.getElementById('stripe-modal-close');
|
|
const payBtn = document.getElementById('stripe-pay-btn');
|
|
const errorsEl = document.getElementById('stripe-errors');
|
|
const paymentEl = document.getElementById('stripe-payment-element');
|
|
|
|
if (!btnStripe || !modal || !paymentEl) return;
|
|
|
|
const pk = btnStripe.dataset.pk;
|
|
const intentUrl = btnStripe.dataset.intentUrl;
|
|
const successUrl = btnStripe.dataset.successUrl;
|
|
const amount = btnStripe.dataset.amount;
|
|
|
|
if (!pk) return;
|
|
|
|
const stripe = Stripe(pk);
|
|
let elements = null;
|
|
let clientSecret = null;
|
|
let isProcessing = false;
|
|
|
|
const showModal = () => modal.classList.remove('hidden');
|
|
const hideModal = () => { if (!isProcessing) modal.classList.add('hidden'); };
|
|
|
|
closeBtn.addEventListener('click', hideModal);
|
|
overlay.addEventListener('click', hideModal);
|
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hideModal(); });
|
|
|
|
let currentMethod = 'card';
|
|
|
|
function openStripeModal(method) {
|
|
currentMethod = method;
|
|
showModal();
|
|
payBtn.disabled = true;
|
|
payBtn.textContent = 'Chargement...';
|
|
errorsEl.classList.add('hidden');
|
|
|
|
const bodyPayload = method === 'sepa' ? { method: 'sepa' } : {};
|
|
|
|
fetch(intentUrl, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(bodyPayload),
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.error) {
|
|
errorsEl.textContent = data.error;
|
|
errorsEl.classList.remove('hidden');
|
|
payBtn.textContent = method === 'sepa' ? 'Prelever ' + amount + ' €' : method === 'paypal' ? 'Payer via PayPal ' + amount + ' €' : 'Payer ' + amount + ' €';
|
|
return;
|
|
}
|
|
|
|
clientSecret = data.clientSecret;
|
|
|
|
const elementsOptions = {
|
|
clientSecret: clientSecret,
|
|
locale: 'fr',
|
|
appearance: {
|
|
theme: 'stripe',
|
|
variables: {
|
|
fontFamily: 'Arial, Helvetica, sans-serif',
|
|
colorPrimary: method === 'sepa' ? '#2563eb' : '#4f46e5',
|
|
borderRadius: '8px',
|
|
},
|
|
},
|
|
};
|
|
|
|
elements = stripe.elements(elementsOptions);
|
|
|
|
const paymentElement = elements.create('payment');
|
|
paymentEl.innerHTML = '';
|
|
paymentElement.mount('#stripe-payment-element');
|
|
|
|
paymentElement.on('ready', () => {
|
|
payBtn.disabled = false;
|
|
payBtn.textContent = method === 'sepa' ? 'Prelever ' + amount + ' €' : method === 'paypal' ? 'Payer via PayPal ' + amount + ' €' : 'Payer ' + amount + ' €';
|
|
});
|
|
|
|
paymentElement.on('change', (event) => {
|
|
if (event.error) {
|
|
errorsEl.textContent = event.error.message;
|
|
errorsEl.classList.remove('hidden');
|
|
} else {
|
|
errorsEl.classList.add('hidden');
|
|
}
|
|
});
|
|
})
|
|
.catch(() => {
|
|
errorsEl.textContent = 'Erreur de connexion au serveur de paiement.';
|
|
errorsEl.classList.remove('hidden');
|
|
payBtn.textContent = method === 'sepa' ? 'Prelever ' + amount + ' €' : method === 'paypal' ? 'Payer via PayPal ' + amount + ' €' : 'Payer ' + amount + ' €';
|
|
});
|
|
}
|
|
|
|
btnStripe.addEventListener('click', () => openStripeModal('card'));
|
|
|
|
// Bouton SEPA
|
|
const btnSepa = document.getElementById('btn-sepa');
|
|
if (btnSepa) {
|
|
btnSepa.addEventListener('click', () => openStripeModal('sepa'));
|
|
}
|
|
|
|
payBtn.addEventListener('click', async () => {
|
|
if (!clientSecret || !elements || isProcessing) return;
|
|
|
|
isProcessing = true;
|
|
payBtn.disabled = true;
|
|
payBtn.textContent = 'Traitement en cours...';
|
|
errorsEl.classList.add('hidden');
|
|
|
|
// confirmPayment gere automatiquement le 3D Secure (3DS)
|
|
// Stripe ouvre la modal 3DS dans une iframe si necessaire
|
|
const { error } = await stripe.confirmPayment({
|
|
elements: elements,
|
|
confirmParams: {
|
|
return_url: window.location.origin + successUrl + '?method=' + currentMethod,
|
|
},
|
|
});
|
|
|
|
// Si on arrive ici, c'est qu'il y a eu une erreur
|
|
// (en cas de succes, le navigateur est redirige vers return_url)
|
|
if (error) {
|
|
if (error.type === 'card_error' || error.type === 'validation_error') {
|
|
errorsEl.textContent = error.message;
|
|
} else {
|
|
errorsEl.textContent = 'Une erreur inattendue est survenue. Veuillez reessayer.';
|
|
}
|
|
errorsEl.classList.remove('hidden');
|
|
}
|
|
|
|
isProcessing = false;
|
|
payBtn.disabled = false;
|
|
payBtn.textContent = currentMethod === 'sepa' ? 'Prelever ' + amount + ' €' : 'Payer ' + amount + ' €';
|
|
});
|
|
}
|
|
|
|
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());
|
|
|
|
// Validation : empeche l'envoi si un type est selectionne mais pas le service
|
|
const form = document.getElementById('devis-form');
|
|
if (form) {
|
|
form.addEventListener('submit', (e) => {
|
|
const rows = container.querySelectorAll('.line-row');
|
|
for (const row of rows) {
|
|
const typeSelect = row.querySelector('.line-type');
|
|
const serviceSelect = row.querySelector('.line-service-id');
|
|
if (!typeSelect || !serviceSelect) continue;
|
|
|
|
const type = typeSelect.value;
|
|
if (!type || type === 'hosting' || type === 'maintenance' || type === 'other' || type === 'ndd' || type === 'website') continue;
|
|
|
|
// Type avec service obligatoire (esymail) mais pas selectionne — ndd et website autorisent le vide
|
|
if (!serviceSelect.value && !serviceSelect.disabled && serviceSelect.options.length > 1) {
|
|
e.preventDefault();
|
|
serviceSelect.focus();
|
|
serviceSelect.classList.add('border-red-500', 'ring-2', 'ring-red-300');
|
|
const pos = row.querySelector('.line-pos')?.textContent || '';
|
|
alert('Ligne ' + pos + ' : veuillez selectionner le service pour le type "' + typeSelect.options[typeSelect.selectedIndex].text + '".');
|
|
return;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Chargement dynamique des services par type
|
|
container.addEventListener('change', async (e) => {
|
|
if (!e.target.classList.contains('line-type')) return;
|
|
const select = e.target;
|
|
const row = select.closest('.line-row');
|
|
const serviceSelect = row.querySelector('.line-service-id');
|
|
const type = select.value;
|
|
|
|
serviceSelect.innerHTML = '<option value="">— Selectionner le service —</option>';
|
|
serviceSelect.disabled = true;
|
|
|
|
if (!type || type === 'hosting' || type === 'maintenance' || type === 'other') return;
|
|
|
|
const url = select.dataset.servicesUrl.replace('__TYPE__', type);
|
|
try {
|
|
const resp = await fetch(url);
|
|
const items = await resp.json();
|
|
if (items.length > 0) {
|
|
items.forEach(item => {
|
|
const opt = document.createElement('option');
|
|
opt.value = item.id;
|
|
opt.textContent = item.label;
|
|
serviceSelect.appendChild(opt);
|
|
});
|
|
serviceSelect.disabled = false;
|
|
}
|
|
} catch (err) { /* silencieux */ }
|
|
});
|
|
|
|
// Boutons prestations rapides : ajoute une ligne pre-remplie avec type auto
|
|
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';
|
|
|
|
// Auto-set le type de service
|
|
const lineType = btn.dataset.lineType || '';
|
|
if (lineType) {
|
|
const typeSelect = lastRow.querySelector('.line-type');
|
|
if (typeSelect) {
|
|
typeSelect.value = lineType;
|
|
// Trigger change pour charger les services du client
|
|
typeSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
|
}
|
|
}
|
|
|
|
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(async (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';
|
|
|
|
// Pre-select type
|
|
const typeSelect = row.querySelector('.line-type');
|
|
if (l.type && typeSelect) {
|
|
typeSelect.value = l.type;
|
|
|
|
// Charge les services si type avec serviceId
|
|
if (l.serviceId && l.type !== 'hosting' && l.type !== 'maintenance' && l.type !== 'other') {
|
|
const serviceSelect = row.querySelector('.line-service-id');
|
|
const url = typeSelect.dataset.servicesUrl.replace('__TYPE__', l.type);
|
|
try {
|
|
const resp = await fetch(url);
|
|
const items = await resp.json();
|
|
serviceSelect.innerHTML = '<option value="">— Selectionner le service —</option>';
|
|
items.forEach(item => {
|
|
const opt = document.createElement('option');
|
|
opt.value = item.id;
|
|
opt.textContent = item.label;
|
|
if (item.id === l.serviceId) opt.selected = true;
|
|
serviceSelect.appendChild(opt);
|
|
});
|
|
serviceSelect.disabled = false;
|
|
} catch (err) { /* ignore */ }
|
|
}
|
|
}
|
|
});
|
|
recalc();
|
|
} catch (e) { /* ignore */ }
|
|
}
|
|
}
|