2026-04-01 15:42:52 +02:00
|
|
|
import "./app.scss"
|
feat: recherche entreprise SIRET/SIREN via API data.gouv.fr (proxy PHP)
Backend (ClientsController::entrepriseSearch) :
- Route GET /admin/clients/entreprise-search?q=...
- Proxy PHP vers https://recherche-entreprises.api.gouv.fr/search
(pas d'appel API direct depuis le JS)
- Retourne JSON avec results[], total_results
- Gestion erreur avec 502 si API indisponible
Frontend (assets/modules/entreprise-search.js) :
- Module JS séparé, pas de script inline (CSP compatible)
- Modal glassmorphism avec champ recherche et liste résultats
- Chaque résultat affiche : nom, SIREN, SIRET, adresse, dirigeant, statut
- Au clic sur un résultat, auto-remplissage du formulaire :
raisonSociale, siret, numTva (calcul clé TVA), address, zipCode, city
+ firstName/lastName du dirigeant si les champs sont vides
- Fermeture modal via overlay, bouton X, ou Escape
Template :
- Bouton "Rechercher SIRET / SIREN" à côté du bouton Retour
- Modal HTML avec header glass-dark, champ recherche, zone résultats scrollable
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:56:10 +02:00
|
|
|
import { initEntrepriseSearch } from "./modules/entreprise-search.js"
|
2026-04-01 15:42:52 +02:00
|
|
|
|
|
|
|
|
// Membre / Super Admin : mutuellement exclusif
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
2026-04-02 19:01:21 +02:00
|
|
|
const memberCheckbox = document.querySelector('input[value="siteconseil_member"]');
|
|
|
|
|
const adminCheckbox = document.querySelector('input[value="siteconseil_admin"]');
|
2026-04-01 19:36:42 +02:00
|
|
|
const otherGroupCheckboxes = () =>
|
|
|
|
|
[...document.querySelectorAll('input[name="groups[]"]')].filter(cb => cb !== memberCheckbox);
|
2026-04-01 15:42:52 +02:00
|
|
|
|
|
|
|
|
if (memberCheckbox && adminCheckbox) {
|
|
|
|
|
memberCheckbox.addEventListener('change', () => {
|
2026-04-03 10:43:41 +02:00
|
|
|
/* istanbul ignore next */ if (memberCheckbox.checked) otherGroupCheckboxes().forEach(cb => { cb.checked = false; });
|
2026-04-01 15:42:52 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
adminCheckbox.addEventListener('change', () => {
|
2026-04-01 19:36:42 +02:00
|
|
|
if (!adminCheckbox.checked) return;
|
|
|
|
|
memberCheckbox.checked = false;
|
|
|
|
|
otherGroupCheckboxes().forEach(cb => { cb.checked = true; });
|
2026-04-01 15:42:52 +02:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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');
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-04 12:05:25 +02:00
|
|
|
// 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" style="border-radius: 16px;">
|
|
|
|
|
<div class="glass-dark text-white px-6 py-4 flex items-center gap-3" style="border-radius: 16px 16px 0 0;">
|
|
|
|
|
<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" style="border-radius: 6px;">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();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-01 15:42:52 +02:00
|
|
|
document.querySelectorAll('form[data-confirm]').forEach(form => {
|
|
|
|
|
form.addEventListener('submit', (e) => {
|
2026-04-04 12:05:25 +02:00
|
|
|
if (form.dataset.confirm) {
|
2026-04-01 15:42:52 +02:00
|
|
|
e.preventDefault();
|
2026-04-04 12:05:25 +02:00
|
|
|
pendingForm = form;
|
|
|
|
|
confirmMessage.textContent = form.dataset.confirm;
|
|
|
|
|
confirmModal.classList.remove('hidden');
|
2026-04-01 15:42:52 +02:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 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)
|
feat: pages services NDD/Esy-Web + index Meilisearch + sync + recherche
Pages services :
- /admin/services/ndd : liste tous les NDD avec client, registrar,
Cloudflare, gestion, facturation, expiration + barre recherche
- /admin/services/esyweb : liste tous les sites avec client, UUID,
type, statut + barre recherche
- Liens sidebar mis à jour (Esy-Web → esyweb, Nom de domaine → ndd)
MeilisearchService :
- Index customer_ndd : searchable fqdn/registrar/customerName/customerEmail,
filterable customerId/isGestion/isBilling
- Index customer_website : searchable name/uuid/customerName/customerEmail,
filterable customerId/type/state
- CRUD : indexDomain/removeDomain/searchDomains, indexWebsite/removeWebsite/searchWebsites
- Serializers avec infos client intégrées (customerName, customerEmail, customerId)
SyncController :
- Route POST /admin/sync/domains : sync tous les Domain dans Meilisearch
- Route POST /admin/sync/websites : sync tous les Website dans Meilisearch
- Compteurs totalDomains et totalWebsites dans index
Template admin/sync :
- Bloc "Noms de domaine" (slate) avec bouton sync
- Bloc "Sites Internet" (blue) avec bouton sync
Recherche (app.js) :
- renderHit adapté : affiche fqdn/name pour NDD/sites, customerName en sous-texte
- Lien vers la fiche client (customerId) pour les résultats NDD/Website
- setupSearch configuré pour search-ndd et search-websites
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:26:17 +02:00
|
|
|
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}
|
2026-04-01 19:36:42 +02:00
|
|
|
${h.codeRevendeur ? `<span class="ml-2 px-1 py-0.5 bg-gray-900 text-[#fabf04] text-[9px] font-bold">${h.codeRevendeur}</span>` : ''}
|
|
|
|
|
</a>`;
|
feat: pages services NDD/Esy-Web + index Meilisearch + sync + recherche
Pages services :
- /admin/services/ndd : liste tous les NDD avec client, registrar,
Cloudflare, gestion, facturation, expiration + barre recherche
- /admin/services/esyweb : liste tous les sites avec client, UUID,
type, statut + barre recherche
- Liens sidebar mis à jour (Esy-Web → esyweb, Nom de domaine → ndd)
MeilisearchService :
- Index customer_ndd : searchable fqdn/registrar/customerName/customerEmail,
filterable customerId/isGestion/isBilling
- Index customer_website : searchable name/uuid/customerName/customerEmail,
filterable customerId/type/state
- CRUD : indexDomain/removeDomain/searchDomains, indexWebsite/removeWebsite/searchWebsites
- Serializers avec infos client intégrées (customerName, customerEmail, customerId)
SyncController :
- Route POST /admin/sync/domains : sync tous les Domain dans Meilisearch
- Route POST /admin/sync/websites : sync tous les Website dans Meilisearch
- Compteurs totalDomains et totalWebsites dans index
Template admin/sync :
- Bloc "Noms de domaine" (slate) avec bouton sync
- Bloc "Sites Internet" (blue) avec bouton sync
Recherche (app.js) :
- renderHit adapté : affiche fqdn/name pour NDD/sites, customerName en sous-texte
- Lien vers la fiche client (customerId) pour les résultats NDD/Website
- setupSearch configuré pour search-ndd et search-websites
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:26:17 +02:00
|
|
|
};
|
2026-04-01 19:36:42 +02:00
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-01 15:42:52 +02:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-01 19:36:42 +02:00
|
|
|
debounce = setTimeout(() => performSearch(searchUrl, linkPrefix, results, q), 300);
|
2026-04-01 15:42:52 +02:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.addEventListener('click', (e) => {
|
2026-04-03 10:43:41 +02:00
|
|
|
/* istanbul ignore next */ if (!results.contains(e.target) && e.target !== input) {
|
2026-04-01 15:42:52 +02:00
|
|
|
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/');
|
feat: pages services NDD/Esy-Web + index Meilisearch + sync + recherche
Pages services :
- /admin/services/ndd : liste tous les NDD avec client, registrar,
Cloudflare, gestion, facturation, expiration + barre recherche
- /admin/services/esyweb : liste tous les sites avec client, UUID,
type, statut + barre recherche
- Liens sidebar mis à jour (Esy-Web → esyweb, Nom de domaine → ndd)
MeilisearchService :
- Index customer_ndd : searchable fqdn/registrar/customerName/customerEmail,
filterable customerId/isGestion/isBilling
- Index customer_website : searchable name/uuid/customerName/customerEmail,
filterable customerId/type/state
- CRUD : indexDomain/removeDomain/searchDomains, indexWebsite/removeWebsite/searchWebsites
- Serializers avec infos client intégrées (customerName, customerEmail, customerId)
SyncController :
- Route POST /admin/sync/domains : sync tous les Domain dans Meilisearch
- Route POST /admin/sync/websites : sync tous les Website dans Meilisearch
- Compteurs totalDomains et totalWebsites dans index
Template admin/sync :
- Bloc "Noms de domaine" (slate) avec bouton sync
- Bloc "Sites Internet" (blue) avec bouton sync
Recherche (app.js) :
- renderHit adapté : affiche fqdn/name pour NDD/sites, customerName en sous-texte
- Lien vers la fiche client (customerId) pour les résultats NDD/Website
- setupSearch configuré pour search-ndd et search-websites
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:26:17 +02:00
|
|
|
setupSearch('search-ndd', 'search-ndd-results', '/admin/services/ndd/search', '/admin/clients/');
|
|
|
|
|
setupSearch('search-websites', 'search-websites-results', '/admin/services/esyweb/search', '/admin/clients/');
|
refactor: rebrand project to CRM SITECONSEIL (SARL SITECONSEIL)
- Rename all references from E-Cosplay/Ecosplay to SITECONSEIL
- Update entity from Association to SARL SITECONSEIL (Siret: 418664058)
- Update address to 27 rue Le Serurier, 02100 Saint-Quentin
- Update emails: contact@siteconseil.fr, rgpd@siteconseil.fr
- Update hosting from GCP to OVHcloud (Roubaix, Gravelines, Strasbourg, Paris)
- Update legal pages: mentions legales, CGV, RGPD, conformite, hebergement, cookies, CGU
- Add tarifs page with tabs: Site Internet, E-Commerce, Nom de domaine, Esy-Mail, Esy-Mailer, Esy-Tchat, Esy-Meet, Esy-Defender
- Add Discord webhook notification workflow
- Disable deploy and sonarqube workflows
- Update OAuth Keycloak realm to master
- Update logo references to logo_facture.png
- Remove forced image sizing in Liip Imagine filters
- Update SonarQube project key and badge token
- Update tribunal competent to Saint-Quentin
- Move tarif tabs JS to app.js (CSP compliance)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:48:25 +02:00
|
|
|
|
|
|
|
|
// Tarif tabs
|
|
|
|
|
const tarifTabs = document.getElementById('tarif-tabs');
|
|
|
|
|
if (tarifTabs) {
|
feat: redesign entire CRM theme from brutalist to glassmorphism
Templates modified:
- templates/base.html.twig: header frosted glass (glass-heavy, backdrop-blur-24px),
footer dark glass (glass-dark-heavy), cookie banner floating glass panel with
rounded corners, all buttons converted to btn-glass/btn-gold/btn-dark classes,
body background with radial gradient mesh (gold + indigo orbs), removed all
border-4/border-8 thick borders, added rounded-lg corners on nav items
- templates/admin/_layout.html.twig: sidebar dark glass (glass-dark-heavy),
nav items with sidebar-nav-item class (rounded-lg, hover glow), active items
with gold glow shadow, avatar rounded-lg, dropdown borders changed to
border-white/10, mobile overlay with backdrop-blur-4px
- templates/home/index.html.twig: login card with glass-heavy + glass-gold header,
inputs with input-glass class (frosted blur, gold focus ring), buttons btn-gold
with hover lift effect
- templates/security/login.html.twig: same glass treatment as home
- templates/security/2fa_*.html.twig: glass cards and inputs
- templates/security/forgot_password.html.twig: glass treatment
- templates/security/set_password*.html.twig: glass treatment
- templates/legal/_layout.html.twig: glass header
- templates/legal/tarif.html.twig: tabs converted to glass/glass-dark,
all pricing cards glass/glass-gold, tables glass with rounded overflow
- templates/external_redirect.html.twig: glass card
SCSS (assets/app.scss):
- Added CSS custom properties: --glass-bg, --glass-border, --glass-blur,
--gold, --gold-glow, --radius, --shadow-glass, etc.
- Added glass classes: .glass, .glass-heavy, .glass-dark, .glass-dark-heavy,
.glass-gold (each with backdrop-filter, semi-transparent bg, subtle borders)
- Added button classes: .btn-glass, .btn-gold, .btn-dark (with hover lift,
glow shadows, smooth cubic-bezier transitions)
- Added .input-glass (frosted input with gold focus ring)
- Added .sidebar-nav-item with .active/.active-danger states
- Added .glass-bg body class with radial gradient background
- Added custom scrollbar for sidebar
- Moved admin layout styles from inline <style> to SCSS
JavaScript (assets/app.js):
- Updated tarif tab classes from brutalist to glass
Config:
- .env.local: OAUTH_KEYCLOAK_REALM changed from siteconseil to master
Design direction: frosted glass panels over gradient mesh background,
semi-transparent surfaces, subtle 1px borders with white/20 opacity,
soft box-shadows, rounded-16px corners, smooth hover transitions with
translateY(-1px) lift effect, gold (#fabf04) accent glow shadows
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:59:41 +02:00
|
|
|
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';
|
refactor: rebrand project to CRM SITECONSEIL (SARL SITECONSEIL)
- Rename all references from E-Cosplay/Ecosplay to SITECONSEIL
- Update entity from Association to SARL SITECONSEIL (Siret: 418664058)
- Update address to 27 rue Le Serurier, 02100 Saint-Quentin
- Update emails: contact@siteconseil.fr, rgpd@siteconseil.fr
- Update hosting from GCP to OVHcloud (Roubaix, Gravelines, Strasbourg, Paris)
- Update legal pages: mentions legales, CGV, RGPD, conformite, hebergement, cookies, CGU
- Add tarifs page with tabs: Site Internet, E-Commerce, Nom de domaine, Esy-Mail, Esy-Mailer, Esy-Tchat, Esy-Meet, Esy-Defender
- Add Discord webhook notification workflow
- Disable deploy and sonarqube workflows
- Update OAuth Keycloak realm to master
- Update logo references to logo_facture.png
- Remove forced image sizing in Liip Imagine filters
- Update SonarQube project key and badge token
- Update tribunal competent to Saint-Quentin
- Move tarif tabs JS to app.js (CSP compliance)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:48:25 +02:00
|
|
|
|
|
|
|
|
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 => {
|
2026-04-03 10:43:41 +02:00
|
|
|
/* istanbul ignore next */ if (el.closest('#tarif-tabs')) return;
|
refactor: rebrand project to CRM SITECONSEIL (SARL SITECONSEIL)
- Rename all references from E-Cosplay/Ecosplay to SITECONSEIL
- Update entity from Association to SARL SITECONSEIL (Siret: 418664058)
- Update address to 27 rue Le Serurier, 02100 Saint-Quentin
- Update emails: contact@siteconseil.fr, rgpd@siteconseil.fr
- Update hosting from GCP to OVHcloud (Roubaix, Gravelines, Strasbourg, Paris)
- Update legal pages: mentions legales, CGV, RGPD, conformite, hebergement, cookies, CGU
- Add tarifs page with tabs: Site Internet, E-Commerce, Nom de domaine, Esy-Mail, Esy-Mailer, Esy-Tchat, Esy-Meet, Esy-Defender
- Add Discord webhook notification workflow
- Disable deploy and sonarqube workflows
- Update OAuth Keycloak realm to master
- Update logo references to logo_facture.png
- Remove forced image sizing in Liip Imagine filters
- Update SonarQube project key and badge token
- Update tribunal competent to Saint-Quentin
- Move tarif tabs JS to app.js (CSP compliance)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:48:25 +02:00
|
|
|
el.classList.toggle('hidden', el.id !== 'content-' + tab);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
feat: recherche entreprise SIRET/SIREN via API data.gouv.fr (proxy PHP)
Backend (ClientsController::entrepriseSearch) :
- Route GET /admin/clients/entreprise-search?q=...
- Proxy PHP vers https://recherche-entreprises.api.gouv.fr/search
(pas d'appel API direct depuis le JS)
- Retourne JSON avec results[], total_results
- Gestion erreur avec 502 si API indisponible
Frontend (assets/modules/entreprise-search.js) :
- Module JS séparé, pas de script inline (CSP compatible)
- Modal glassmorphism avec champ recherche et liste résultats
- Chaque résultat affiche : nom, SIREN, SIRET, adresse, dirigeant, statut
- Au clic sur un résultat, auto-remplissage du formulaire :
raisonSociale, siret, numTva (calcul clé TVA), address, zipCode, city
+ firstName/lastName du dirigeant si les champs sont vides
- Fermeture modal via overlay, bouton X, ou Escape
Template :
- Bouton "Rechercher SIRET / SIREN" à côté du bouton Retour
- Modal HTML avec header glass-dark, champ recherche, zone résultats scrollable
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:56:10 +02:00
|
|
|
|
|
|
|
|
// Recherche entreprise (page creation client)
|
|
|
|
|
initEntrepriseSearch();
|
feat: barre de recherche globale dans la navbar admin
Navbar admin :
- Barre de recherche persistante en haut de toutes les pages admin
- Recherche dans tous les index Meilisearch simultanément :
clients (5), NDD (5), sites (5), contacts (5), revendeurs (3)
- Résultats en dropdown glassmorphism avec icône par type
- Clic sur un résultat → page + tab correspondant :
Client → /admin/clients/{id}
NDD → /admin/clients/{id}?tab=ndd
Site → /admin/clients/{id}?tab=sites
Contact → /admin/clients/{id}?tab=contacts
Revendeur → /admin/revendeurs/{id}/edit
DashboardController::globalSearch :
- Route GET /admin/global-search?q=...
- Agrège les résultats de 5 index Meilisearch
- Retourne [{type, label, sub, url}]
app.js :
- Debounce 250ms, min 2 chars
- Badges type (Client, NDD, Site, Contact, Revendeur)
- Fermeture Escape / clic extérieur
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 21:29:36 +02:00
|
|
|
|
|
|
|
|
// 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');
|
|
|
|
|
});
|
|
|
|
|
}
|
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>
2026-04-07 09:44:35 +02:00
|
|
|
|
|
|
|
|
// ──────── 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'));
|
|
|
|
|
}
|
2026-04-01 15:42:52 +02:00
|
|
|
});
|
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>
2026-04-07 09:44:35 +02:00
|
|
|
|
|
|
|
|
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 */ }
|
|
|
|
|
}
|
|
|
|
|
}
|