Files
DEVIS-GENERATOR/app.js
Matthieu 952a43059b devis v1
2025-10-30 10:30:27 +01:00

1163 lines
46 KiB
JavaScript

// Générateur de Devis — logique principale
(() => {
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
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);
};
// UI bindings
const bindField = (inputSel, key, previewSel) => {
const el = $(inputSel);
if (!el) return;
// 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, state[key]);
save();
if (['vatRate', 'discountRate', 'currency'].includes(key)) computeAndRender();
});
if (previewSel) updatePreviewText(previewSel, 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.removeAttribute('src');
img.style.display = 'none';
}
};
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>${groupLabel ? 'Sous-total — ' + escapeHtml(groupLabel) : 'Sous-total'}</div>
<div>${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 head = document.createElement('div');
head.className = 'items-row group-title';
head.innerHTML = `<div>${escapeHtml(groupLabel || 'Groupe')}</div>`;
wrap.appendChild(head);
const gdesc = (it.description || '').toString().trim();
if (gdesc) {
const drow = document.createElement('div');
drow.className = 'items-row group-description';
drow.innerHTML = `<div>${escapeHtml(gdesc)}</div>`;
wrap.appendChild(drow);
}
return;
}
const row = document.createElement('div');
row.className = 'items-row';
row.innerHTML = `
<div>${escapeHtml(it.description)}</div>
<div>${numStr(it.qty)}</div>
<div>${formatMoney(it.unitPrice)}</div>
<div style="text-align:right">${formatMoney(it.qty * it.unitPrice)}</div>
`;
wrap.appendChild(row);
const line = Number(it.qty || 0) * Number(it.unitPrice || 0);
groupSum += line; groupHasRows = true;
});
// tail flush
flushGroupSubtotal();
};
const numStr = (n) => {
const val = Number(n || 0);
return Number.isInteger(val) ? String(val) : val.toFixed(2);
};
const escapeHtml = (s) => String(s || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
const computeTotals = () => {
const subtotal = state.items.reduce((acc, it) => acc + Number(it.qty || 0) * Number(it.unitPrice || 0), 0);
const discount = subtotal * (Number(state.discountRate || 0) / 100);
const base = Math.max(0, subtotal - discount);
const vat = base * (Number(state.vatRate || 0) / 100);
const total = base + vat;
return { subtotal, discount, vat, total };
};
const computeAndRender = () => {
// Rows: update row totals
$$('#items .table-row').forEach((row, i) => {
const totalDiv = row.querySelector('.row-total');
const it = state.items[i];
if (totalDiv && (!it.type || it.type !== 'group')) totalDiv.textContent = formatMoney(Number(it.qty || 0) * Number(it.unitPrice || 0));
});
// Preview Items
renderPreviewItems();
// Totals
const { subtotal, discount, vat, total } = computeTotals();
const totalDays = state.items.reduce((acc, it) => acc + Number(it.qty || 0), 0);
updatePreviewText('#p_subtotal', formatMoney(subtotal));
updatePreviewText('#p_discount', `- ${formatMoney(discount)}`);
updatePreviewText('#p_vatRate', numStr(state.vatRate));
updatePreviewText('#p_vat', formatMoney(vat));
updatePreviewText('#p_total', formatMoney(total));
updatePreviewText('#p_totalDays', numStr(totalDays));
// 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');
if (!container) return;
container.innerHTML = '';
const items = Array.isArray(state.items) ? state.items : [];
if (!items.length) return;
// 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 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;
});
// 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
// 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 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 = `
<div class="items-head">
<div>Description</div><div>Temps (jours)</div><div>PU HT</div><div>Total HT</div>
</div>
<div class="items-body"></div>
`;
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 = `<div>${escapeHtml(r.label || 'Groupe')}</div>`;
} else if (r.kind === 'group-description') {
row.className = 'items-row group-description';
row.innerHTML = `<div>${escapeHtml(r.description || '')}</div>`;
} else if (r.kind === 'group-subtotal') {
row.className = 'items-row group-subtotal';
const label = r.label ? 'Sous-total — ' + r.label : 'Sous-total';
row.innerHTML = `<div>${escapeHtml(label)}</div><div>${formatMoney(r.amount || 0)}</div>`;
} else {
row.className = 'items-row';
row.innerHTML = `
<div>${escapeHtml(r.description)}</div>
<div>${numStr(r.qty)}</div>
<div>${formatMoney(r.unitPrice)}</div>
<div style="text-align:right">${formatMoney(r.total)}</div>
`;
}
body.appendChild(row);
});
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));
}
// Footer with page number
const footer = document.createElement('div');
footer.className = 'page-footer';
footer.textContent = `Page ${idx + 1}/${totalPages}`;
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');
bindField('#quoteValidUntil', 'quoteValidUntil', '#p_quoteValidUntil');
// Divers
bindField('#discountRate', 'discountRate');
bindField('#paymentTerms', 'paymentTerms', '#p_paymentTerms');
bindField('#notes', 'notes', '#p_notes');
};
const bindParams = () => {
const cur = $('#currency');
const vat = $('#vatRate');
const tpl = $('#printTemplate');
if (cur) {
if (state.currency) cur.value = state.currency;
cur.addEventListener('change', () => { state.currency = cur.value; save(); computeAndRender(); });
}
if (vat) {
if (state.vatRate != null) vat.value = state.vatRate;
vat.addEventListener('input', () => { state.vatRate = Number(vat.value || 0); save(); computeAndRender(); });
}
if (tpl) {
if (state.printTemplate) tpl.value = state.printTemplate;
tpl.addEventListener('change', () => { state.printTemplate = tpl.value || 'standard'; applyTemplate(); save(); computeAndRender(); });
}
};
const applyTemplate = () => {
document.body.setAttribute('data-template', state.printTemplate || 'standard');
};
const bindButtons = () => {
$('#addItemBtn')?.addEventListener('click', () => addItem());
$('#addGroupBtn')?.addEventListener('click', () => addGroup());
$('#printBtn')?.addEventListener('click', () => window.print());
// Save current quote
$('#saveQuoteBtn')?.addEventListener('click', () => saveCurrentQuote());
// Library open/close
$('#openLibraryBtn')?.addEventListener('click', () => openLibrary());
$('#closeLibraryBtn')?.addEventListener('click', () => closeLibrary());
document.querySelector('#libraryModal .modal-backdrop')?.addEventListener('click', (e) => { if (e.target?.dataset?.close) closeLibrary(); });
// (modèles de devis retirés)
// Export JSON
const exportBtn = $('#exportJsonBtn');
if (exportBtn) {
exportBtn.addEventListener('click', () => {
try {
const data = buildExportJson();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const num = (state.quoteNumber || '').toString().trim().replace(/\s+/g, '_');
a.download = num ? `devis_${num}.json` : 'devis.json';
a.href = url;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (e) {
alert('Export JSON impossible: ' + (e.message || e));
}
});
}
// Bulk actions
const getCheckedIndices = () => {
const wrap = $('#items');
const rows = wrap ? [...wrap.querySelectorAll('.table-row')] : [];
const indices = [];
rows.forEach((row, i) => { if (row.querySelector('.row-select')?.checked) indices.push(i); });
return indices;
};
$('#duplicateSelectedBtn')?.addEventListener('click', () => {
const idxs = getCheckedIndices();
if (!idxs.length) return;
// duplicate in ascending order, inserting after each original
let offset = 0;
idxs.forEach((i) => {
const src = state.items[i + offset];
const copy = src && src.type === 'group'
? { type: 'group', title: src.title || '', description: src.description || '' }
: { description: src.description || '', qty: Number(src.qty || 0), unitPrice: Number(src.unitPrice || 0) };
state.items.splice(i + offset + 1, 0, copy);
offset += 1;
});
save();
renderItemsForm();
computeAndRender();
});
$('#deleteSelectedBtn')?.addEventListener('click', () => {
const idxs = getCheckedIndices().sort((a,b) => b - a);
if (!idxs.length) return;
idxs.forEach((i) => state.items.splice(i, 1));
save();
renderItemsForm();
computeAndRender();
});
// Select all toggle
const selectAll = $('#selectAllRows');
if (selectAll) {
selectAll.addEventListener('change', () => {
$$('#items .row-select').forEach(cb => { cb.checked = selectAll.checked; });
});
}
// Import JSON
const importBtn = $('#importJsonBtn');
const importInput = $('#importJsonInput');
if (importBtn && importInput) {
importBtn.addEventListener('click', () => importInput.click());
importInput.addEventListener('change', async () => {
const file = importInput.files && importInput.files[0];
if (!file) return;
try {
const text = await file.text();
const obj = JSON.parse(text);
loadFromJson(obj);
} catch (e) {
alert('Impossible d\'importer le JSON: ' + (e.message || e));
} finally {
importInput.value = '';
}
});
}
$('#resetBtn')?.addEventListener('click', () => {
if (!confirm('Réinitialiser le devis ?')) return;
try { localStorage.removeItem(persistKey); } catch {}
// Reset state
Object.keys(state).forEach((k) => {
if (k === 'currency') state[k] = 'EUR';
else if (k === 'vatRate') state[k] = 20;
else if (k === 'discountRate') state[k] = 0;
else if (k === 'items') state[k] = [{ description: '', qty: 1, unitPrice: 0 }];
else if (k.endsWith('Country')) state[k] = 'France';
else state[k] = '';
});
// Reset inputs
$$('input, textarea, select').forEach((el) => {
if (el.id === 'currency') el.value = 'EUR';
else if (el.id === 'vatRate') el.value = '20';
else if (el.id === 'discountRate') el.value = '0';
else if (el.id && el.id.endsWith('Country')) el.value = 'France';
else if (el.type === 'date') {
if (el.id === 'quoteDate') el.value = todayISO();
else if (el.id === 'quoteValidUntil') el.value = '';
else el.value = '';
} else {
el.value = '';
}
});
renderItemsForm();
mirrorSimpleFields();
updateLogo();
computeAndRender();
save();
});
};
const buildExportJson = () => {
const simpleKeys = [
'currency','vatRate','myName','myStreet','myPostcode','myCity','myCountry','myEmail','myPhone','myLogo','myLegal',
'clientName','clientStreet','clientPostcode','clientCity','clientCountry','clientEmail','clientPhone',
'quoteNumber','quoteDate','quoteValidUntil','discountRate','paymentTerms','notes'
];
const out = {};
simpleKeys.forEach(k => { if (state[k] !== undefined) out[k] = state[k]; });
out.items = (state.items || []).map(it => {
if (it && it.type === 'group') return { type: 'group', title: it.title || '', description: it.description || '' };
return {
description: (it?.description || ''),
days: Number(it?.qty || 0),
unitPrice: Number(it?.unitPrice || 0)
};
});
return out;
};
// --- Import JSON ---
const keysAllowed = [
'currency','vatRate','myName','myStreet','myPostcode','myCity','myCountry','myEmail','myPhone','myLogo','myLegal',
'clientName','clientStreet','clientPostcode','clientCity','clientCountry','clientEmail','clientPhone',
'quoteNumber','quoteDate','quoteValidUntil','discountRate','paymentTerms','notes'
];
const syncInputsFromState = () => {
$$('input, textarea, select').forEach((el) => {
const id = el.id;
if (!id || !(id in state)) return;
if (id === 'currency') {
el.value = state[id] || 'EUR';
el.dispatchEvent(new Event('change', { bubbles: true }));
} else if (id === 'vatRate' || id === 'discountRate') {
el.value = state[id] ?? 0;
el.dispatchEvent(new Event('input', { bubbles: true }));
} else if (el.type === 'date' || el.type === 'text' || el.type === 'email' || el.type === 'tel' || el.type === 'number' || el.tagName === 'TEXTAREA') {
el.value = state[id] ?? '';
el.dispatchEvent(new Event('input', { bubbles: true }));
}
});
updateAddressPreview('my');
updateAddressPreview('client');
updateLogo();
};
const loadFromJson = (obj) => {
if (!obj || typeof obj !== 'object') throw new Error('JSON invalide');
// Merge top-level simple keys
keysAllowed.forEach((k) => {
if (Object.prototype.hasOwnProperty.call(obj, k)) state[k] = obj[k];
});
// Items mapping
const items = Array.isArray(obj.items) ? obj.items : [];
state.items = items.map((it) => {
if ((it.type || '').toString() === 'group') {
return { type: 'group', title: (it.title ?? it.label ?? '').toString(), description: (it.description ?? it.desc ?? '').toString() };
}
return {
description: (it.description ?? '').toString(),
qty: Number(it.days ?? it.qty ?? 0),
unitPrice: Number(it.unitPrice ?? it.unit_price ?? 0)
};
});
save();
renderItemsForm();
renderPreviewItems();
computeAndRender();
syncInputsFromState();
};
// --- Autocomplete Adresses (API Adresse BAN) ---
const debounce = (fn, ms = 250) => {
let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
};
const setupAddressAutocomplete = (selector, { prefix } = {}) => {
const el = typeof selector === 'string'
? (selector.startsWith('#') ? $(selector) : $('#' + selector))
: selector;
if (!el) return;
const parent = el.closest('label') || el.parentElement;
if (!parent) return;
parent.style.position = parent.style.position || 'relative';
const list = document.createElement('div');
list.className = 'ac-list';
parent.appendChild(list);
let suggestions = [];
let active = -1;
let controller = null;
const hide = () => { list.style.display = 'none'; active = -1; };
const show = () => { list.style.display = suggestions.length ? 'block' : 'none'; };
const render = () => {
list.innerHTML = suggestions.map((s, i) => (
`<div class="ac-item${i === active ? ' active' : ''}" data-i="${i}">${escapeHtml(s.label)}</div>`
)).join('');
list.querySelectorAll('.ac-item').forEach((it) => {
it.addEventListener('mousedown', (e) => {
e.preventDefault();
const i = Number(it.getAttribute('data-i'));
select(i);
});
});
};
const select = (i) => {
if (!suggestions[i]) return;
const s = suggestions[i];
el.value = s.label;
el.dispatchEvent(new Event('input', { bubbles: true }));
if (prefix && s.props) {
setAddressFromProps(prefix, s.props);
}
hide();
};
const search = debounce(async () => {
const term = (el.value || '').trim();
if (term.length < 3) { suggestions = []; render(); hide(); return; }
try {
if (controller) controller.abort();
controller = new AbortController();
const url = `https://api-adresse.data.gouv.fr/search/?q=${encodeURIComponent(term)}&autocomplete=1&limit=5`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error('HTTP ' + res.status);
const json = await res.json();
suggestions = (json.features || []).map(f => ({ label: f.properties?.label || '', props: f.properties || {} }));
active = -1;
render();
show();
} catch (e) {
if (e.name === 'AbortError') return; // nouvelle requête
suggestions = [];
render();
hide();
}
}, 250);
el.addEventListener('input', search);
el.addEventListener('keydown', (e) => {
if (!suggestions.length) return;
if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % suggestions.length; render(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + suggestions.length) % suggestions.length; render(); }
else if (e.key === 'Enter') { if (active >= 0) { e.preventDefault(); select(active); } }
else if (e.key === 'Escape') { hide(); }
});
el.addEventListener('blur', () => setTimeout(hide, 120));
};
// --- Autocomplete Entreprises (Annuaire des entreprises) ---
const setupCompanyAutocomplete = (selector, { prefix = 'client' } = {}) => {
const el = typeof selector === 'string'
? (selector.startsWith('#') ? $(selector) : $('#' + selector))
: selector;
if (!el) return;
const parent = el.closest('label') || el.parentElement;
if (!parent) return;
parent.style.position = parent.style.position || 'relative';
const list = document.createElement('div');
list.className = 'ac-list';
parent.appendChild(list);
let suggestions = [];
let active = -1;
let controller = null;
const hide = () => { list.style.display = 'none'; active = -1; };
const show = () => { list.style.display = suggestions.length ? 'block' : 'none'; };
const render = () => {
list.innerHTML = suggestions.map((s, i) => (
`<div class="ac-item${i === active ? ' active' : ''}" data-i="${i}">`
+ `${escapeHtml(s.title)}`
+ (s.subtitle ? `<div style="color:#9aa7b3;font-size:12px;">${escapeHtml(s.subtitle)}</div>` : '')
+ `</div>`
)).join('');
list.querySelectorAll('.ac-item').forEach((it) => {
it.addEventListener('mousedown', (e) => {
e.preventDefault();
const i = Number(it.getAttribute('data-i'));
select(i);
});
});
};
const select = (i) => {
if (!suggestions[i]) return;
const s = suggestions[i];
// Remplit le nom
el.value = s.company.nom_complet || s.title;
el.dispatchEvent(new Event('input', { bubbles: true }));
// Remplit l'adresse si disponible
const siege = s.company.siege || {};
const street = siege.adresse || [siege.numero_voie, siege.type_voie, siege.libelle_voie].filter(Boolean).join(' ');
const city = siege.libelle_commune || '';
const postcode = siege.code_postal || '';
const country = (siege.pays || siege.libelle_pays_etranger || '').trim() || 'France';
state[`${prefix}Name`] = el.value;
state[`${prefix}Street`] = street;
state[`${prefix}City`] = city;
state[`${prefix}Postcode`] = postcode;
state[`${prefix}Country`] = country;
const streetEl = document.querySelector(`#${prefix}Street`);
const cityEl = document.querySelector(`#${prefix}City`);
const pcEl = document.querySelector(`#${prefix}Postcode`);
const countryEl = document.querySelector(`#${prefix}Country`);
if (streetEl) streetEl.value = street;
if (cityEl) cityEl.value = city;
if (pcEl) pcEl.value = postcode;
if (countryEl) countryEl.value = country;
updateAddressPreview(prefix);
updatePreviewText('#p_clientName', state[`${prefix}Name`] || '');
save();
hide();
};
const search = debounce(async () => {
const term = (el.value || '').trim();
if (term.length < 2) { suggestions = []; render(); hide(); return; }
try {
if (controller) controller.abort();
controller = new AbortController();
const url = `https://recherche-entreprises.api.gouv.fr/search?q=${encodeURIComponent(term)}&per_page=5`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error('HTTP ' + res.status);
const json = await res.json();
const results = json?.results || [];
suggestions = results.map((e) => ({
title: e.nom_complet || e.nom_raison_sociale || '',
subtitle: [e.siege?.adresse, e.siege?.code_postal, e.siege?.libelle_commune].filter(Boolean).join(', '),
company: e
}));
active = -1;
render();
show();
} catch (e) {
if (e.name === 'AbortError') return;
suggestions = [];
render();
hide();
}
}, 250);
el.addEventListener('input', search);
el.addEventListener('keydown', (e) => {
if (!suggestions.length) return;
if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % suggestions.length; render(); }
else if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + suggestions.length) % suggestions.length; render(); }
else if (e.key === 'Enter') { if (active >= 0) { e.preventDefault(); select(active); } }
else if (e.key === 'Escape') { hide(); }
});
el.addEventListener('blur', () => setTimeout(hide, 120));
};
const setAddressFromProps = (prefix, props) => {
const streetName = props.street || props.name || '';
const housenumber = props.housenumber ? props.housenumber + ' ' : '';
const streetFull = (housenumber + streetName).trim();
const city = props.city || '';
const postcode = props.postcode || '';
const country = 'France';
state[`${prefix}Street`] = streetFull;
state[`${prefix}City`] = city;
state[`${prefix}Postcode`] = postcode;
state[`${prefix}Country`] = country;
const streetEl = document.querySelector(`#${prefix}Street`);
const cityEl = document.querySelector(`#${prefix}City`);
const pcEl = document.querySelector(`#${prefix}Postcode`);
const countryEl = document.querySelector(`#${prefix}Country`);
if (streetEl) streetEl.value = streetFull;
if (cityEl) cityEl.value = city;
if (pcEl) pcEl.value = postcode;
if (countryEl) countryEl.value = country;
updateAddressPreview(prefix);
save();
};
const init = () => {
load();
applyInitialDefaults();
// Titre de page: adapter pour l'impression (évite "Générateur de Devis" dans l'en-tête navigateur)
const originalTitle = document.title;
window.addEventListener('beforeprint', () => {
const num = (state.quoteNumber || '').toString().trim();
document.title = num ? `Devis ${num}` : 'Devis';
});
window.addEventListener('afterprint', () => {
document.title = originalTitle;
});
// Fill inputs from state
$('#currency') && ($('#currency').value = state.currency);
$('#vatRate') && ($('#vatRate').value = state.vatRate);
$('#printTemplate') && ($('#printTemplate').value = state.printTemplate || 'standard');
$('#myName') && ($('#myName').value = state.myName);
$('#myStreet') && ($('#myStreet').value = state.myStreet);
$('#myPostcode') && ($('#myPostcode').value = state.myPostcode);
$('#myCity') && ($('#myCity').value = state.myCity);
$('#myCountry') && ($('#myCountry').value = state.myCountry || 'France');
$('#myEmail') && ($('#myEmail').value = state.myEmail);
$('#myPhone') && ($('#myPhone').value = state.myPhone);
$('#myLogo') && ($('#myLogo').value = state.myLogo);
$('#myLegal') && ($('#myLegal').value = state.myLegal);
$('#clientName') && ($('#clientName').value = state.clientName);
$('#clientStreet') && ($('#clientStreet').value = state.clientStreet);
$('#clientPostcode') && ($('#clientPostcode').value = state.clientPostcode);
$('#clientCity') && ($('#clientCity').value = state.clientCity);
$('#clientCountry') && ($('#clientCountry').value = state.clientCountry || 'France');
$('#clientEmail') && ($('#clientEmail').value = state.clientEmail);
$('#clientPhone') && ($('#clientPhone').value = state.clientPhone);
$('#quoteNumber') && ($('#quoteNumber').value = state.quoteNumber);
$('#quoteDate') && ($('#quoteDate').value = state.quoteDate);
$('#quoteValidUntil') && ($('#quoteValidUntil').value = state.quoteValidUntil);
$('#discountRate') && ($('#discountRate').value = state.discountRate);
$('#paymentTerms') && ($('#paymentTerms').value = state.paymentTerms);
$('#notes') && ($('#notes').value = state.notes);
// Bind
bindParams();
applyTemplate();
mirrorSimpleFields();
// Autocomplete adresses sur Rue
setupAddressAutocomplete('#myStreet', { prefix: 'my' });
setupAddressAutocomplete('#clientStreet', { prefix: 'client' });
// Autocomplete entreprises sur Nom/Société (client)
setupCompanyAutocomplete('#clientName', { prefix: 'client' });
bindButtons();
renderItemsForm();
computeAndRender();
updateAddressPreview('my');
updateAddressPreview('client');
save();
};
document.addEventListener('DOMContentLoaded', init);
})();