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.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.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 = `
@@ -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