diff --git a/LOGO-DEVIS.jpg b/LOGO-DEVIS.jpg new file mode 100644 index 0000000..56bb7da Binary files /dev/null and b/LOGO-DEVIS.jpg differ diff --git a/app.js b/app.js index ee5eee8..d63665c 100644 --- a/app.js +++ b/app.js @@ -3,59 +3,67 @@ const $ = (sel) => document.querySelector(sel); const $$ = (sel) => Array.from(document.querySelectorAll(sel)); + const DEFAULT_LOGO = "LOGO-DEVIS.jpg"; + const state = { - currency: 'EUR', + 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: '', + 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: '' + paymentTerms: "", + notes: "", }; const CURRENCY_MAP = { - EUR: { code: 'EUR', symbol: '€' }, - USD: { code: 'USD', symbol: '$' }, - GBP: { code: 'GBP', symbol: '£' }, - CHF: { code: 'CHF', symbol: 'CHF' } + 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'; + 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)); + 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 || '€'; + 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 persistKey = "devis-generator:v1"; + const savedListKey = "devis-generator:saved:v1"; const save = () => { - try { localStorage.setItem(persistKey, JSON.stringify(state)); } catch {} + try { + localStorage.setItem(persistKey, JSON.stringify(state)); + } catch {} }; const load = () => { try { @@ -66,14 +74,26 @@ // Saved quotes library helpers const getSavedList = () => { - try { return JSON.parse(localStorage.getItem(savedListKey) || '[]'); } catch { return []; } + try { + return JSON.parse(localStorage.getItem(savedListKey) || "[]"); + } catch { + return []; + } }; const setSavedList = (arr) => { - try { localStorage.setItem(savedListKey, JSON.stringify(arr)); } catch {} + 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 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); @@ -82,59 +102,68 @@ // (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 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()); + 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 || '', + 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 + data, }; const list = getSavedList(); list.unshift(entry); setSavedList(list); - alert('Devis enregistré.'); + alert("Devis enregistré."); }; const openLibrary = () => { - const modal = document.getElementById('libraryModal'); - const listEl = document.getElementById('libraryList'); - const emptyEl = document.getElementById('libraryEmpty'); + 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 = ''; + listEl.innerHTML = ""; if (!list.length) { - emptyEl.style.display = ''; + emptyEl.style.display = ""; } else { - emptyEl.style.display = 'none'; + 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'); + 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); + 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(); }); @@ -145,11 +174,11 @@ listEl.appendChild(row); }); } - modal.setAttribute('aria-hidden', 'false'); + modal.setAttribute("aria-hidden", "false"); }; const closeLibrary = () => { - const modal = document.getElementById('libraryModal'); - if (modal) modal.setAttribute('aria-hidden', 'true'); + const modal = document.getElementById("libraryModal"); + if (modal) modal.setAttribute("aria-hidden", "true"); }; const todayISO = () => new Date().toISOString().slice(0, 10); @@ -159,32 +188,51 @@ return d.toISOString().slice(0, 10); }; + const formatDisplayDate = (iso) => { + if (!iso) return ""; + const parts = iso.split("-"); + if (parts.length !== 3) return iso; + const [year, month, day] = parts.map(Number); + if (!year || !month || !day) return iso; + const date = new Date(Date.UTC(year, month - 1, day)); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleDateString("fr-FR", { + day: "numeric", + month: "long", + year: "numeric", + }); + }; + // UI bindings - const bindField = (inputSel, key, previewSel) => { + const bindField = (inputSel, key, previewSel, formatter) => { const el = $(inputSel); if (!el) return; + const formatPreview = + typeof formatter === "function" ? formatter : (value) => value; // Init value - if (state[key] !== undefined && state[key] !== null && state[key] !== '') { + 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]); + el.addEventListener("input", () => { + state[key] = el.type === "number" ? Number(el.value || 0) : el.value; + if (previewSel) updatePreviewText(previewSel, formatPreview(state[key])); save(); - if (['vatRate', 'discountRate', 'currency'].includes(key)) computeAndRender(); + if (["vatRate", "discountRate", "currency", "quoteDate"].includes(key)) + computeAndRender(); }); - if (previewSel) updatePreviewText(previewSel, state[key] || ''); + if (previewSel) + updatePreviewText(previewSel, formatPreview(state[key] || "")); }; const updatePreviewText = (sel, val) => { const tgt = $(sel); if (!tgt) return; - const text = (val ?? '').toString(); + const text = (val ?? "").toString(); tgt.textContent = text; - const row = tgt.closest('.icon-text'); + const row = tgt.closest(".icon-text"); if (row) { const visible = text.trim().length > 0; - row.style.display = visible ? '' : 'none'; + row.style.display = visible ? "" : "none"; } }; @@ -195,47 +243,48 @@ const country = state[`${prefix}Country`]; const parts = []; if (street) parts.push(street); - const line2 = [pc, city].filter(Boolean).join(' '); + 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']; + const legacy = state[prefix === "my" ? "myAddress" : "clientAddress"]; if (legacy) return legacy; } - return parts.join(', '); + return parts.join(", "); }; const updateAddressPreview = (prefix) => { - if (prefix === 'my') updatePreviewText('#p_myAddress', joinAddress('my')); - if (prefix === 'client') updatePreviewText('#p_clientAddress', joinAddress('client')); + if (prefix === "my") updatePreviewText("#p_myAddress", joinAddress("my")); + if (prefix === "client") + updatePreviewText("#p_clientAddress", joinAddress("client")); }; const updateLogo = () => { - const img = $('#p_myLogo'); + const img = $("#p_myLogo"); if (!img) return; if (state.myLogo) { img.src = state.myLogo; - img.style.display = ''; + img.style.display = ""; } else { - img.removeAttribute('src'); - img.style.display = 'none'; + img.src = DEFAULT_LOGO; + img.style.display = ""; } }; - const addItem = (item = { description: '', qty: 1, unitPrice: 0 }) => { + const addItem = (item = { description: "", qty: 1, unitPrice: 0 }) => { // Push to state state.items.push({ - description: item.description || '', + description: item.description || "", qty: Number(item.qty || 1), - unitPrice: Number(item.unitPrice || 0) + unitPrice: Number(item.unitPrice || 0), }); renderItemsForm(); computeAndRender(); save(); }; - const addGroup = (title = '') => { - state.items.push({ type: 'group', title: title || '' }); + const addGroup = (title = "") => { + state.items.push({ type: "group", title: title || "" }); renderItemsForm(); computeAndRender(); save(); @@ -249,30 +298,34 @@ }; const getSelectedIndices = () => { - const wrap = $('#items'); + const wrap = $("#items"); if (!wrap) return []; - return Array.from(wrap.querySelectorAll('.table-row')) + return Array.from(wrap.querySelectorAll(".table-row")) .map((row, i) => ({ row, i })) - .filter(({ row }) => row.querySelector('.row-select')?.checked) + .filter(({ row }) => row.querySelector(".row-select")?.checked) .map(({ i }) => i); }; const renderItemsForm = () => { - const wrap = $('#items'); + const wrap = $("#items"); if (!wrap) return; // preserve selection across re-render const previouslySelected = getSelectedIndices(); - wrap.innerHTML = ''; + 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') { + 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 = ` - - + +
@@ -284,9 +337,15 @@ } else { row.innerHTML = ` - - - + + +
${formatMoney(it.qty * it.unitPrice)}
@@ -296,56 +355,92 @@ } // 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(); }); + 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'; }; + 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(); }); + 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)); + 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)); + 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'; + 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'; + 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); + setTimeout(() => { + try { + document.body.removeChild(ghost); + } catch {} + }, 0); }); - handleBtn.addEventListener('dragend', () => { - row.classList.remove('dragging'); + handleBtn.addEventListener("dragend", () => { + row.classList.remove("dragging"); // Remove placeholder if exists - if (placeholder && placeholder.parentElement) placeholder.parentElement.removeChild(placeholder); + if (placeholder && placeholder.parentElement) + placeholder.parentElement.removeChild(placeholder); placeholder = null; }); } @@ -355,9 +450,13 @@ // Attach container-level handlers once if (!wrap.dataset.dndBound) { const getAfterElement = (container, y) => { - const els = [...container.querySelectorAll('.table-row:not(.dragging):not(.placeholder)')]; + const els = [ + ...container.querySelectorAll( + ".table-row:not(.dragging):not(.placeholder)" + ), + ]; let closest = { offset: Number.NEGATIVE_INFINITY, element: null }; - els.forEach(el => { + els.forEach((el) => { const box = el.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) { @@ -367,9 +466,9 @@ return closest.element; }; - wrap.addEventListener('dragover', (e) => { + wrap.addEventListener("dragover", (e) => { e.preventDefault(); - e.dataTransfer && (e.dataTransfer.dropEffect = 'move'); + e.dataTransfer && (e.dataTransfer.dropEffect = "move"); // Move placeholder to the calculated position if (placeholder) { const afterEl = getAfterElement(wrap, e.clientY); @@ -380,18 +479,24 @@ } } }); - wrap.addEventListener('drop', (e) => { + wrap.addEventListener("drop", (e) => { e.preventDefault(); - const dragging = wrap.querySelector('.table-row.dragging'); + const dragging = wrap.querySelector(".table-row.dragging"); if (!dragging) return; - const fromIndex = Number(dragging.getAttribute('data-index')); + const fromIndex = Number(dragging.getAttribute("data-index")); const afterEl = getAfterElement(wrap, e.clientY); - const rows = [...wrap.querySelectorAll('.table-row')]; + 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; } + 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; } + if (insertAt === fromIndex) { + dragging.classList.remove("dragging"); + return; + } // Move in state const [moved] = state.items.splice(fromIndex, 1); state.items.splice(insertAt, 0, moved); @@ -399,60 +504,77 @@ renderItemsForm(); computeAndRender(); // Cleanup placeholder - if (placeholder && placeholder.parentElement) placeholder.parentElement.removeChild(placeholder); + if (placeholder && placeholder.parentElement) + placeholder.parentElement.removeChild(placeholder); placeholder = null; }); - wrap.dataset.dndBound = '1'; + wrap.dataset.dndBound = "1"; } }; const renderPreviewItems = () => { - const wrap = $('#p_items'); + const wrap = $("#p_items"); if (!wrap) return; - wrap.innerHTML = ''; + 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'; + const sub = document.createElement("div"); + sub.className = "items-row group-subtotal"; sub.innerHTML = ` -
${groupLabel ? 'Sous-total — ' + escapeHtml(groupLabel) : 'Sous-total'}
-
${formatMoney(groupSum)}
+
${ + groupLabel ? "Sous-total — " + escapeHtml(groupLabel) : "Sous-total" + }
+
+
+
${formatMoney(groupSum)}
`; wrap.appendChild(sub); - groupSum = 0; groupHasRows = false; + groupSum = 0; + groupHasRows = false; }; state.items.forEach((it) => { - if (it.type === 'group') { + 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')}
`; + groupLabel = (it.title || "").toString(); + const gdesc = (it.description || "").toString().trim(); + const head = document.createElement("div"); + head.className = "items-row group-title"; + head.innerHTML = ` +
+
${escapeHtml( + groupLabel || "Groupe" + )}
+ ${ + gdesc + ? `
${escapeHtml(gdesc)}
` + : "" + } +
+
Temps
+
PU HT
+
Total HT
+ `; 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'; + 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)}
+
${formatMoney( + it.qty * it.unitPrice + )}
`; wrap.appendChild(row); const line = Number(it.qty || 0) * Number(it.unitPrice || 0); - groupSum += line; groupHasRows = true; + groupSum += line; + groupHasRows = true; }); // tail flush flushGroupSubtotal(); @@ -463,15 +585,19 @@ return Number.isInteger(val) ? String(val) : val.toFixed(2); }; - const escapeHtml = (s) => String(s || '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); + 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 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); @@ -481,10 +607,13 @@ const computeAndRender = () => { // Rows: update row totals - $$('#items .table-row').forEach((row, i) => { - const totalDiv = row.querySelector('.row-total'); + $$("#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)); + if (totalDiv && (!it.type || it.type !== "group")) + totalDiv.textContent = formatMoney( + Number(it.qty || 0) * Number(it.unitPrice || 0) + ); }); // Preview Items @@ -492,13 +621,16 @@ // 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)); + 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(); @@ -507,238 +639,302 @@ const applyInitialDefaults = () => { if (!state.quoteDate) state.quoteDate = todayISO(); if (!Array.isArray(state.items) || state.items.length === 0) { - state.items = [{ description: '', qty: 1, unitPrice: 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; + const container = document.getElementById("printPages"); + const previewPanel = document.getElementById("printArea"); + if (!container || !previewPanel) return; + container.style.display = "block"; + container.innerHTML = ""; - // 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 rows = Array.from( + previewPanel.querySelectorAll("#p_items > .items-row") + ); + if (!rows.length) return; + + const pxPerMm = 96 / 25.4; + const pageHeightPx = (297 - 2 * 8) * pxPerMm; // A4 minus page margins (8mm each side) + const safeFooter = 24; // espace réservé pour footer/numéro de page + + const header = previewPanel.querySelector(".quote-header"); + const titleBlock = previewPanel.querySelector(".quote-title-block"); + const sectionTitle = previewPanel.querySelector(".items-section-title"); + + const headerH = header?.getBoundingClientRect().height || 0; + const titleH = titleBlock?.getBoundingClientRect().height || 0; + const sectionTitleH = sectionTitle?.getBoundingClientRect().height || 0; + + const firstAvailable = Math.max( + 240, + pageHeightPx - headerH - titleH - sectionTitleH - safeFooter + ); + const otherAvailable = Math.max(240, pageHeightPx - safeFooter); + + const rowHeights = rows.map( + (row) => row.getBoundingClientRect().height || 48 + ); + + const pages = []; + let start = 0; + let current = 0; + let available = firstAvailable; + rows.forEach((row, idx) => { + const h = rowHeights[idx]; + if (current + h > available && current > 0) { + pages.push(rows.slice(start, idx)); + start = idx; + current = 0; + available = otherAvailable; } - 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; + current += h; }); - // 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 + if (start < rows.length) pages.push(rows.slice(start)); - // 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 totals = previewPanel.querySelector(".totals"); + const signature = previewPanel.querySelector(".signature-block"); + const validRow = previewPanel.querySelector(".quote-valid-row"); + const notes = previewPanel.querySelector(".notes"); - 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)}
- `; + const stripIds = (node) => { + if (!node) return; + if (node.id) node.removeAttribute("id"); + node.querySelectorAll("[id]").forEach((el) => el.removeAttribute("id")); + }; + + pages.forEach((slice, index) => { + const page = document.createElement("div"); + page.className = "print-page"; + + if (index === 0) { + if (header) { + const clone = header.cloneNode(true); + stripIds(clone); + page.appendChild(clone); } - body.appendChild(row); - }); + if (titleBlock) { + const clone = titleBlock.cloneNode(true); + stripIds(clone); + page.appendChild(clone); + } + if (sectionTitle) { + const clone = sectionTitle.cloneNode(true); + stripIds(clone); + page.appendChild(clone); + } + } + + const itemsWrap = document.createElement("div"); + itemsWrap.className = "items"; + const body = document.createElement("div"); + body.className = "items-body"; + slice.forEach((row) => body.appendChild(row.cloneNode(true))); + itemsWrap.appendChild(body); 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)); + if (index === pages.length - 1) { + if (totals) { + const clone = totals.cloneNode(true); + stripIds(clone); + page.appendChild(clone); + } + if (signature) { + const clone = signature.cloneNode(true); + stripIds(clone); + page.appendChild(clone); + } + if (validRow) { + const clone = validRow.cloneNode(true); + stripIds(clone); + page.appendChild(clone); + } + if (notes) { + const clone = notes.cloneNode(true); + stripIds(clone); + page.appendChild(clone); + } } - // Footer with page number - const footer = document.createElement('div'); - footer.className = 'page-footer'; - footer.textContent = `Page ${idx + 1}/${totalPages}`; + const footer = document.createElement("div"); + footer.className = "page-footer"; + footer.textContent = `Page ${index + 1} / ${pages.length}`; 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'); + 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(); }); + $("#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'); + 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'); + bindField("#quoteNumber", "quoteNumber", "#p_quoteNumber"); + bindField("#quoteDate", "quoteDate", "#p_quoteDate", formatDisplayDate); + bindField( + "#quoteValidUntil", + "quoteValidUntil", + "#p_quoteValidUntil", + formatDisplayDate + ); // Divers - bindField('#discountRate', 'discountRate'); - bindField('#paymentTerms', 'paymentTerms', '#p_paymentTerms'); - bindField('#notes', 'notes', '#p_notes'); + bindField("#discountRate", "discountRate"); + bindField("#paymentTerms", "paymentTerms", "#p_paymentTerms"); + bindField("#notes", "notes", "#p_notes"); }; const bindParams = () => { - const cur = $('#currency'); - const vat = $('#vatRate'); - const tpl = $('#printTemplate'); + 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(); }); + 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(); }); + 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(); }); + tpl.addEventListener("change", () => { + state.printTemplate = tpl.value || "standard"; + applyTemplate(); + save(); + computeAndRender(); + }); } }; const applyTemplate = () => { - document.body.setAttribute('data-template', state.printTemplate || 'standard'); + document.body.setAttribute( + "data-template", + state.printTemplate || "standard" + ); }; const bindButtons = () => { - $('#addItemBtn')?.addEventListener('click', () => addItem()); - $('#addGroupBtn')?.addEventListener('click', () => addGroup()); - $('#printBtn')?.addEventListener('click', () => window.print()); + $("#addItemBtn")?.addEventListener("click", () => addItem()); + $("#addGroupBtn")?.addEventListener("click", () => addGroup()); + $("#printBtn")?.addEventListener("click", () => { + buildPrintPages(); + window.print(); + }); // Save current quote - $('#saveQuoteBtn')?.addEventListener('click', () => saveCurrentQuote()); + $("#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(); }); + $("#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'); + const exportBtn = $("#exportJsonBtn"); if (exportBtn) { - exportBtn.addEventListener('click', () => { + exportBtn.addEventListener("click", () => { try { const data = buildExportJson(); - const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); + 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'; + 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)); + alert("Export JSON impossible: " + (e.message || e)); } }); } // Bulk actions const getCheckedIndices = () => { - const wrap = $('#items'); - const rows = wrap ? [...wrap.querySelectorAll('.table-row')] : []; + 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); }); + rows.forEach((row, i) => { + if (row.querySelector(".row-select")?.checked) indices.push(i); + }); return indices; }; - $('#duplicateSelectedBtn')?.addEventListener('click', () => { + $("#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) }; + 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; }); @@ -746,8 +942,8 @@ renderItemsForm(); computeAndRender(); }); - $('#deleteSelectedBtn')?.addEventListener('click', () => { - const idxs = getCheckedIndices().sort((a,b) => b - a); + $("#deleteSelectedBtn")?.addEventListener("click", () => { + const idxs = getCheckedIndices().sort((a, b) => b - a); if (!idxs.length) return; idxs.forEach((i) => state.items.splice(i, 1)); save(); @@ -755,18 +951,20 @@ computeAndRender(); }); // Select all toggle - const selectAll = $('#selectAllRows'); + const selectAll = $("#selectAllRows"); if (selectAll) { - selectAll.addEventListener('change', () => { - $$('#items .row-select').forEach(cb => { cb.checked = selectAll.checked; }); + selectAll.addEventListener("change", () => { + $$("#items .row-select").forEach((cb) => { + cb.checked = selectAll.checked; + }); }); } // Import JSON - const importBtn = $('#importJsonBtn'); - const importInput = $('#importJsonInput'); + const importBtn = $("#importJsonBtn"); + const importInput = $("#importJsonInput"); if (importBtn && importInput) { - importBtn.addEventListener('click', () => importInput.click()); - importInput.addEventListener('change', async () => { + importBtn.addEventListener("click", () => importInput.click()); + importInput.addEventListener("change", async () => { const file = importInput.files && importInput.files[0]; if (!file) return; try { @@ -774,36 +972,39 @@ const obj = JSON.parse(text); loadFromJson(obj); } catch (e) { - alert('Impossible d\'importer le JSON: ' + (e.message || e)); + alert("Impossible d'importer le JSON: " + (e.message || e)); } finally { - importInput.value = ''; + importInput.value = ""; } }); } - $('#resetBtn')?.addEventListener('click', () => { - if (!confirm('Réinitialiser le devis ?')) return; - try { localStorage.removeItem(persistKey); } catch {} + $("#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] = ''; + 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 = ''; + $$("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 = ''; + el.value = ""; } }); renderItemsForm(); @@ -816,18 +1017,46 @@ 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' + "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 || '' }; + 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 || ''), + description: it?.description || "", days: Number(it?.qty || 0), - unitPrice: Number(it?.unitPrice || 0) + unitPrice: Number(it?.unitPrice || 0), }; }); return out; @@ -835,33 +1064,61 @@ // --- 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' + "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) => { + $$("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') { + 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 })); + 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'); + updateAddressPreview("my"); + updateAddressPreview("client"); updateLogo(); }; const loadFromJson = (obj) => { - if (!obj || typeof obj !== 'object') throw new Error('JSON invalide'); + 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]; @@ -869,13 +1126,17 @@ // 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() }; + 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(), + description: (it.description ?? "").toString(), qty: Number(it.days ?? it.qty ?? 0), - unitPrice: Number(it.unitPrice ?? it.unit_price ?? 0) + unitPrice: Number(it.unitPrice ?? it.unit_price ?? 0), }; }); save(); @@ -887,37 +1148,54 @@ // --- Autocomplete Adresses (API Adresse BAN) --- const debounce = (fn, ms = 250) => { - let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; + 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; + const el = + typeof selector === "string" + ? selector.startsWith("#") + ? $(selector) + : $("#" + selector) + : selector; if (!el) return; - const parent = el.closest('label') || el.parentElement; + const parent = el.closest("label") || el.parentElement; if (!parent) return; - parent.style.position = parent.style.position || 'relative'; + parent.style.position = parent.style.position || "relative"; - const list = document.createElement('div'); - list.className = 'ac-list'; + 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 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) => { + 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')); + const i = Number(it.getAttribute("data-i")); select(i); }); }); @@ -927,7 +1205,7 @@ if (!suggestions[i]) return; const s = suggestions[i]; el.value = s.label; - el.dispatchEvent(new Event('input', { bubbles: true })); + el.dispatchEvent(new Event("input", { bubbles: true })); if (prefix && s.props) { setAddressFromProps(prefix, s.props); } @@ -935,70 +1213,109 @@ }; const search = debounce(async () => { - const term = (el.value || '').trim(); - if (term.length < 3) { suggestions = []; render(); hide(); return; } + 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 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); + 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 || {} })); + 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 + if (e.name === "AbortError") return; // nouvelle requête suggestions = []; render(); hide(); } }, 250); - el.addEventListener('input', search); - el.addEventListener('keydown', (e) => { + 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(); } + 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)); + 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; + 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; + const parent = el.closest("label") || el.parentElement; if (!parent) return; - parent.style.position = parent.style.position || 'relative'; + parent.style.position = parent.style.position || "relative"; - const list = document.createElement('div'); - list.className = 'ac-list'; + 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 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) => { + 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')); + const i = Number(it.getAttribute("data-i")); select(i); }); }); @@ -1009,13 +1326,18 @@ const s = suggestions[i]; // Remplit le nom el.value = s.company.nom_complet || s.title; - el.dispatchEvent(new Event('input', { bubbles: true })); + 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'; + 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; @@ -1033,56 +1355,81 @@ if (countryEl) countryEl.value = country; updateAddressPreview(prefix); - updatePreviewText('#p_clientName', state[`${prefix}Name`] || ''); + 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; } + 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 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); + 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 + 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; + if (e.name === "AbortError") return; suggestions = []; render(); hide(); } }, 250); - el.addEventListener('input', search); - el.addEventListener('keydown', (e) => { + 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(); } + 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)); + el.addEventListener("blur", () => setTimeout(hide, 120)); }; const setAddressFromProps = (prefix, props) => { - const streetName = props.street || props.name || ''; - const housenumber = props.housenumber ? props.housenumber + ' ' : ''; + 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'; + const city = props.city || ""; + const postcode = props.postcode || ""; + const country = "France"; state[`${prefix}Street`] = streetFull; state[`${prefix}City`] = city; @@ -1107,56 +1454,61 @@ 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("beforeprint", () => { + const num = (state.quoteNumber || "").toString().trim(); + document.title = num ? `Devis ${num}` : "Devis"; + // Assure que les pages d'impression sont reconstruites juste avant l'aperçu + buildPrintPages(); }); - window.addEventListener('afterprint', () => { + 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); + $("#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' }); + setupAddressAutocomplete("#myStreet", { prefix: "my" }); + setupAddressAutocomplete("#clientStreet", { prefix: "client" }); // Autocomplete entreprises sur Nom/Société (client) - setupCompanyAutocomplete('#clientName', { prefix: 'client' }); + setupCompanyAutocomplete("#clientName", { prefix: "client" }); bindButtons(); renderItemsForm(); computeAndRender(); - updateAddressPreview('my'); - updateAddressPreview('client'); + updateAddressPreview("my"); + updateAddressPreview("client"); save(); }; - document.addEventListener('DOMContentLoaded', init); + document.addEventListener("DOMContentLoaded", init); })(); diff --git a/index.html b/index.html index e94b2ad..da32938 100644 --- a/index.html +++ b/index.html @@ -13,14 +13,43 @@

Générateur de Devis

- - - - - - + + + + + +
- +
@@ -28,7 +57,8 @@

Paramètres

-

Apparence

-

Client

-
-
- - - - +
+ + + +
-
-
@@ -239,14 +389,14 @@ - - - + diff --git a/sample-devis-1.png b/sample-devis-1.png new file mode 100644 index 0000000..d158c49 Binary files /dev/null and b/sample-devis-1.png differ diff --git a/sample-devis.pdf b/sample-devis.pdf new file mode 100644 index 0000000..f32c7e0 Binary files /dev/null and b/sample-devis.pdf differ diff --git a/styles.css b/styles.css index 6b3337f..53ae18c 100644 --- a/styles.css +++ b/styles.css @@ -1,3 +1,5 @@ +@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap"); + :root { --bg: #0b0f14; --panel: #121822; @@ -10,28 +12,81 @@ --border: #223041; } -* { box-sizing: border-box; } -html, body { height: 100%; } +* { + 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"; + font-family: "Poppins", sans-serif; background: linear-gradient(180deg, var(--bg), #0d1420 50%, var(--bg)); color: var(--text); } -.app-header, .app-footer { +.app-header, +.app-footer { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; - background: rgba(18,24,34,0.8); + 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; } +.app-footer { + border-top: 1px solid var(--border); + border-bottom: none; + justify-content: space-between; +} +.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; +} +.app-footer .page-counter { + display: none; + font-weight: 500; + opacity: 0.8; +} + +.print-pages { + display: none; +} +.print-page { + width: 100%; + box-sizing: border-box; + background: #ffffff; + border: 1px solid #d5ddf4; + border-radius: 16px; + padding: 24px 28px 48px; + margin-bottom: 24px; + box-shadow: none; +} +.print-page .page-footer { + margin-top: 32px; + text-align: right; + font-size: 9pt; + font-weight: 500; + color: #1f3fae; +} .container { max-width: none; @@ -49,9 +104,19 @@ body { padding: 16px; } -h2, h3 { margin: 8px 0 12px; } -label { display: block; font-size: 14px; color: var(--muted); margin-bottom: 10px; } -input, textarea, select { +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); @@ -60,251 +125,838 @@ input, textarea, select { 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; +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; } -.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; } +.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; } +.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; + 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); } +.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; } +.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); } +.items-body .items-row.group-title { + display: grid; + grid-template-columns: 2fr 80px 120px 120px; + background: transparent; + font-weight: 600; + padding: 12px 16px; + text-align: left; + font-size: 16px; + gap: 8px; + align-items: flex-start; + position: relative; +} +.items-body .items-row.group-title::after { + display: none; +} +.items-body .items-row.group-title .group-title-wrap { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; +} +.items-body .items-row.group-title .group-title-text { + text-transform: uppercase; + letter-spacing: 0.6px; +} +.items-body .items-row.group-title .group-desc-inline { + font-size: 10px; /* ~8pt */ + font-weight: 400; + font-family: "Poppins", sans-serif; + text-transform: none; +} +.items-body .items-row.group-title .group-col-label { + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.6px; + display: flex; + align-items: center; + justify-content: center; +} +.items-body .items-row.group-title .group-col-label-total { + justify-content: flex-end; +} +.items-body .items-row.group-subtotal { + display: grid; + grid-template-columns: 2fr 80px 120px 120px; + gap: 8px; + padding: 10px 16px; + font-weight: 600; + position: relative; +} +.items-body .items-row.group-subtotal .group-subtotal-label { + grid-column: 1 / span 3; +} +.items-body .items-row.group-subtotal .group-subtotal-value { + text-align: right; +} +.items-body .items-row.group-subtotal::before { + content: ""; + position: absolute; + left: var(--line-inset); + right: var(--line-inset); + top: 0; + height: var(--items-subtotal-line-thickness); + background: var(--items-subtotal-line-color); +} + +.row-total { + text-align: right; + padding-right: 6px; + color: #1f3fae; + font-weight: 600; +} /* 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; } +.preview-panel, +.preview-panel * { + color: #1f3fae !important; +} -.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; } +.preview-panel { + font-family: "Poppins", system-ui, -apple-system, Segoe UI, sans-serif; + background: #ffffff; + border: 1px solid #d5ddf4; + border-radius: 16px; + padding: 28px; + box-shadow: 0 18px 38px rgba(10, 25, 70, 0.12); +} -.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; } +.quote-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 24px; + padding-bottom: 18px; + margin-bottom: 12px; +} -.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; } +.company { + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.company-details { + display: flex; + flex-direction: column; + gap: 5px; +} + +.logo { + width: 96px; + height: 60px; + object-fit: contain; + background: transparent; + padding: 0; + align-self: flex-start; + margin-top: -8px; +} + +.company-name { + font-weight: 600; + font-size: 12pt; + letter-spacing: 0.4px; + text-transform: uppercase; + color: #163079; +} + +.company-address, +.company-contact, +.company-legal { + color: #5a6c96; + font-size: 9pt; + line-height: 12pt; + font-weight: 300; + font-family: "Poppins", sans-serif; +} + +.company-info-block { + font-weight: 300; + font-size: 9pt; + line-height: 12pt; +} + +.company-contact.stack { + display: grid; + gap: 6px; +} + +.header-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 18px; + text-align: right; +} + +.quote-dates { + display: grid; + justify-items: end; + gap: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 9pt; + color: #5a6c96; + text-align: right; +} + +.quote-date-label { + font-weight: 600; + color: #1f3fae; +} + +.quote-date-value { + font-weight: 500; + font-size: 11pt; + color: #1b2d53; + letter-spacing: 0.3px; + text-transform: none; + text-align: right; +} + +.quote-valid { + display: grid; + gap: 2px; + justify-items: end; +} + +.quote-valid-row { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; +} + +.client-block { + background: transparent; + border: none; + border-radius: 0; + padding: 0; + margin: 0; + text-align: right; + max-width: 260px; + font-family: "Poppins", sans-serif; + font-weight: 300; + font-size: 9pt; + line-height: 12pt; +} + +.client-block .muted { + font-size: 9pt; + font-weight: 300; + color: #1f3fae; + letter-spacing: 1px; + text-transform: uppercase; +} + +.client-name { + font-weight: 300; + font-size: 9pt; + color: #1b2d53; + margin-top: 4px; +} + +.client-address, +.client-contact { + color: #5a6c96; + font-size: 9pt; + line-height: 12pt; + font-weight: 300; + font-family: "Poppins", sans-serif; +} + +.client-block .client-info { + font-weight: 300; + font-size: 9pt; + line-height: 12pt; +} + +.client-address { + white-space: pre-wrap; +} + +.client-contact.stack { + display: grid; + gap: 4px; + justify-items: end; +} + +.quote-title-block { + text-align: center; + margin-bottom: 28px; + display: grid; +} + +.quote-title { + font-size: 28pt; + font-weight: 600; + color: #1f3fae; + text-transform: uppercase; +} + +.quote-number { + font-size: 11px; /* ~8pt */ + font-weight: 400; + color: #1b2d53; + letter-spacing: 1px; + text-transform: uppercase; +} + +.items-section-title { + position: relative; + padding: 12px 16px; + font-family: "Poppins", sans-serif; + font-weight: 600; + font-size: 12pt; + text-transform: uppercase; + letter-spacing: 0.8px; +} +.items-section-title::before, +.items-section-title::after { + content: ""; + position: absolute; + left: var(--line-inset, 16px); + right: var(--line-inset, 16px); + height: 1px; + background: #1f3fae; +} +.items-section-title::before { + top: 0; +} +.items-section-title::after { + bottom: 0; +} + +.items { + --line-inset: 16px; + --items-head-line-color: #d0daf5; + --items-row-line-color: #e6ecfa; + --items-subtotal-line-color: #dbe3f7; + --items-head-line-thickness: 2px; + --items-row-line-thickness: 1px; + --items-subtotal-line-thickness: 1px; + border: none; + border-radius: 0; + overflow: hidden; +} + +.items-head, +.items-row { + display: grid; + grid-template-columns: 2fr 80px 120px 120px; + gap: 8px; +} + +.items-head { + background: #ffffff; + color: #1f3fae; + padding: 14px 16px; + text-transform: uppercase; + letter-spacing: 0.6px; + position: relative; +} +.items-head::after { + content: ""; + position: absolute; + left: var(--line-inset); + right: var(--line-inset); + bottom: 0; + height: var(--items-head-line-thickness); + background: var(--items-head-line-color); +} + +.items-head div { + font-family: "Poppins", system-ui, -apple-system, Segoe UI, sans-serif; + font-size: 16px; /* 12pt */ + font-weight: 600; +} + +.items-body .items-row { + position: relative; + padding: 12px 16px; + font-size: 8pt; + font-weight: 400; +} +.items-body .items-row::after { + content: ""; + position: absolute; + left: var(--line-inset); + right: var(--line-inset); + bottom: 0; + height: var(--items-row-line-thickness); + background: var(--items-row-line-color); +} + +.items-body .items-row:nth-child(even) { + background: #ffffff; +} +.items-body .items-row:last-child::after { + display: none; +} + +.items-row > div { + display: flex; + align-items: center; + min-width: 0; +} +.items-row > div:nth-child(2), +.items-row > div:nth-child(3) { + justify-content: center; +} + +.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 */ +.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; } +.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; } +.totals { + margin-top: 36px; + padding: 0; + border: none; + background: transparent; + max-width: 280px; + margin-left: auto; + display: grid; + gap: 4px; +} -.notes { margin-top: 12px; color: var(--muted); } +.totals .row { + display: flex; + justify-content: space-between; + padding: 6px 0; + font-size: 8pt; + color: #1b2d53; + border-bottom: 1px solid #dbe3f7; +} + +.totals .row span:first-child { + color: #5a6c96; + text-transform: uppercase; + letter-spacing: 0.6px; +} + +.totals .grand { + font-size: 10pt; + font-weight: 600; + color: #1f3fae; + border-top: 2px solid #1f3fae; + border-bottom: none; + margin-top: 8px; + padding-top: 10px; +} + +.notes { + margin-top: 32px; + color: #5a6c96; + font-size: 8pt; + display: grid; + gap: 8px; +} + +.notes strong { + font-weight: 600; + color: #1f3fae; +} + +.signature-block { + margin-top: 32px; + margin-left: auto; + max-width: 320px; + border: 1px solid #dbe3f7; + border-radius: 0; + padding: 18px 20px; + font-size: 7pt; + color: #1b2d53; + text-align: center; + font-weight: 400; + background: #ffffff; +} /* 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; } +.icon-text { + display: inline-flex; + align-items: center; + gap: 8px; +} +.icon-text.right { + justify-content: flex-end; +} +.preview-panel .icon-text .ico { + display: none; +} +.preview-panel .icon-text { + gap: 4px; +} -/* 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; } +.ico { + display: inline-flex; + width: 16px; + height: 16px; + align-items: center; + justify-content: center; + filter: grayscale(0.3); + opacity: 0.9; +} @media (max-width: 980px) { - .container { grid-template-columns: 1fr; } + .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; + @page { + margin: 8mm; } - 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; } + body { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } - /* 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; } + .app-header, + .form-panel, + #resetBtn, + #addItemBtn { + display: none !important; + } - /* 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; } + .app-footer { + display: none !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; } + .preview-panel { + padding: 0 !important; + margin: 0 !important; + box-shadow: none !important; + border: none !important; + background: transparent !important; + } + + /* Affiche uniquement les pages prêtes à être imprimées */ + .preview-panel { + display: block !important; + } + .print-pages { + display: none !important; /* désactive la pagination custom, on laisse le navigateur paginer */ + } + + .print-page { + margin: 0 !important; + box-shadow: none !important; + border: 1px solid #d5ddf4 !important; + border-radius: 10px !important; + padding: 12mm 10mm 12mm !important; + background: #ffffff !important; + display: block; + min-height: auto; + page-break-inside: auto; + page-break-after: always; + break-after: page; + } + .print-page:last-child { + page-break-after: auto; + break-after: auto; + } + .print-page .page-footer { + margin-top: 16px; + padding-top: 10px; + text-align: right; + font-size: 9pt; + font-weight: 500; + color: #1f3fae; + } } /* ===== 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; } +body[data-template="pro-minimal"] .preview-panel .items { + border: none; + --items-head-line-color: var(--border); + --items-row-line-color: var(--border); + --items-subtotal-line-color: var(--border); +} +body[data-template="pro-minimal"] .preview-panel .items-head { + background: transparent; + color: var(--text); + --items-head-line-thickness: 2px; +} +body[data-template="pro-minimal"] .preview-panel .items-body .items-row { + 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; } - /* 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; } +body[data-template="pro-borders"] .preview-panel .totals { + border-width: 2px; + border-style: solid; + border-color: var(--border); + background: #0b111a; + --items-head-line-color: var(--border); + --items-row-line-color: var(--border); + --items-subtotal-line-color: var(--border); + --items-head-line-thickness: 2px; + --items-row-line-thickness: 1px; + --items-subtotal-line-thickness: 2px; +} +body[data-template="pro-borders"] .preview-panel .items-head { + background: #0d1420; +} +body[data-template="pro-borders"] .items-body .items-row { +} +body[data-template="pro-borders"] .items-body .items-row:last-child { +} +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; + font-weight: 700; } /* 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"] .preview-panel { + position: relative; + padding-left: 14px; } -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; } +body[data-template="sidebar-accent"] .preview-panel::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; } /* 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; } +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 { + border: none; +} +body[data-template="centered-minimal"] .preview-panel .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); } /* 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; } +body[data-template="pro-striped"] + .preview-panel + .items-body + .items-row:nth-child(even) { + background: rgba(255, 255, 255, 0.035); } - /* 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; } +body[data-template="compact"] .preview-panel .items-body .items-row { + padding: 6px 8px; +} +body[data-template="compact"] .preview-panel { + font-size: 13px; } - /* Autocomplete */ .ac-list { position: absolute; @@ -315,24 +967,85 @@ body[data-template="compact"] .print-page { font-size: 13px; } background: #0b111a; border: 1px solid var(--border); border-radius: 8px; - box-shadow: 0 6px 24px rgba(0,0,0,0.35); + 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; } +.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; } +.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; +}