1163 lines
46 KiB
JavaScript
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, '&')
|
|
.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');
|
|
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);
|
|
})();
|