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