1515 lines
48 KiB
JavaScript
1515 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, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
|
|
const computeTotals = () => {
|
|
const subtotal = state.items.reduce(
|
|
(acc, it) => acc + Number(it.qty || 0) * Number(it.unitPrice || 0),
|
|
0
|
|
);
|
|
const discount = subtotal * (Number(state.discountRate || 0) / 100);
|
|
const base = Math.max(0, subtotal - discount);
|
|
const vat = base * (Number(state.vatRate || 0) / 100);
|
|
const total = base + vat;
|
|
return { subtotal, discount, vat, total };
|
|
};
|
|
|
|
const computeAndRender = () => {
|
|
// Rows: update row totals
|
|
$$("#items .table-row").forEach((row, i) => {
|
|
const totalDiv = row.querySelector(".row-total");
|
|
const it = state.items[i];
|
|
if (totalDiv && (!it.type || it.type !== "group"))
|
|
totalDiv.textContent = formatMoney(
|
|
Number(it.qty || 0) * Number(it.unitPrice || 0)
|
|
);
|
|
});
|
|
|
|
// Preview Items
|
|
renderPreviewItems();
|
|
|
|
// Totals
|
|
const { subtotal, discount, vat, total } = computeTotals();
|
|
const totalDays = state.items.reduce(
|
|
(acc, it) => acc + Number(it.qty || 0),
|
|
0
|
|
);
|
|
updatePreviewText("#p_subtotal", formatMoney(subtotal));
|
|
updatePreviewText("#p_discount", `- ${formatMoney(discount)}`);
|
|
updatePreviewText("#p_vatRate", numStr(state.vatRate));
|
|
updatePreviewText("#p_vat", formatMoney(vat));
|
|
updatePreviewText("#p_total", formatMoney(total));
|
|
updatePreviewText("#p_totalDays", numStr(totalDays));
|
|
|
|
// Rebuild print pages preview structure (for print)
|
|
buildPrintPages();
|
|
};
|
|
|
|
const applyInitialDefaults = () => {
|
|
if (!state.quoteDate) state.quoteDate = todayISO();
|
|
if (!Array.isArray(state.items) || state.items.length === 0) {
|
|
state.items = [{ description: "", qty: 1, unitPrice: 0 }];
|
|
}
|
|
};
|
|
|
|
// --- Print pagination (one table per page) ---
|
|
const buildPrintPages = () => {
|
|
const container = document.getElementById("printPages");
|
|
const previewPanel = document.getElementById("printArea");
|
|
if (!container || !previewPanel) return;
|
|
container.style.display = "block";
|
|
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", () => {
|
|
buildPrintPages();
|
|
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";
|
|
// Assure que les pages d'impression sont reconstruites juste avant l'aperçu
|
|
buildPrintPages();
|
|
});
|
|
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);
|
|
})();
|