// Générateur de Devis — logique principale (() => { const $ = (sel) => document.querySelector(sel); const $$ = (sel) => Array.from(document.querySelectorAll(sel)); const state = { currency: 'EUR', vatRate: 20, printTemplate: 'standard', myName: '', myAddress: '', myStreet: '', myPostcode: '', myCity: '', myCountry: 'France', myEmail: '', myPhone: '', myLogo: '', myLegal: '', clientName: '', clientAddress: '', clientStreet: '', clientPostcode: '', clientCity: '', clientCountry: 'France', clientEmail: '', clientPhone: '', quoteNumber: '', quoteDate: '', quoteValidUntil: '', items: [], discountRate: 0, paymentTerms: '', notes: '' }; const CURRENCY_MAP = { EUR: { code: 'EUR', symbol: '€' }, USD: { code: 'USD', symbol: '$' }, GBP: { code: 'GBP', symbol: '£' }, CHF: { code: 'CHF', symbol: 'CHF' } }; const formatMoney = (num) => { const code = state.currency in CURRENCY_MAP ? state.currency : 'EUR'; try { return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: code, maximumFractionDigits: 2 }).format(Number(num || 0)); } catch { // Fallback simple const sym = CURRENCY_MAP[code]?.symbol || '€'; return `${Number(num || 0).toFixed(2)} ${sym}`; } }; const persistKey = 'devis-generator:v1'; const savedListKey = 'devis-generator:saved:v1'; const save = () => { try { localStorage.setItem(persistKey, JSON.stringify(state)); } catch {} }; const load = () => { try { const raw = localStorage.getItem(persistKey); if (raw) Object.assign(state, JSON.parse(raw)); } catch {} }; // Saved quotes library helpers const getSavedList = () => { try { return JSON.parse(localStorage.getItem(savedListKey) || '[]'); } catch { return []; } }; const setSavedList = (arr) => { try { localStorage.setItem(savedListKey, JSON.stringify(arr)); } catch {} }; const computeQuickTotal = (data) => { const items = Array.isArray(data.items) ? data.items : []; const subtotal = items.reduce((acc, it) => acc + Number(it.days ?? it.qty ?? 0) * Number(it.unitPrice ?? it.unit_price ?? 0), 0); const discount = subtotal * (Number(data.discountRate || 0) / 100); const base = Math.max(0, subtotal - discount); const vat = base * (Number(data.vatRate || 0) / 100); return base + vat; }; // (modèles de devis retirés) const buildDefaultSaveTitle = () => { const num = (state.quoteNumber || '').trim(); const client = (state.clientName || '').trim(); const date = (state.quoteDate || todayISO()); return num || [client, date].filter(Boolean).join(' - ') || 'Devis sans titre'; }; const saveCurrentQuote = () => { const data = buildExportJson(); const input = prompt('Nom du devis', buildDefaultSaveTitle()); if (input === null) return; // Annulé => ne rien faire const entry = { id: (Date.now().toString(36) + Math.random().toString(36).slice(2)), name: (input.trim() || buildDefaultSaveTitle()), number: data.quoteNumber || '', clientName: data.clientName || '', date: data.quoteDate || todayISO(), total: computeQuickTotal(data), savedAt: new Date().toISOString(), data }; const list = getSavedList(); list.unshift(entry); setSavedList(list); alert('Devis enregistré.'); }; const openLibrary = () => { const modal = document.getElementById('libraryModal'); const listEl = document.getElementById('libraryList'); const emptyEl = document.getElementById('libraryEmpty'); if (!modal || !listEl || !emptyEl) return; const list = getSavedList(); listEl.innerHTML = ''; if (!list.length) { emptyEl.style.display = ''; } else { emptyEl.style.display = 'none'; list.forEach((e) => { const row = document.createElement('div'); row.className = 'library-item'; const title = document.createElement('div'); title.innerHTML = `
${escapeHtml(e.name || '(sans nom)')}
#${escapeHtml(e.number || '—')} · ${escapeHtml(e.clientName || 'Client inconnu')} · ${escapeHtml(e.date || '')}
`; const price = document.createElement('div'); price.textContent = formatMoney(e.total || 0); const btnLoad = document.createElement('button'); btnLoad.className = 'btn btn-primary'; btnLoad.textContent = 'Charger'; btnLoad.addEventListener('click', () => { loadFromJson(e.data); closeLibrary(); }); const btnDel = document.createElement('button'); btnDel.className = 'btn btn-danger'; btnDel.textContent = 'Supprimer'; btnDel.addEventListener('click', () => { if (!confirm('Supprimer ce devis enregistré ?')) return; const cur = getSavedList().filter(x => x.id !== e.id); setSavedList(cur); openLibrary(); }); row.appendChild(title); row.appendChild(price); row.appendChild(btnLoad); row.appendChild(btnDel); listEl.appendChild(row); }); } modal.setAttribute('aria-hidden', 'false'); }; const closeLibrary = () => { const modal = document.getElementById('libraryModal'); if (modal) modal.setAttribute('aria-hidden', 'true'); }; const todayISO = () => new Date().toISOString().slice(0, 10); const plusDaysISO = (days) => { const d = new Date(); d.setDate(d.getDate() + days); return d.toISOString().slice(0, 10); }; // UI bindings const bindField = (inputSel, key, previewSel) => { const el = $(inputSel); if (!el) return; // Init value if (state[key] !== undefined && state[key] !== null && state[key] !== '') { el.value = state[key]; } el.addEventListener('input', () => { state[key] = el.type === 'number' ? Number(el.value || 0) : el.value; if (previewSel) updatePreviewText(previewSel, state[key]); save(); if (['vatRate', 'discountRate', 'currency'].includes(key)) computeAndRender(); }); if (previewSel) updatePreviewText(previewSel, state[key] || ''); }; const updatePreviewText = (sel, val) => { const tgt = $(sel); if (!tgt) return; const text = (val ?? '').toString(); tgt.textContent = text; const row = tgt.closest('.icon-text'); if (row) { const visible = text.trim().length > 0; row.style.display = visible ? '' : 'none'; } }; const joinAddress = (prefix) => { const street = state[`${prefix}Street`]; const pc = state[`${prefix}Postcode`]; const city = state[`${prefix}City`]; const country = state[`${prefix}Country`]; const parts = []; if (street) parts.push(street); const line2 = [pc, city].filter(Boolean).join(' '); if (line2) parts.push(line2); if (country) parts.push(country); if (!parts.length) { const legacy = state[prefix === 'my' ? 'myAddress' : 'clientAddress']; if (legacy) return legacy; } return parts.join(', '); }; const updateAddressPreview = (prefix) => { if (prefix === 'my') updatePreviewText('#p_myAddress', joinAddress('my')); if (prefix === 'client') updatePreviewText('#p_clientAddress', joinAddress('client')); }; const updateLogo = () => { const img = $('#p_myLogo'); if (!img) return; if (state.myLogo) { img.src = state.myLogo; img.style.display = ''; } else { img.removeAttribute('src'); img.style.display = 'none'; } }; const addItem = (item = { description: '', qty: 1, unitPrice: 0 }) => { // Push to state state.items.push({ description: item.description || '', qty: Number(item.qty || 1), unitPrice: Number(item.unitPrice || 0) }); renderItemsForm(); computeAndRender(); save(); }; const addGroup = (title = '') => { state.items.push({ type: 'group', title: title || '' }); renderItemsForm(); computeAndRender(); save(); }; const removeItem = (idx) => { state.items.splice(idx, 1); renderItemsForm(); computeAndRender(); save(); }; const getSelectedIndices = () => { const wrap = $('#items'); if (!wrap) return []; return Array.from(wrap.querySelectorAll('.table-row')) .map((row, i) => ({ row, i })) .filter(({ row }) => row.querySelector('.row-select')?.checked) .map(({ i }) => i); }; const renderItemsForm = () => { const wrap = $('#items'); if (!wrap) return; // preserve selection across re-render const previouslySelected = getSelectedIndices(); wrap.innerHTML = ''; let placeholder = null; state.items.forEach((it, idx) => { const row = document.createElement('div'); row.className = 'table-row' + (it.type === 'group' ? ' group' : ''); row.setAttribute('data-index', String(idx)); if (it.type === 'group') { row.innerHTML = `
`; } else { row.innerHTML = `
${formatMoney(it.qty * it.unitPrice)}
`; } // Bind events const [handleBtn] = row.children; const actionsEl = row.querySelector('.row-actions'); const delBtn = actionsEl.querySelector('.btn-danger'); const selectCb = actionsEl.querySelector('.row-select'); if (it.type === 'group') { const titleEl = row.querySelector('.group-title'); const gdescEl = row.querySelector('.group-desc'); if (titleEl) titleEl.addEventListener('input', () => { it.title = titleEl.value; save(); renderPreviewItems(); computeAndRender(); }); if (gdescEl) gdescEl.addEventListener('input', () => { it.description = gdescEl.value; save(); renderPreviewItems(); computeAndRender(); }); } else { const descEl = row.children[1]; const qtyEl = row.children[2]; const priceEl = row.children[3]; const totalDiv = row.children[4]; const autoresize = (el) => { el.style.height = 'auto'; el.style.height = Math.min(300, el.scrollHeight) + 'px'; }; autoresize(descEl); descEl.addEventListener('input', () => { it.description = descEl.value; autoresize(descEl); save(); renderPreviewItems(); }); qtyEl.addEventListener('input', () => { it.qty = Number(qtyEl.value || 0); totalDiv.textContent = formatMoney(it.qty * it.unitPrice); computeAndRender(); save(); }); priceEl.addEventListener('input', () => { it.unitPrice = Number(priceEl.value || 0); totalDiv.textContent = formatMoney(it.qty * it.unitPrice); computeAndRender(); save(); }); } delBtn.addEventListener('click', () => removeItem(idx)); if (previouslySelected.includes(idx) && selectCb) selectCb.checked = true; // Drag & drop — only via handle row.draggable = false; if (handleBtn) { handleBtn.addEventListener('dragstart', (e) => { row.classList.add('dragging'); const currentIndex = Number(row.getAttribute('data-index')); e.dataTransfer?.setData('text/plain', String(currentIndex)); // Create visible drag image (ghost) const ghost = row.cloneNode(true); ghost.classList.add('drag-ghost'); ghost.style.width = row.getBoundingClientRect().width + 'px'; ghost.style.position = 'absolute'; ghost.style.top = '-1000px'; document.body.appendChild(ghost); e.dataTransfer?.setDragImage(ghost, 10, 10); // Create placeholder to indicate drop position placeholder = document.createElement('div'); placeholder.className = 'table-row placeholder'; placeholder.style.minHeight = row.getBoundingClientRect().height + 'px'; // Insert placeholder right after current row initially row.parentElement?.insertBefore(placeholder, row.nextSibling); // Cleanup ghost after a tick setTimeout(() => { try { document.body.removeChild(ghost); } catch {} }, 0); }); handleBtn.addEventListener('dragend', () => { row.classList.remove('dragging'); // Remove placeholder if exists if (placeholder && placeholder.parentElement) placeholder.parentElement.removeChild(placeholder); placeholder = null; }); } wrap.appendChild(row); }); // Attach container-level handlers once if (!wrap.dataset.dndBound) { const getAfterElement = (container, y) => { const els = [...container.querySelectorAll('.table-row:not(.dragging):not(.placeholder)')]; let closest = { offset: Number.NEGATIVE_INFINITY, element: null }; els.forEach(el => { const box = el.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) { closest = { offset, element: el }; } }); return closest.element; }; wrap.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer && (e.dataTransfer.dropEffect = 'move'); // Move placeholder to the calculated position if (placeholder) { const afterEl = getAfterElement(wrap, e.clientY); if (afterEl) { wrap.insertBefore(placeholder, afterEl); } else { wrap.appendChild(placeholder); } } }); wrap.addEventListener('drop', (e) => { e.preventDefault(); const dragging = wrap.querySelector('.table-row.dragging'); if (!dragging) return; const fromIndex = Number(dragging.getAttribute('data-index')); const afterEl = getAfterElement(wrap, e.clientY); const rows = [...wrap.querySelectorAll('.table-row')]; const toIndexRaw = afterEl ? rows.indexOf(afterEl) : rows.length; // insertion index if (Number.isNaN(fromIndex) || Number.isNaN(toIndexRaw)) { dragging.classList.remove('dragging'); return; } let insertAt = toIndexRaw; if (fromIndex < insertAt) insertAt -= 1; // account for removal shift if (insertAt === fromIndex) { dragging.classList.remove('dragging'); return; } // Move in state const [moved] = state.items.splice(fromIndex, 1); state.items.splice(insertAt, 0, moved); save(); renderItemsForm(); computeAndRender(); // Cleanup placeholder if (placeholder && placeholder.parentElement) placeholder.parentElement.removeChild(placeholder); placeholder = null; }); wrap.dataset.dndBound = '1'; } }; const renderPreviewItems = () => { const wrap = $('#p_items'); if (!wrap) return; wrap.innerHTML = ''; let groupLabel = null; let groupSum = 0; let groupHasRows = false; const flushGroupSubtotal = () => { if (!groupHasRows) return; const sub = document.createElement('div'); sub.className = 'items-row group-subtotal'; sub.innerHTML = `
${groupLabel ? 'Sous-total — ' + escapeHtml(groupLabel) : 'Sous-total'}
${formatMoney(groupSum)}
`; wrap.appendChild(sub); groupSum = 0; groupHasRows = false; }; state.items.forEach((it) => { if (it.type === 'group') { // flush previous flushGroupSubtotal(); groupLabel = (it.title || '').toString(); const head = document.createElement('div'); head.className = 'items-row group-title'; head.innerHTML = `
${escapeHtml(groupLabel || 'Groupe')}
`; wrap.appendChild(head); const gdesc = (it.description || '').toString().trim(); if (gdesc) { const drow = document.createElement('div'); drow.className = 'items-row group-description'; drow.innerHTML = `
${escapeHtml(gdesc)}
`; wrap.appendChild(drow); } return; } const row = document.createElement('div'); row.className = 'items-row'; row.innerHTML = `
${escapeHtml(it.description)}
${numStr(it.qty)}
${formatMoney(it.unitPrice)}
${formatMoney(it.qty * it.unitPrice)}
`; wrap.appendChild(row); const line = Number(it.qty || 0) * Number(it.unitPrice || 0); groupSum += line; groupHasRows = true; }); // tail flush flushGroupSubtotal(); }; const numStr = (n) => { const val = Number(n || 0); return Number.isInteger(val) ? String(val) : val.toFixed(2); }; const escapeHtml = (s) => String(s || '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const computeTotals = () => { const subtotal = state.items.reduce((acc, it) => acc + Number(it.qty || 0) * Number(it.unitPrice || 0), 0); const discount = subtotal * (Number(state.discountRate || 0) / 100); const base = Math.max(0, subtotal - discount); const vat = base * (Number(state.vatRate || 0) / 100); const total = base + vat; return { subtotal, discount, vat, total }; }; const computeAndRender = () => { // Rows: update row totals $$('#items .table-row').forEach((row, i) => { const totalDiv = row.querySelector('.row-total'); const it = state.items[i]; if (totalDiv && (!it.type || it.type !== 'group')) totalDiv.textContent = formatMoney(Number(it.qty || 0) * Number(it.unitPrice || 0)); }); // Preview Items renderPreviewItems(); // Totals const { subtotal, discount, vat, total } = computeTotals(); const totalDays = state.items.reduce((acc, it) => acc + Number(it.qty || 0), 0); updatePreviewText('#p_subtotal', formatMoney(subtotal)); updatePreviewText('#p_discount', `- ${formatMoney(discount)}`); updatePreviewText('#p_vatRate', numStr(state.vatRate)); updatePreviewText('#p_vat', formatMoney(vat)); updatePreviewText('#p_total', formatMoney(total)); updatePreviewText('#p_totalDays', numStr(totalDays)); // Rebuild print pages preview structure (for print) buildPrintPages(); }; const applyInitialDefaults = () => { if (!state.quoteDate) state.quoteDate = todayISO(); if (!Array.isArray(state.items) || state.items.length === 0) { state.items = [{ description: '', qty: 1, unitPrice: 0 }]; } }; // --- Print pagination (one table per page) --- const buildPrintPages = () => { const container = document.getElementById('printPages'); if (!container) return; container.innerHTML = ''; const items = Array.isArray(state.items) ? state.items : []; if (!items.length) return; // Build a flat list of rows including group titles and group subtotals const rows = []; let groupLabel = null; let groupSum = 0; let groupHasRows = false; const flushGroupSubtotal = () => { if (!groupHasRows) return; rows.push({ kind: 'group-subtotal', label: groupLabel || '', amount: groupSum }); groupSum = 0; groupHasRows = false; }; items.forEach((it) => { if (it.type === 'group') { // close previous group flushGroupSubtotal(); groupLabel = (it.title || '').toString(); rows.push({ kind: 'group-title', label: groupLabel }); const gdesc = (it.description || '').toString().trim(); if (gdesc) rows.push({ kind: 'group-description', description: gdesc }); return; } const qty = Number(it.qty || 0); const unit = Number(it.unitPrice || 0); const line = qty * unit; rows.push({ kind: 'item', description: it.description || '', qty, unitPrice: unit, total: line }); groupSum += line; groupHasRows = true; }); // tail flush flushGroupSubtotal(); // Estimate how many rows fit per page based on visible preview metrics const pageH = window.innerHeight || 800; const preview = document.getElementById('printArea'); const headerH = preview?.querySelector('.quote-header')?.getBoundingClientRect().height || 0; const clientH = preview?.querySelector('.client-block')?.getBoundingClientRect().height || 0; const itemsHeadH = document.querySelector('.items.original .items-head')?.getBoundingClientRect().height || 0; const sampleRowH = document.querySelector('.items.original .items-body .items-row')?.getBoundingClientRect().height || 40; const footerH = 24; // page footer height const marginsBuffer = 40; // spacing/borders // Safety -1 row per page to prevent overflow creating blank pages on some printers const rowsFirst = Math.max(1, Math.floor((pageH - headerH - clientH - marginsBuffer - footerH - itemsHeadH) / sampleRowH) - 1); const rowsNext = Math.max(1, Math.floor((pageH - footerH - itemsHeadH - 12) / sampleRowH) - 1); const chunks = []; let i = 0; if (rows.length <= rowsFirst) { chunks.push(rows.slice()); } else { chunks.push(rows.slice(0, rowsFirst)); i = rowsFirst; while (i < rows.length) { chunks.push(rows.slice(i, i + rowsNext)); i += rowsNext; } } const totalPages = chunks.length; chunks.forEach((chunk, idx) => { const page = document.createElement('div'); page.className = 'print-page'; // Table const itemsWrap = document.createElement('div'); itemsWrap.className = 'items'; itemsWrap.innerHTML = `
Description
Temps (jours)
PU HT
Total HT
`; const body = itemsWrap.querySelector('.items-body'); chunk.forEach((r) => { const row = document.createElement('div'); if (r.kind === 'group-title') { row.className = 'items-row group-title'; row.innerHTML = `
${escapeHtml(r.label || 'Groupe')}
`; } else if (r.kind === 'group-description') { row.className = 'items-row group-description'; row.innerHTML = `
${escapeHtml(r.description || '')}
`; } else if (r.kind === 'group-subtotal') { row.className = 'items-row group-subtotal'; const label = r.label ? 'Sous-total — ' + r.label : 'Sous-total'; row.innerHTML = `
${escapeHtml(label)}
${formatMoney(r.amount || 0)}
`; } else { row.className = 'items-row'; row.innerHTML = `
${escapeHtml(r.description)}
${numStr(r.qty)}
${formatMoney(r.unitPrice)}
${formatMoney(r.total)}
`; } body.appendChild(row); }); page.appendChild(itemsWrap); // On the last page, include totals and notes below the table if (idx === totalPages - 1) { const totalsEl = document.querySelector('.totals'); const notesEl = document.querySelector('.notes'); if (totalsEl) page.appendChild(totalsEl.cloneNode(true)); if (notesEl) page.appendChild(notesEl.cloneNode(true)); } // Footer with page number const footer = document.createElement('div'); footer.className = 'page-footer'; footer.textContent = `Page ${idx + 1}/${totalPages}`; page.appendChild(footer); container.appendChild(page); }); }; const mirrorSimpleFields = () => { // Entreprise bindField('#myName', 'myName', '#p_myName'); bindField('#myStreet', 'myStreet'); bindField('#myPostcode', 'myPostcode'); bindField('#myCity', 'myCity'); bindField('#myCountry', 'myCountry'); updateAddressPreview('my'); $$('#myStreet, #myPostcode, #myCity, #myCountry').forEach(el => el.addEventListener('input', () => { updateAddressPreview('my'); save(); })); bindField('#myEmail', 'myEmail', '#p_myEmail'); bindField('#myPhone', 'myPhone', '#p_myPhone'); bindField('#myLegal', 'myLegal', '#p_myLegal'); bindField('#myLogo', 'myLogo'); updateLogo(); $('#myLogo')?.addEventListener('input', () => { updateLogo(); save(); }); // Client bindField('#clientName', 'clientName', '#p_clientName'); bindField('#clientStreet', 'clientStreet'); bindField('#clientPostcode', 'clientPostcode'); bindField('#clientCity', 'clientCity'); bindField('#clientCountry', 'clientCountry'); updateAddressPreview('client'); $$('#clientStreet, #clientPostcode, #clientCity, #clientCountry').forEach(el => el.addEventListener('input', () => { updateAddressPreview('client'); save(); })); bindField('#clientEmail', 'clientEmail', '#p_clientEmail'); bindField('#clientPhone', 'clientPhone', '#p_clientPhone'); // Devis meta bindField('#quoteNumber', 'quoteNumber', '#p_quoteNumber'); bindField('#quoteDate', 'quoteDate', '#p_quoteDate'); bindField('#quoteValidUntil', 'quoteValidUntil', '#p_quoteValidUntil'); // Divers bindField('#discountRate', 'discountRate'); bindField('#paymentTerms', 'paymentTerms', '#p_paymentTerms'); bindField('#notes', 'notes', '#p_notes'); }; const bindParams = () => { const cur = $('#currency'); const vat = $('#vatRate'); const tpl = $('#printTemplate'); if (cur) { if (state.currency) cur.value = state.currency; cur.addEventListener('change', () => { state.currency = cur.value; save(); computeAndRender(); }); } if (vat) { if (state.vatRate != null) vat.value = state.vatRate; vat.addEventListener('input', () => { state.vatRate = Number(vat.value || 0); save(); computeAndRender(); }); } if (tpl) { if (state.printTemplate) tpl.value = state.printTemplate; tpl.addEventListener('change', () => { state.printTemplate = tpl.value || 'standard'; applyTemplate(); save(); computeAndRender(); }); } }; const applyTemplate = () => { document.body.setAttribute('data-template', state.printTemplate || 'standard'); }; const bindButtons = () => { $('#addItemBtn')?.addEventListener('click', () => addItem()); $('#addGroupBtn')?.addEventListener('click', () => addGroup()); $('#printBtn')?.addEventListener('click', () => window.print()); // Save current quote $('#saveQuoteBtn')?.addEventListener('click', () => saveCurrentQuote()); // Library open/close $('#openLibraryBtn')?.addEventListener('click', () => openLibrary()); $('#closeLibraryBtn')?.addEventListener('click', () => closeLibrary()); document.querySelector('#libraryModal .modal-backdrop')?.addEventListener('click', (e) => { if (e.target?.dataset?.close) closeLibrary(); }); // (modèles de devis retirés) // Export JSON const exportBtn = $('#exportJsonBtn'); if (exportBtn) { exportBtn.addEventListener('click', () => { try { const data = buildExportJson(); const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); const num = (state.quoteNumber || '').toString().trim().replace(/\s+/g, '_'); a.download = num ? `devis_${num}.json` : 'devis.json'; a.href = url; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (e) { alert('Export JSON impossible: ' + (e.message || e)); } }); } // Bulk actions const getCheckedIndices = () => { const wrap = $('#items'); const rows = wrap ? [...wrap.querySelectorAll('.table-row')] : []; const indices = []; rows.forEach((row, i) => { if (row.querySelector('.row-select')?.checked) indices.push(i); }); return indices; }; $('#duplicateSelectedBtn')?.addEventListener('click', () => { const idxs = getCheckedIndices(); if (!idxs.length) return; // duplicate in ascending order, inserting after each original let offset = 0; idxs.forEach((i) => { const src = state.items[i + offset]; const copy = src && src.type === 'group' ? { type: 'group', title: src.title || '', description: src.description || '' } : { description: src.description || '', qty: Number(src.qty || 0), unitPrice: Number(src.unitPrice || 0) }; state.items.splice(i + offset + 1, 0, copy); offset += 1; }); save(); renderItemsForm(); computeAndRender(); }); $('#deleteSelectedBtn')?.addEventListener('click', () => { const idxs = getCheckedIndices().sort((a,b) => b - a); if (!idxs.length) return; idxs.forEach((i) => state.items.splice(i, 1)); save(); renderItemsForm(); computeAndRender(); }); // Select all toggle const selectAll = $('#selectAllRows'); if (selectAll) { selectAll.addEventListener('change', () => { $$('#items .row-select').forEach(cb => { cb.checked = selectAll.checked; }); }); } // Import JSON const importBtn = $('#importJsonBtn'); const importInput = $('#importJsonInput'); if (importBtn && importInput) { importBtn.addEventListener('click', () => importInput.click()); importInput.addEventListener('change', async () => { const file = importInput.files && importInput.files[0]; if (!file) return; try { const text = await file.text(); const obj = JSON.parse(text); loadFromJson(obj); } catch (e) { alert('Impossible d\'importer le JSON: ' + (e.message || e)); } finally { importInput.value = ''; } }); } $('#resetBtn')?.addEventListener('click', () => { if (!confirm('Réinitialiser le devis ?')) return; try { localStorage.removeItem(persistKey); } catch {} // Reset state Object.keys(state).forEach((k) => { if (k === 'currency') state[k] = 'EUR'; else if (k === 'vatRate') state[k] = 20; else if (k === 'discountRate') state[k] = 0; else if (k === 'items') state[k] = [{ description: '', qty: 1, unitPrice: 0 }]; else if (k.endsWith('Country')) state[k] = 'France'; else state[k] = ''; }); // Reset inputs $$('input, textarea, select').forEach((el) => { if (el.id === 'currency') el.value = 'EUR'; else if (el.id === 'vatRate') el.value = '20'; else if (el.id === 'discountRate') el.value = '0'; else if (el.id && el.id.endsWith('Country')) el.value = 'France'; else if (el.type === 'date') { if (el.id === 'quoteDate') el.value = todayISO(); else if (el.id === 'quoteValidUntil') el.value = ''; else el.value = ''; } else { el.value = ''; } }); renderItemsForm(); mirrorSimpleFields(); updateLogo(); computeAndRender(); save(); }); }; const buildExportJson = () => { const simpleKeys = [ 'currency','vatRate','myName','myStreet','myPostcode','myCity','myCountry','myEmail','myPhone','myLogo','myLegal', 'clientName','clientStreet','clientPostcode','clientCity','clientCountry','clientEmail','clientPhone', 'quoteNumber','quoteDate','quoteValidUntil','discountRate','paymentTerms','notes' ]; const out = {}; simpleKeys.forEach(k => { if (state[k] !== undefined) out[k] = state[k]; }); out.items = (state.items || []).map(it => { if (it && it.type === 'group') return { type: 'group', title: it.title || '', description: it.description || '' }; return { description: (it?.description || ''), days: Number(it?.qty || 0), unitPrice: Number(it?.unitPrice || 0) }; }); return out; }; // --- Import JSON --- const keysAllowed = [ 'currency','vatRate','myName','myStreet','myPostcode','myCity','myCountry','myEmail','myPhone','myLogo','myLegal', 'clientName','clientStreet','clientPostcode','clientCity','clientCountry','clientEmail','clientPhone', 'quoteNumber','quoteDate','quoteValidUntil','discountRate','paymentTerms','notes' ]; const syncInputsFromState = () => { $$('input, textarea, select').forEach((el) => { const id = el.id; if (!id || !(id in state)) return; if (id === 'currency') { el.value = state[id] || 'EUR'; el.dispatchEvent(new Event('change', { bubbles: true })); } else if (id === 'vatRate' || id === 'discountRate') { el.value = state[id] ?? 0; el.dispatchEvent(new Event('input', { bubbles: true })); } else if (el.type === 'date' || el.type === 'text' || el.type === 'email' || el.type === 'tel' || el.type === 'number' || el.tagName === 'TEXTAREA') { el.value = state[id] ?? ''; el.dispatchEvent(new Event('input', { bubbles: true })); } }); updateAddressPreview('my'); updateAddressPreview('client'); updateLogo(); }; const loadFromJson = (obj) => { if (!obj || typeof obj !== 'object') throw new Error('JSON invalide'); // Merge top-level simple keys keysAllowed.forEach((k) => { if (Object.prototype.hasOwnProperty.call(obj, k)) state[k] = obj[k]; }); // Items mapping const items = Array.isArray(obj.items) ? obj.items : []; state.items = items.map((it) => { if ((it.type || '').toString() === 'group') { return { type: 'group', title: (it.title ?? it.label ?? '').toString(), description: (it.description ?? it.desc ?? '').toString() }; } return { description: (it.description ?? '').toString(), qty: Number(it.days ?? it.qty ?? 0), unitPrice: Number(it.unitPrice ?? it.unit_price ?? 0) }; }); save(); renderItemsForm(); renderPreviewItems(); computeAndRender(); syncInputsFromState(); }; // --- Autocomplete Adresses (API Adresse BAN) --- const debounce = (fn, ms = 250) => { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; }; const setupAddressAutocomplete = (selector, { prefix } = {}) => { const el = typeof selector === 'string' ? (selector.startsWith('#') ? $(selector) : $('#' + selector)) : selector; if (!el) return; const parent = el.closest('label') || el.parentElement; if (!parent) return; parent.style.position = parent.style.position || 'relative'; const list = document.createElement('div'); list.className = 'ac-list'; parent.appendChild(list); let suggestions = []; let active = -1; let controller = null; const hide = () => { list.style.display = 'none'; active = -1; }; const show = () => { list.style.display = suggestions.length ? 'block' : 'none'; }; const render = () => { list.innerHTML = suggestions.map((s, i) => ( `
${escapeHtml(s.label)}
` )).join(''); list.querySelectorAll('.ac-item').forEach((it) => { it.addEventListener('mousedown', (e) => { e.preventDefault(); const i = Number(it.getAttribute('data-i')); select(i); }); }); }; const select = (i) => { if (!suggestions[i]) return; const s = suggestions[i]; el.value = s.label; el.dispatchEvent(new Event('input', { bubbles: true })); if (prefix && s.props) { setAddressFromProps(prefix, s.props); } hide(); }; const search = debounce(async () => { const term = (el.value || '').trim(); if (term.length < 3) { suggestions = []; render(); hide(); return; } try { if (controller) controller.abort(); controller = new AbortController(); const url = `https://api-adresse.data.gouv.fr/search/?q=${encodeURIComponent(term)}&autocomplete=1&limit=5`; const res = await fetch(url, { signal: controller.signal }); if (!res.ok) throw new Error('HTTP ' + res.status); const json = await res.json(); suggestions = (json.features || []).map(f => ({ label: f.properties?.label || '', props: f.properties || {} })); active = -1; render(); show(); } catch (e) { if (e.name === 'AbortError') return; // nouvelle requête suggestions = []; render(); hide(); } }, 250); el.addEventListener('input', search); el.addEventListener('keydown', (e) => { if (!suggestions.length) return; if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % suggestions.length; render(); } else if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + suggestions.length) % suggestions.length; render(); } else if (e.key === 'Enter') { if (active >= 0) { e.preventDefault(); select(active); } } else if (e.key === 'Escape') { hide(); } }); el.addEventListener('blur', () => setTimeout(hide, 120)); }; // --- Autocomplete Entreprises (Annuaire des entreprises) --- const setupCompanyAutocomplete = (selector, { prefix = 'client' } = {}) => { const el = typeof selector === 'string' ? (selector.startsWith('#') ? $(selector) : $('#' + selector)) : selector; if (!el) return; const parent = el.closest('label') || el.parentElement; if (!parent) return; parent.style.position = parent.style.position || 'relative'; const list = document.createElement('div'); list.className = 'ac-list'; parent.appendChild(list); let suggestions = []; let active = -1; let controller = null; const hide = () => { list.style.display = 'none'; active = -1; }; const show = () => { list.style.display = suggestions.length ? 'block' : 'none'; }; const render = () => { list.innerHTML = suggestions.map((s, i) => ( `
` + `${escapeHtml(s.title)}` + (s.subtitle ? `
${escapeHtml(s.subtitle)}
` : '') + `
` )).join(''); list.querySelectorAll('.ac-item').forEach((it) => { it.addEventListener('mousedown', (e) => { e.preventDefault(); const i = Number(it.getAttribute('data-i')); select(i); }); }); }; const select = (i) => { if (!suggestions[i]) return; const s = suggestions[i]; // Remplit le nom el.value = s.company.nom_complet || s.title; el.dispatchEvent(new Event('input', { bubbles: true })); // Remplit l'adresse si disponible const siege = s.company.siege || {}; const street = siege.adresse || [siege.numero_voie, siege.type_voie, siege.libelle_voie].filter(Boolean).join(' '); const city = siege.libelle_commune || ''; const postcode = siege.code_postal || ''; const country = (siege.pays || siege.libelle_pays_etranger || '').trim() || 'France'; state[`${prefix}Name`] = el.value; state[`${prefix}Street`] = street; state[`${prefix}City`] = city; state[`${prefix}Postcode`] = postcode; state[`${prefix}Country`] = country; const streetEl = document.querySelector(`#${prefix}Street`); const cityEl = document.querySelector(`#${prefix}City`); const pcEl = document.querySelector(`#${prefix}Postcode`); const countryEl = document.querySelector(`#${prefix}Country`); if (streetEl) streetEl.value = street; if (cityEl) cityEl.value = city; if (pcEl) pcEl.value = postcode; if (countryEl) countryEl.value = country; updateAddressPreview(prefix); updatePreviewText('#p_clientName', state[`${prefix}Name`] || ''); save(); hide(); }; const search = debounce(async () => { const term = (el.value || '').trim(); if (term.length < 2) { suggestions = []; render(); hide(); return; } try { if (controller) controller.abort(); controller = new AbortController(); const url = `https://recherche-entreprises.api.gouv.fr/search?q=${encodeURIComponent(term)}&per_page=5`; const res = await fetch(url, { signal: controller.signal }); if (!res.ok) throw new Error('HTTP ' + res.status); const json = await res.json(); const results = json?.results || []; suggestions = results.map((e) => ({ title: e.nom_complet || e.nom_raison_sociale || '', subtitle: [e.siege?.adresse, e.siege?.code_postal, e.siege?.libelle_commune].filter(Boolean).join(', '), company: e })); active = -1; render(); show(); } catch (e) { if (e.name === 'AbortError') return; suggestions = []; render(); hide(); } }, 250); el.addEventListener('input', search); el.addEventListener('keydown', (e) => { if (!suggestions.length) return; if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % suggestions.length; render(); } else if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + suggestions.length) % suggestions.length; render(); } else if (e.key === 'Enter') { if (active >= 0) { e.preventDefault(); select(active); } } else if (e.key === 'Escape') { hide(); } }); el.addEventListener('blur', () => setTimeout(hide, 120)); }; const setAddressFromProps = (prefix, props) => { const streetName = props.street || props.name || ''; const housenumber = props.housenumber ? props.housenumber + ' ' : ''; const streetFull = (housenumber + streetName).trim(); const city = props.city || ''; const postcode = props.postcode || ''; const country = 'France'; state[`${prefix}Street`] = streetFull; state[`${prefix}City`] = city; state[`${prefix}Postcode`] = postcode; state[`${prefix}Country`] = country; const streetEl = document.querySelector(`#${prefix}Street`); const cityEl = document.querySelector(`#${prefix}City`); const pcEl = document.querySelector(`#${prefix}Postcode`); const countryEl = document.querySelector(`#${prefix}Country`); if (streetEl) streetEl.value = streetFull; if (cityEl) cityEl.value = city; if (pcEl) pcEl.value = postcode; if (countryEl) countryEl.value = country; updateAddressPreview(prefix); save(); }; const init = () => { load(); applyInitialDefaults(); // Titre de page: adapter pour l'impression (évite "Générateur de Devis" dans l'en-tête navigateur) const originalTitle = document.title; window.addEventListener('beforeprint', () => { const num = (state.quoteNumber || '').toString().trim(); document.title = num ? `Devis ${num}` : 'Devis'; }); window.addEventListener('afterprint', () => { document.title = originalTitle; }); // Fill inputs from state $('#currency') && ($('#currency').value = state.currency); $('#vatRate') && ($('#vatRate').value = state.vatRate); $('#printTemplate') && ($('#printTemplate').value = state.printTemplate || 'standard'); $('#myName') && ($('#myName').value = state.myName); $('#myStreet') && ($('#myStreet').value = state.myStreet); $('#myPostcode') && ($('#myPostcode').value = state.myPostcode); $('#myCity') && ($('#myCity').value = state.myCity); $('#myCountry') && ($('#myCountry').value = state.myCountry || 'France'); $('#myEmail') && ($('#myEmail').value = state.myEmail); $('#myPhone') && ($('#myPhone').value = state.myPhone); $('#myLogo') && ($('#myLogo').value = state.myLogo); $('#myLegal') && ($('#myLegal').value = state.myLegal); $('#clientName') && ($('#clientName').value = state.clientName); $('#clientStreet') && ($('#clientStreet').value = state.clientStreet); $('#clientPostcode') && ($('#clientPostcode').value = state.clientPostcode); $('#clientCity') && ($('#clientCity').value = state.clientCity); $('#clientCountry') && ($('#clientCountry').value = state.clientCountry || 'France'); $('#clientEmail') && ($('#clientEmail').value = state.clientEmail); $('#clientPhone') && ($('#clientPhone').value = state.clientPhone); $('#quoteNumber') && ($('#quoteNumber').value = state.quoteNumber); $('#quoteDate') && ($('#quoteDate').value = state.quoteDate); $('#quoteValidUntil') && ($('#quoteValidUntil').value = state.quoteValidUntil); $('#discountRate') && ($('#discountRate').value = state.discountRate); $('#paymentTerms') && ($('#paymentTerms').value = state.paymentTerms); $('#notes') && ($('#notes').value = state.notes); // Bind bindParams(); applyTemplate(); mirrorSimpleFields(); // Autocomplete adresses sur Rue setupAddressAutocomplete('#myStreet', { prefix: 'my' }); setupAddressAutocomplete('#clientStreet', { prefix: 'client' }); // Autocomplete entreprises sur Nom/Société (client) setupCompanyAutocomplete('#clientName', { prefix: 'client' }); bindButtons(); renderItemsForm(); computeAndRender(); updateAddressPreview('my'); updateAddressPreview('client'); save(); }; document.addEventListener('DOMContentLoaded', init); })();