Files
DEVIS-GENERATOR/app.js
2025-12-08 09:16:42 +01:00

1508 lines
48 KiB
JavaScript

// 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 save = () => {
try {
localStorage.setItem(persistKey, JSON.stringify(state));
} catch {}
};
const load = () => {
try {
const raw = localStorage.getItem(persistKey);
if (raw) Object.assign(state, JSON.parse(raw));
} catch {}
};
// Saved quotes library helpers
const getSavedList = () => {
try {
return JSON.parse(localStorage.getItem(savedListKey) || "[]");
} catch {
return [];
}
};
const setSavedList = (arr) => {
try {
localStorage.setItem(savedListKey, JSON.stringify(arr));
} catch {}
};
const computeQuickTotal = (data) => {
const items = Array.isArray(data.items) ? data.items : [];
const subtotal = items.reduce(
(acc, it) =>
acc +
Number(it.days ?? it.qty ?? 0) *
Number(it.unitPrice ?? it.unit_price ?? 0),
0
);
const discount = subtotal * (Number(data.discountRate || 0) / 100);
const base = Math.max(0, subtotal - discount);
const vat = base * (Number(data.vatRate || 0) / 100);
return base + vat;
};
// (modèles de devis retirés)
const buildDefaultSaveTitle = () => {
const num = (state.quoteNumber || "").trim();
const client = (state.clientName || "").trim();
const date = state.quoteDate || todayISO();
return (
num || [client, date].filter(Boolean).join(" - ") || "Devis sans titre"
);
};
const saveCurrentQuote = () => {
const data = buildExportJson();
const input = prompt("Nom du devis", buildDefaultSaveTitle());
if (input === null) return; // Annulé => ne rien faire
const entry = {
id: Date.now().toString(36) + Math.random().toString(36).slice(2),
name: input.trim() || buildDefaultSaveTitle(),
number: data.quoteNumber || "",
clientName: data.clientName || "",
date: data.quoteDate || todayISO(),
total: computeQuickTotal(data),
savedAt: new Date().toISOString(),
data,
};
const list = getSavedList();
list.unshift(entry);
setSavedList(list);
alert("Devis enregistré.");
};
const openLibrary = () => {
const modal = document.getElementById("libraryModal");
const listEl = document.getElementById("libraryList");
const emptyEl = document.getElementById("libraryEmpty");
if (!modal || !listEl || !emptyEl) return;
const list = getSavedList();
listEl.innerHTML = "";
if (!list.length) {
emptyEl.style.display = "";
} else {
emptyEl.style.display = "none";
list.forEach((e) => {
const row = document.createElement("div");
row.className = "library-item";
const title = document.createElement("div");
title.innerHTML = `<div class="title">${escapeHtml(
e.name || "(sans nom)"
)}</div>
<div class="meta">#${escapeHtml(e.number || "—")} · ${escapeHtml(
e.clientName || "Client inconnu"
)} · ${escapeHtml(e.date || "")}</div>`;
const price = document.createElement("div");
price.textContent = formatMoney(e.total || 0);
const btnLoad = document.createElement("button");
btnLoad.className = "btn btn-primary";
btnLoad.textContent = "Charger";
btnLoad.addEventListener("click", () => {
loadFromJson(e.data);
closeLibrary();
});
const btnDel = document.createElement("button");
btnDel.className = "btn btn-danger";
btnDel.textContent = "Supprimer";
btnDel.addEventListener("click", () => {
if (!confirm("Supprimer ce devis enregistré ?")) return;
const cur = getSavedList().filter((x) => x.id !== e.id);
setSavedList(cur);
openLibrary();
});
row.appendChild(title);
row.appendChild(price);
row.appendChild(btnLoad);
row.appendChild(btnDel);
listEl.appendChild(row);
});
}
modal.setAttribute("aria-hidden", "false");
};
const closeLibrary = () => {
const modal = document.getElementById("libraryModal");
if (modal) modal.setAttribute("aria-hidden", "true");
};
const todayISO = () => new Date().toISOString().slice(0, 10);
const plusDaysISO = (days) => {
const d = new Date();
d.setDate(d.getDate() + days);
return d.toISOString().slice(0, 10);
};
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 = `
<button class="drag-handle" title="Déplacer" draggable="true">⠿</button>
<input class="group-title" type="text" placeholder="Titre du groupe" value="${escapeHtml(
it.title || ""
)}" />
<textarea class="group-desc" rows="2" placeholder="Description du groupe (optionnelle)">${escapeHtml(
it.description || ""
)}</textarea>
<div></div>
<div></div>
<div></div>
<div class="row-actions">
<input class="row-select" type="checkbox" />
<button class="btn btn-danger" title="Supprimer">✕</button>
</div>
`;
} else {
row.innerHTML = `
<button class="drag-handle" title="Déplacer" draggable="true">⠿</button>
<textarea class="desc" placeholder="Description" rows="3">${escapeHtml(
it.description
)}</textarea>
<input class="qty" type="number" min="0" step="0.01" value="${Number(
it.qty
)}" />
<input class="unitPrice" type="number" min="0" step="0.01" value="${Number(
it.unitPrice
)}" />
<div class="row-total">${formatMoney(it.qty * it.unitPrice)}</div>
<div class="row-actions">
<input class="row-select" type="checkbox" />
<button class="btn btn-danger" title="Supprimer">✕</button>
</div>
`;
}
// 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 = `
<div class="group-subtotal-label">${
groupLabel ? "Sous-total — " + escapeHtml(groupLabel) : "Sous-total"
}</div>
<div></div>
<div></div>
<div class="group-subtotal-value">${formatMoney(groupSum)}</div>
`;
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 = `
<div class="group-title-wrap">
<div class="group-title-text">${escapeHtml(
groupLabel || "Groupe"
)}</div>
${
gdesc
? `<div class="group-desc-inline">${escapeHtml(gdesc)}</div>`
: ""
}
</div>
<div class="group-col-label">Temps</div>
<div class="group-col-label">PU HT</div>
<div class="group-col-label group-col-label-total">Total HT</div>
`;
wrap.appendChild(head);
return;
}
const row = document.createElement("div");
row.className = "items-row";
row.innerHTML = `
<div>${escapeHtml(it.description)}</div>
<div>${numStr(it.qty)}</div>
<div>${formatMoney(it.unitPrice)}</div>
<div style="text-align:right">${formatMoney(
it.qty * it.unitPrice
)}</div>
`;
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
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());
// Library open/close
$("#openLibraryBtn")?.addEventListener("click", () => openLibrary());
$("#closeLibraryBtn")?.addEventListener("click", () => closeLibrary());
document
.querySelector("#libraryModal .modal-backdrop")
?.addEventListener("click", (e) => {
if (e.target?.dataset?.close) closeLibrary();
});
// (modèles de devis retirés)
// Export JSON
const exportBtn = $("#exportJsonBtn");
if (exportBtn) {
exportBtn.addEventListener("click", () => {
try {
const data = buildExportJson();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const num = (state.quoteNumber || "")
.toString()
.trim()
.replace(/\s+/g, "_");
a.download = num ? `devis_${num}.json` : "devis.json";
a.href = url;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
alert("Export JSON impossible: " + (e.message || e));
}
});
}
// Bulk actions
const getCheckedIndices = () => {
const wrap = $("#items");
const rows = wrap ? [...wrap.querySelectorAll(".table-row")] : [];
const indices = [];
rows.forEach((row, i) => {
if (row.querySelector(".row-select")?.checked) indices.push(i);
});
return indices;
};
$("#duplicateSelectedBtn")?.addEventListener("click", () => {
const idxs = getCheckedIndices();
if (!idxs.length) return;
// duplicate in ascending order, inserting after each original
let offset = 0;
idxs.forEach((i) => {
const src = state.items[i + offset];
const copy =
src && src.type === "group"
? {
type: "group",
title: src.title || "",
description: src.description || "",
}
: {
description: src.description || "",
qty: Number(src.qty || 0),
unitPrice: Number(src.unitPrice || 0),
};
state.items.splice(i + offset + 1, 0, copy);
offset += 1;
});
save();
renderItemsForm();
computeAndRender();
});
$("#deleteSelectedBtn")?.addEventListener("click", () => {
const idxs = getCheckedIndices().sort((a, b) => b - a);
if (!idxs.length) return;
idxs.forEach((i) => state.items.splice(i, 1));
save();
renderItemsForm();
computeAndRender();
});
// Select all toggle
const selectAll = $("#selectAllRows");
if (selectAll) {
selectAll.addEventListener("change", () => {
$$("#items .row-select").forEach((cb) => {
cb.checked = selectAll.checked;
});
});
}
// Import JSON
const importBtn = $("#importJsonBtn");
const importInput = $("#importJsonInput");
if (importBtn && importInput) {
importBtn.addEventListener("click", () => importInput.click());
importInput.addEventListener("change", async () => {
const file = importInput.files && importInput.files[0];
if (!file) return;
try {
const text = await file.text();
const obj = JSON.parse(text);
loadFromJson(obj);
} catch (e) {
alert("Impossible d'importer le JSON: " + (e.message || e));
} finally {
importInput.value = "";
}
});
}
$("#resetBtn")?.addEventListener("click", () => {
if (!confirm("Réinitialiser le devis ?")) return;
try {
localStorage.removeItem(persistKey);
} catch {}
// Reset state
Object.keys(state).forEach((k) => {
if (k === "currency") state[k] = "EUR";
else if (k === "vatRate") state[k] = 20;
else if (k === "discountRate") state[k] = 0;
else if (k === "items")
state[k] = [{ description: "", qty: 1, unitPrice: 0 }];
else if (k.endsWith("Country")) state[k] = "France";
else state[k] = "";
});
// Reset inputs
$$("input, textarea, select").forEach((el) => {
if (el.id === "currency") el.value = "EUR";
else if (el.id === "vatRate") el.value = "20";
else if (el.id === "discountRate") el.value = "0";
else if (el.id && el.id.endsWith("Country")) el.value = "France";
else if (el.type === "date") {
if (el.id === "quoteDate") el.value = todayISO();
else if (el.id === "quoteValidUntil") el.value = "";
else el.value = "";
} else {
el.value = "";
}
});
renderItemsForm();
mirrorSimpleFields();
updateLogo();
computeAndRender();
save();
});
};
const buildExportJson = () => {
const simpleKeys = [
"currency",
"vatRate",
"myName",
"myStreet",
"myPostcode",
"myCity",
"myCountry",
"myEmail",
"myPhone",
"myLogo",
"myLegal",
"clientName",
"clientStreet",
"clientPostcode",
"clientCity",
"clientCountry",
"clientEmail",
"clientPhone",
"quoteNumber",
"quoteDate",
"quoteValidUntil",
"discountRate",
"paymentTerms",
"notes",
];
const out = {};
simpleKeys.forEach((k) => {
if (state[k] !== undefined) out[k] = state[k];
});
out.items = (state.items || []).map((it) => {
if (it && it.type === "group")
return {
type: "group",
title: it.title || "",
description: it.description || "",
};
return {
description: it?.description || "",
days: Number(it?.qty || 0),
unitPrice: Number(it?.unitPrice || 0),
};
});
return out;
};
// --- Import JSON ---
const keysAllowed = [
"currency",
"vatRate",
"myName",
"myStreet",
"myPostcode",
"myCity",
"myCountry",
"myEmail",
"myPhone",
"myLogo",
"myLegal",
"clientName",
"clientStreet",
"clientPostcode",
"clientCity",
"clientCountry",
"clientEmail",
"clientPhone",
"quoteNumber",
"quoteDate",
"quoteValidUntil",
"discountRate",
"paymentTerms",
"notes",
];
const syncInputsFromState = () => {
$$("input, textarea, select").forEach((el) => {
const id = el.id;
if (!id || !(id in state)) return;
if (id === "currency") {
el.value = state[id] || "EUR";
el.dispatchEvent(new Event("change", { bubbles: true }));
} else if (id === "vatRate" || id === "discountRate") {
el.value = state[id] ?? 0;
el.dispatchEvent(new Event("input", { bubbles: true }));
} else if (
el.type === "date" ||
el.type === "text" ||
el.type === "email" ||
el.type === "tel" ||
el.type === "number" ||
el.tagName === "TEXTAREA"
) {
el.value = state[id] ?? "";
el.dispatchEvent(new Event("input", { bubbles: true }));
}
});
updateAddressPreview("my");
updateAddressPreview("client");
updateLogo();
};
const loadFromJson = (obj) => {
if (!obj || typeof obj !== "object") throw new Error("JSON invalide");
// Merge top-level simple keys
keysAllowed.forEach((k) => {
if (Object.prototype.hasOwnProperty.call(obj, k)) state[k] = obj[k];
});
// Items mapping
const items = Array.isArray(obj.items) ? obj.items : [];
state.items = items.map((it) => {
if ((it.type || "").toString() === "group") {
return {
type: "group",
title: (it.title ?? it.label ?? "").toString(),
description: (it.description ?? it.desc ?? "").toString(),
};
}
return {
description: (it.description ?? "").toString(),
qty: Number(it.days ?? it.qty ?? 0),
unitPrice: Number(it.unitPrice ?? it.unit_price ?? 0),
};
});
save();
renderItemsForm();
renderPreviewItems();
computeAndRender();
syncInputsFromState();
};
// --- Autocomplete Adresses (API Adresse BAN) ---
const debounce = (fn, ms = 250) => {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), ms);
};
};
const setupAddressAutocomplete = (selector, { prefix } = {}) => {
const el =
typeof selector === "string"
? selector.startsWith("#")
? $(selector)
: $("#" + selector)
: selector;
if (!el) return;
const parent = el.closest("label") || el.parentElement;
if (!parent) return;
parent.style.position = parent.style.position || "relative";
const list = document.createElement("div");
list.className = "ac-list";
parent.appendChild(list);
let suggestions = [];
let active = -1;
let controller = null;
const hide = () => {
list.style.display = "none";
active = -1;
};
const show = () => {
list.style.display = suggestions.length ? "block" : "none";
};
const render = () => {
list.innerHTML = suggestions
.map(
(s, i) =>
`<div class="ac-item${
i === active ? " active" : ""
}" data-i="${i}">${escapeHtml(s.label)}</div>`
)
.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) =>
`<div class="ac-item${
i === active ? " active" : ""
}" data-i="${i}">` +
`${escapeHtml(s.title)}` +
(s.subtitle
? `<div style="color:#9aa7b3;font-size:12px;">${escapeHtml(
s.subtitle
)}</div>`
: "") +
`</div>`
)
.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);
})();