From 952a43059bddef2fbea89069e019307ccae9f22b Mon Sep 17 00:00:00 2001 From: Matthieu Date: Thu, 30 Oct 2025 10:30:27 +0100 Subject: [PATCH] devis v1 --- app.js | 1162 ++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 252 ++++++++++++ styles.css | 338 +++++++++++++++ 3 files changed, 1752 insertions(+) create mode 100644 app.js create mode 100644 index.html create mode 100644 styles.css diff --git a/app.js b/app.js new file mode 100644 index 0000000..ee5eee8 --- /dev/null +++ b/app.js @@ -0,0 +1,1162 @@ +// 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); +})(); diff --git a/index.html b/index.html new file mode 100644 index 0000000..e94b2ad --- /dev/null +++ b/index.html @@ -0,0 +1,252 @@ + + + + + + Générateur de Devis + + + +
+
+ +

Générateur de Devis

+
+
+ + + + + + +
+ +
+ +
+
+

Paramètres

+
+
+ +
+
+ +
+
+ +

Apparence

+
+ +
+ + +
+
+

Votre entreprise

+ + +
+ + +
+ +
+ + +
+ + +
+
+

Client

+ + +
+ + +
+ +
+ + +
+
+
+ +

Détails du devis

+
+ + + +
+ +

Lignes

+
+
+
+
Description
+
Temps (jours)
+
PU HT
+
Total HT
+
+
+
+
+
+ + + + +
+ +
+ + +
+ +
+ +
+
+
+ +
+
+
📍
+
+
✉️
+
📞
+
+
🧾
+
+
+
+
DEVIS
+
#
+
📅Date
+
Valide jusqu'au
+
+
+ +
+
Destinataire
+
+
📍
+
+
✉️
+
📞
+
+
+ +
+
+
Description
Temps (jours)
PU HT
Total HT
+
+
+
+ + + +
+
Total jours
+
Sous-total
+
Remise
+
TVA (%)
+
Total TTC
+
+ +
+
💳Conditions de paiement:
+
📝
+
+
+
+ + + + + + + + + + + diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..6b3337f --- /dev/null +++ b/styles.css @@ -0,0 +1,338 @@ +:root { + --bg: #0b0f14; + --panel: #121822; + --panel-2: #0f1520; + --text: #e6edf3; + --muted: #9aa7b3; + --primary: #4da3ff; + --accent: #7ee787; + --danger: #ff6b6b; + --border: #223041; +} + +* { box-sizing: border-box; } +html, body { height: 100%; } +body { + margin: 0; + font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji", "Segoe UI Emoji"; + background: linear-gradient(180deg, var(--bg), #0d1420 50%, var(--bg)); + color: var(--text); +} + +.app-header, .app-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + background: rgba(18,24,34,0.8); + backdrop-filter: blur(6px); + border-bottom: 1px solid var(--border); +} +.app-footer { border-top: 1px solid var(--border); border-bottom: none; justify-content: center; } +.brand { display: flex; gap: 10px; align-items: center; } +.logo-circle { width: 28px; height: 28px; border-radius: 50%; background: var(--primary); color: #001225; display: inline-flex; align-items: center; justify-content: center; font-weight: 700; } +.app-header h1 { margin: 0; font-size: 18px; } + +.container { + max-width: none; + margin: 20px auto; + padding: 0 16px; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.panel { + background: linear-gradient(180deg, var(--panel), var(--panel-2)); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px; +} + +h2, h3 { margin: 8px 0 12px; } +label { display: block; font-size: 14px; color: var(--muted); margin-bottom: 10px; } +input, textarea, select { + width: 100%; + background: #0b111a; + color: var(--text); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + margin-top: 6px; +} +input[type="date"] { padding: 8px 10px; } + +.grid.two { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.grid.three { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; } +.mt { margin-top: 12px; } + +.btn { border-radius: 8px; border: 1px solid var(--border); padding: 8px 12px; cursor: pointer; color: var(--text); background: #0f1520; } +.btn:hover { filter: brightness(1.05); } +.btn-primary { background: var(--primary); color: #001225; border-color: #2b6bbf; font-weight: 700; } +.btn-secondary { background: #0f1520; } +.btn-outline { background: transparent; border-style: dashed; } +.btn-danger { background: #201014; border-color: #4a1e27; color: #ffd7d7; } + +.actions { display: flex; gap: 8px; } + +.table { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } +.table-head, .table-row { + display: grid; grid-template-columns: 40px 2fr 80px 120px 120px 120px; gap: 8px; align-items: center; +} +.table-row { align-items: start; } +.table-head { background: #0d1420; padding: 10px; color: var(--muted); font-size: 13px; border-bottom: 1px solid var(--border); } +.table-body { display: grid; gap: 8px; padding: 10px; } +.table-row { background: #0b111a; border: 1px solid var(--border); border-radius: 8px; padding: 8px; } +.table-row input { margin: 0; } +.table-row textarea.desc { margin: 0; min-height: 64px; resize: vertical; } +/* Drag & drop affordances */ +.table-row.dragging { opacity: 0.6; } +.table-row.placeholder { border: 2px dashed var(--border); background: rgba(34, 48, 65, 0.35); min-height: 48px; } +.drag-ghost { box-shadow: 0 8px 24px rgba(0,0,0,0.45); border: 1px solid var(--border); background: #0b111a; opacity: 0.9; padding: 8px; border-radius: 8px; } + +/* Actions cell */ +.row-actions { display: flex; gap: 6px; align-items: center; justify-content: flex-end; } +.drag-handle { + width: 28px; height: 28px; + display: inline-flex; align-items: center; justify-content: center; + border: 1px solid var(--border); + border-radius: 6px; + background: #0f1520; + color: var(--muted); + cursor: grab; +} +.drag-handle:active { cursor: grabbing; } +.drag-handle:focus { outline: none; } +.row-actions input[type="checkbox"] { transform: translateY(1px); } + +/* Group rows in editor */ +.table-row.group .group-title, +.table-row.group .group-desc { grid-column: 2 / span 4; } +.table-row.group .group-title { font-weight: 600; background: #0b111a; border: 1px dashed var(--border); border-radius: 6px; padding: 8px 10px; } +.table-row.group .group-desc { background: #0b111a; border: 1px dashed var(--border); border-radius: 6px; padding: 6px 10px; color: var(--muted); } +.table-row.group .row-actions { grid-column: 6; grid-row: 1 / span 2; align-self: start; } + +.items-body .items-row.group-title { display: grid; grid-template-columns: 1fr; background: #0d1420; font-weight: 700; padding: 8px 10px; color: var(--text); border-left: 3px solid var(--accent); text-align: left; } +.items-body .items-row.group-title div { justify-content: flex-start; } +.items-body .items-row.group-description { display: grid; grid-template-columns: 1fr; padding: 8px 10px; color: var(--muted); background: transparent; } +.items-body .items-row.group-description div { justify-content: flex-start; } +.items-body .items-row.group-subtotal { display: grid; grid-template-columns: 1fr auto; padding: 8px 10px; border-top: 1px dashed var(--border); } +.row-total { text-align: right; padding-right: 6px; color: var(--text); } + +/* Preview styles */ +.quote-header { display: flex; justify-content: space-between; gap: 12px; border-bottom: 1px dashed var(--border); padding-bottom: 12px; margin-bottom: 12px; } +.company { display: flex; gap: 12px; align-items: flex-start; } +.logo { width: 56px; height: 56px; object-fit: contain; border-radius: 8px; border: 1px solid var(--border); background: #0b111a; } +.company-name { font-weight: 700; font-size: 18px; } +.company-address, .company-contact, .company-legal { color: var(--muted); font-size: 13px; } +.company-contact.stack { display: grid; gap: 4px; } + +.quote-title { font-size: 22px; font-weight: 800; color: var(--accent); letter-spacing: 1px; } +.quote-meta { text-align: right; display: grid; gap: 4px; justify-items: end; } + +.client-block { background: #0b111a; border: 1px solid var(--border); border-radius: 8px; padding: 10px; margin-bottom: 12px; } +.client-name { font-weight: 600; } +.client-address, .client-contact { color: var(--muted); font-size: 13px; } +.client-contact.stack { display: grid; gap: 4px; } + +.items { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } +.items-head, .items-row { display: grid; grid-template-columns: 2fr 80px 120px 120px; gap: 8px; } +.items-head { background: #0d1420; color: var(--muted); padding: 10px; font-size: 13px; border-bottom: 1px solid var(--border); } +.items-body .items-row { padding: 10px; border-bottom: 1px dashed var(--border); } +.items-body .items-row:last-child { border-bottom: none; } +.items-row div { display: flex; align-items: center; min-width: 0; } +.items-row div:last-child { justify-content: flex-end; } + +/* Ensure long description wraps and doesn't overflow horizontally */ +.items-row div:first-child { + white-space: pre-wrap; /* preserve newlines, allow wrapping */ + overflow-wrap: anywhere; /* break long words/URLs if needed */ + word-break: break-word; /* extra safety for legacy engines */ +} + +/* Prevent grid inputs from forcing overflow in the editor table */ +.table-row input { min-width: 0; } + +.totals { margin-top: 12px; padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: #0b111a; max-width: 420px; margin-left: auto; } +.totals .row { display: flex; justify-content: space-between; padding: 6px 0; } +.totals .grand { font-size: 18px; font-weight: 800; color: var(--accent); border-top: 1px dashed var(--border); margin-top: 6px; padding-top: 8px; } + +.notes { margin-top: 12px; color: var(--muted); } + +/* Icon rows */ +.icon-text { display: inline-flex; align-items: center; gap: 8px; } +.icon-text.right { justify-content: flex-end; } +.ico { display: inline-flex; width: 16px; height: 16px; align-items: center; justify-content: center; filter: grayscale(0.3); opacity: 0.9; } + +/* Print pagination scaffolding */ +.print-pages { display: none; } +.print-page { margin-top: 12px; } +.page-footer { color: var(--muted); font-size: 12px; text-align: right; margin-top: 8px; } + +@media (max-width: 980px) { + .container { grid-template-columns: 1fr; } +} + +/* Print styles */ +@media print { + @page { margin: 12mm; } + /* Neutral, pro palette for print */ + :root { + --bg: #ffffff; + --panel: #ffffff; + --panel-2: #ffffff; + --text: #000000; + --muted: #333333; + --primary: #000000; + --accent: #000000; + --danger: #000000; + --border: #222222; + } + + body { background: #fff !important; color: #000 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; } + .app-header, .form-panel, .app-footer, #resetBtn, #addItemBtn { display: none !important; } + .container { margin: 0; padding: 0; max-width: none; } + .preview-panel { border: none !important; background: #fff !important; } + .panel, .client-block, .items, .totals { box-shadow: none !important; background: #fff !important; border-color: #222 !important; } + .company-address, .company-contact, .company-legal, .client-address, .client-contact { color: #000 !important; } + .quote-title { color: #000 !important; } + + /* Basculer vers une table par page */ + .items.original { display: none !important; } + .print-pages { display: block !important; } + .print-page { margin-top: 0 !important; page-break-after: always; break-after: page; } + .print-page:last-child { page-break-after: auto; break-after: auto; } + .items-body .items-row { break-inside: avoid-page; page-break-inside: avoid; } + /* Hide original totals/notes; they are re-inserted into last printed page */ + .preview-panel > .totals, .preview-panel > .notes { display: none !important; } + .totals, .notes { break-inside: avoid-page; page-break-inside: avoid; } + + /* Table look */ + .items { border-color: #222 !important; } + .items-head { background: #f2f2f2 !important; color: #000 !important; border-bottom: 1px solid #222 !important; } + .items-body .items-row { border-bottom: 1px solid #e0e0e0; } + .items-body .items-row:last-child { border-bottom: none; } + .row-total { color: #000 !important; } + + /* Group blocks: left accent in black and left-aligned titles */ + .items-body .items-row.group-title { background: #fff !important; border-left: 3px solid #000 !important; color: #000 !important; } + .items-body .items-row.group-description { color: #000 !important; } + .items-body .items-row.group-subtotal { border-top: 1px solid #222 !important; font-weight: 600; } + .page-footer { color: #000 !important; } +} + +/* ===== Templates d'impression ===== */ +/* Pro Minimal: lignes fines, pas de cadres, look très sobre */ +body[data-template="pro-minimal"] .preview-panel .items, +body[data-template="pro-minimal"] .print-page .items { border: none; } +body[data-template="pro-minimal"] .preview-panel .items-head, +body[data-template="pro-minimal"] .print-page .items-head { background: transparent; color: var(--text); border-bottom: 2px solid var(--border); } +body[data-template="pro-minimal"] .preview-panel .items-body .items-row, +body[data-template="pro-minimal"] .print-page .items-body .items-row { border-bottom: 1px solid var(--border); background: transparent; } +body[data-template="pro-minimal"] .preview-panel .client-block { background: transparent; border-style: solid; } +body[data-template="pro-minimal"] .preview-panel .totals { background: transparent; } +@media print { + body[data-template="pro-minimal"] .items { border: none !important; } + body[data-template="pro-minimal"] .items-head { background: transparent !important; color: #000 !important; border-bottom: 2px solid #000 !important; } + body[data-template="pro-minimal"] .items-body .items-row { background: transparent !important; border-bottom: 1px solid #e0e0e0 !important; } + body[data-template="pro-minimal"] .client-block, body[data-template="pro-minimal"] .totals { background: transparent !important; } +} + +/* Pro Bordures: cadres nets et totaux accentués */ +body[data-template="pro-borders"] .preview-panel .client-block, +body[data-template="pro-borders"] .preview-panel .items, +body[data-template="pro-borders"] .preview-panel .totals, +body[data-template="pro-borders"] .print-page .items, +body[data-template="pro-borders"] .print-page .totals { border-width: 2px; border-style: solid; border-color: var(--border); background: #0b111a; } +body[data-template="pro-borders"] .preview-panel .items-head, +body[data-template="pro-borders"] .print-page .items-head { background: #0d1420; border-bottom: 2px solid var(--border); } +body[data-template="pro-borders"] .items-body .items-row { border-bottom: 1px solid var(--border); } +body[data-template="pro-borders"] .items-body .items-row:last-child { border-bottom: none; } +body[data-template="pro-borders"] .items-body .items-row.group-title { border-left: 4px solid var(--accent); background: #0d1420; } +body[data-template="pro-borders"] .items-body .items-row.group-subtotal { background: #0f1520; border-top: 2px solid var(--border); font-weight: 700; } +@media print { + body[data-template="pro-borders"] .client-block, + body[data-template="pro-borders"] .items, + body[data-template="pro-borders"] .totals { border-color: #000 !important; background: #fff !important; } + body[data-template="pro-borders"] .items-head { background: #f2f2f2 !important; border-bottom-color: #000 !important; } + body[data-template="pro-borders"] .items-body .items-row { border-bottom: 1px solid #e0e0e0 !important; } + body[data-template="pro-borders"] .items-body .items-row.group-title { border-left: 4px solid #000 !important; background: #fff !important; } + body[data-template="pro-borders"] .items-body .items-row.group-subtotal { background: #fff !important; border-top: 2px solid #000 !important; } +} + +/* Bandeau Latéral: bande verticale d'accent à gauche */ +body[data-template="sidebar-accent"] .preview-panel, +body[data-template="sidebar-accent"] .print-page { position: relative; padding-left: 14px; } +body[data-template="sidebar-accent"] .preview-panel::before, +body[data-template="sidebar-accent"] .print-page::before { + content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 6px; background: var(--accent); opacity: 0.9; +} +body[data-template="sidebar-accent"] .items-body .items-row.group-title { border-left: 0; background: transparent; font-weight: 800; } +@media print { + body[data-template="sidebar-accent"] .print-page::before { background: #000 !important; } +} + +/* Minimal Centré: entête centré, table légère */ +body[data-template="centered-minimal"] .quote-header { flex-direction: column; align-items: center; text-align: center; gap: 6px; } +body[data-template="centered-minimal"] .quote-meta { text-align: center; justify-items: center; } +body[data-template="centered-minimal"] .icon-text.right { justify-content: center; } +body[data-template="centered-minimal"] .ico { display: none; } +body[data-template="centered-minimal"] .preview-panel .items, +body[data-template="centered-minimal"] .print-page .items { border: none; } +body[data-template="centered-minimal"] .preview-panel .items-head, +body[data-template="centered-minimal"] .print-page .items-head { background: transparent; border-bottom: 2px solid var(--border); } +body[data-template="centered-minimal"] .items-body .items-row { background: transparent; border-bottom: 1px dashed var(--border); } +@media print { + body[data-template="centered-minimal"] .items-head { border-bottom-color: #000 !important; } + body[data-template="centered-minimal"] .items-body .items-row { border-bottom: 1px solid #e0e0e0 !important; } +} +/* Pro Bandes: alternance de bandes sur les lignes */ +body[data-template="pro-striped"] .preview-panel .items-body .items-row:nth-child(even), +body[data-template="pro-striped"] .print-page .items-body .items-row:nth-child(even) { background: rgba(255,255,255,0.035); } +@media print { + body[data-template="pro-striped"] .items-body .items-row:nth-child(even) { background: #f7f7f7 !important; } +} + +/* Compact: paddings et tailles réduites pour tenir plus */ +body[data-template="compact"] .preview-panel .items-head, +body[data-template="compact"] .preview-panel .items-body .items-row, +body[data-template="compact"] .print-page .items-head, +body[data-template="compact"] .print-page .items-body .items-row { padding: 6px 8px; } +body[data-template="compact"] .preview-panel, +body[data-template="compact"] .print-page { font-size: 13px; } +@media print { + body[data-template="compact"] .items-head, + body[data-template="compact"] .items-body .items-row { padding: 6px 8px !important; } +} + +/* Autocomplete */ +.ac-list { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 50; + background: #0b111a; + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 6px 24px rgba(0,0,0,0.35); + max-height: 220px; + overflow: auto; + display: none; +} +.ac-item { padding: 8px 10px; cursor: pointer; font-size: 14px; } +.ac-item + .ac-item { border-top: 1px dashed var(--border); } +.ac-item:hover, .ac-item.active { background: #0d1420; } + +/* Modal (bibliothèque de devis) */ +.modal { position: fixed; inset: 0; display: none; align-items: center; justify-content: center; z-index: 100; } +.modal[aria-hidden="false"] { display: flex; } +.modal-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.5); backdrop-filter: blur(2px); } +.modal-content { position: relative; width: min(760px, 96vw); max-height: 86vh; overflow: auto; background: linear-gradient(180deg, var(--panel), var(--panel-2)); border: 1px solid var(--border); border-radius: 12px; padding: 12px; box-shadow: 0 16px 48px rgba(0,0,0,0.45); } +.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 6px 6px 12px; border-bottom: 1px solid var(--border); margin-bottom: 8px; } +.modal-body { padding: 6px; } +.library-list { display: grid; gap: 8px; } +.library-item { display: grid; grid-template-columns: 1fr auto auto auto; gap: 8px; align-items: center; background: #0b111a; border: 1px solid var(--border); border-radius: 8px; padding: 10px; } +.library-item .meta { color: var(--muted); font-size: 12px; } +.library-item .title { font-weight: 600; } +.library-item .btn { padding: 6px 10px; }