// documents.js — extracted from documents_dashboard.html
// Data injected via window.__DOCS_DATA__
// ── Document type field toggling ──────────────────────────────
const FIELD_GROUPS = {
invoice: ['invoice', 'invoice_quote', 'line_items'],
quote: ['quote', 'invoice_quote', 'line_items'],
proposal: ['proposal'],
receipt: ['receipt', 'proposal receipt custom'],
custom: ['invoice', 'invoice_quote', 'line_items', 'proposal', 'proposal receipt custom'],
};
function toggleDocFields() {
const dtype = document.getElementById('gen-doc-type').value;
const groups = FIELD_GROUPS[dtype] || [];
// Hide all field groups first
document.querySelectorAll('[data-field-group]').forEach(el => {
el.style.display = 'none';
});
// Show the groups that match this type
groups.forEach(g => {
document.querySelectorAll(`[data-field-group="${g}"]`).forEach(el => {
el.style.display = '';
});
});
// Update layout hint text
const hint = document.querySelector('#gen-layout + .form-hint');
if (hint) {
const labels = { invoice: 'invoice', quote: 'quote', proposal: 'proposal', receipt: 'receipt', custom: 'document' };
hint.textContent = 'Choose the visual layout for this ' + (labels[dtype] || 'document') + '.';
}
// Update line item total label
const totalEl = document.querySelector('.line-total');
if (totalEl) {
totalEl.textContent = (dtype === 'invoice' || dtype === 'quote') ? 'Total: $' : '';
}
}
function filterDocs(status, chipEl) {
document.querySelectorAll('#status-filters .status-chip').forEach(c => c.classList.remove('active'));
if (chipEl) chipEl.classList.add('active');
const rows = document.querySelectorAll('#doc-table tbody tr');
const activeType = document.querySelector('#doc-type-filters .status-chip.active')?.dataset?.type || 'all';
rows.forEach(row => {
const typeMatch = !activeType || activeType === 'all' || row.dataset.type === activeType;
const statusMatch = !status || row.dataset.status === status;
row.style.display = (typeMatch && statusMatch) ? '' : 'none';
});
}
function filterByType(dtype, chipEl) {
document.querySelectorAll('#doc-type-filters .status-chip').forEach(c => c.classList.remove('active'));
if (chipEl) {
chipEl.classList.add('active');
chipEl.dataset.type = dtype || 'all';
}
const rows = document.querySelectorAll('#doc-table tbody tr');
const activeStatus = document.querySelector('#status-filters .status-chip.active')?.dataset?.status || null;
rows.forEach(row => {
const typeMatch = !dtype || dtype === 'all' || row.dataset.type === dtype;
const statusMatch = !activeStatus || row.dataset.status === activeStatus;
row.style.display = (typeMatch && statusMatch) ? '' : 'none';
});
}
// Tab switching
function switchTab(tab, btnEl) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
btnEl.classList.add('active');
document.getElementById('tab-documents').style.display = tab === 'documents' ? '' : 'none';
document.getElementById('tab-templates').style.display = tab === 'templates' ? '' : 'none';
if (tab === 'templates') loadTemplates();
}
// Toast
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 3000);
}
// ---- Template Management ----
let templates = [];
async function loadTemplates() {
try {
const res = await fetch('/documents/templates');
if (!res.ok) {
throw new Error('HTTP ' + res.status + ': ' + res.statusText);
}
const contentType = res.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
throw new Error('Unexpected response — not JSON (possible login redirect)');
}
const data = await res.json();
templates = data.templates || [];
renderTemplates();
} catch (e) {
console.error('Failed to load templates', e);
document.getElementById('template-grid').innerHTML =
'<div style="grid-column:1/-1;text-align:center;padding:48px 20px;color:#888;">' +
'<p>Failed to load templates.</p>' +
'<p style="font-size:12px;color:#bbb;">' + e.message + '</p></div>';
}
}
function renderTemplates() {
const grid = document.getElementById('template-grid');
if (templates.length === 0) {
grid.innerHTML = `
<div style="grid-column:1/-1;text-align:center;padding:48px 20px;color:#888;">
<p>No templates yet.</p>
<button class="primary-btn" data-onclick="openTemplateModal()" style="margin-top:12px;">+ Create Template</button>
</div>`;
return;
}
grid.innerHTML = templates.map(t => `
<div class="template-card" data-id="${t.id}">
<div style="display:flex;justify-content:space-between;align-items:start;">
<h4>${escapeHtml(t.name)}</h4>
<span class="tpl-type">${escapeHtml(t.document_type || 'invoice')}</span>
</div>
<div class="tpl-layout">Layout: ${escapeHtml(t.layout || 'classic')}</div>
${t.invoice_number_format ? `<div style="font-size:12px;color:#666;">Format: ${escapeHtml(t.invoice_number_format)}</div>` : ''}
<div class="tpl-actions">
<button class="action-btn primary" data-onclick="generateFromTemplate(${t.id})">Generate</button>
<button class="action-btn danger" data-onclick="deleteTemplate(${t.id})">Delete</button>
</div>
</div>
`).join('');
}
function openTemplateModal() {
document.getElementById('template-modal').classList.add('open');
document.getElementById('tpl-name').value = '';
document.getElementById('tpl-doc-type').value = 'invoice';
document.getElementById('tpl-layout').value = 'classic';
document.getElementById('tpl-inv-format').value = 'INV-YYYY-####';
document.getElementById('tpl-error').style.display = 'none';
document.body.style.overflow = 'hidden';
document.querySelector('nav').style.display = 'none';
}
function closeTemplateModal() {
document.getElementById('template-modal').classList.remove('open');
document.body.style.overflow = '';
document.querySelector('nav').style.display = '';
}
async function createTemplate() {
const name = document.getElementById('tpl-name').value.trim();
if (!name) {
showFormError('tpl-error', 'Template name is required.');
return;
}
const data = {
name: name,
document_type: document.getElementById('tpl-doc-type').value,
layout: document.getElementById('tpl-layout').value,
invoice_number_format: document.getElementById('tpl-inv-format').value.trim() || 'INV-YYYY-####',
};
try {
const res = await fetch('/documents/templates', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data),
});
const result = await res.json();
if (!res.ok) throw new Error(result.error || 'Failed to create template');
closeTemplateModal();
await loadTemplates();
showToast('Template created');
} catch (e) {
showFormError('tpl-error', e.message);
}
}
async function deleteTemplate(templateId) {
if (!confirm('Delete this template? This cannot be undone.')) return;
try {
const res = await fetch(`/documents/templates/${templateId}`, { method: 'DELETE' });
const result = await res.json();
if (!res.ok) throw new Error(result.error || 'Failed to delete template');
await loadTemplates();
showToast('Template deleted');
} catch (e) {
showToast('Error: ' + e.message);
}
}
// ---- Invoice Generation ----
function openGenerateModal() {
document.getElementById('generate-modal').classList.add('open');
document.getElementById('gen-customer-name').value = '';
document.getElementById('gen-customer-email').value = '';
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 14);
document.getElementById('gen-due-date').value = tomorrow.toISOString().split('T')[0];
document.getElementById('line-items-container').innerHTML = `
<div class="line-item-row">
<input type="text" placeholder="Description" class="li-desc">
<input type="number" placeholder="Qty" value="1" min="1" class="li-qty" data-oninput="calcTotal()">
<input type="number" placeholder="Price" step="any" class="li-price" data-oninput="calcTotal()">
<button class="line-item-remove" data-onclick="removeLineItem(this)">×</button>
</div>`;
document.getElementById('gen-error').style.display = 'none';
toggleDocFields();
loadTemplateSelect();
document.body.style.overflow = 'hidden';
document.querySelector('nav').style.display = 'none';
}
function closeGenerateModal() {
document.getElementById('generate-modal').classList.remove('open');
document.body.style.overflow = '';
document.querySelector('nav').style.display = '';
}
async function loadTemplateSelect() {
const sel = document.getElementById('gen-template-select');
if (templates.length === 0) {
try {
const res = await fetch('/documents/templates');
const data = await res.json();
templates = data.templates || [];
} catch (e) { /* ignore */ }
}
sel.innerHTML = '<option value="">— No template (standalone) —</option>' +
templates.map(t => `<option value="${t.id}">${escapeHtml(t.name)} (${t.document_type || 'invoice'})</option>`).join('');
}
async function generateFromTemplate(templateId) {
// Ensure templates are loaded first
if (templates.length === 0) {
try {
const res = await fetch('/documents/templates');
const data = await res.json();
templates = data.templates || [];
} catch (e) { /* ignore */ }
}
// Populate the template select dropdown with options
await loadTemplateSelect();
openGenerateModal();
document.getElementById('gen-template-select').value = templateId;
// Sync document type to match the selected template
const template = templates.find(t => t.id == templateId);
if (template && template.document_type) {
document.getElementById('gen-doc-type').value = template.document_type;
toggleDocFields();
}
}
function addLineItem() {
const container = document.getElementById('line-items-container');
const row = document.createElement('div');
row.className = 'line-item-row';
row.innerHTML = `
<input type="text" placeholder="Description" class="li-desc">
<input type="number" placeholder="Qty" value="1" min="1" class="li-qty" data-oninput="calcTotal()">
<input type="number" placeholder="Price" step="any" class="li-price" data-oninput="calcTotal()">
<button class="line-item-remove" data-onclick="removeLineItem(this)">×</button>`;
container.appendChild(row);
}
function removeLineItem(btn) {
const container = document.getElementById('line-items-container');
if (container.children.length > 1) {
btn.closest('.line-item-row').remove();
calcTotal();
}
}
function calcTotal() {
let total = 0;
document.querySelectorAll('.line-item-row').forEach(row => {
const qty = parseFloat(row.querySelector('.li-qty').value) || 0;
const price = parseFloat(row.querySelector('.li-price').value) || 0;
total += qty * price;
});
document.getElementById('line-total').textContent = total.toFixed(2);
}
async function generateInvoice() {
const customerName = document.getElementById('gen-customer-name').value.trim();
const customerEmail = document.getElementById('gen-customer-email').value.trim();
if (!customerName) {
showFormError('gen-error', 'Customer name is required.');
return;
}
const dtype = document.getElementById('gen-doc-type').value;
const needsLineItems = ['invoice', 'quote', 'custom'].includes(dtype);
const lineItems = [];
if (needsLineItems) {
let valid = true;
document.querySelectorAll('.line-item-row').forEach(row => {
const desc = row.querySelector('.li-desc').value.trim();
const qty = parseFloat(row.querySelector('.li-qty').value) || 0;
const price = parseFloat(row.querySelector('.li-price').value) || 0;
if (!desc || price <= 0) valid = false;
lineItems.push({ description: desc || 'Service', qty, unit_price: price });
});
if (!valid || lineItems.length === 0) {
showFormError('gen-error', 'Each line item needs a description and price.');
return;
}
}
try {
const payload = {
template_id: document.getElementById('gen-template-select').value || null,
document_type: dtype,
layout: document.getElementById('gen-layout').value,
style: {
primary_color: document.getElementById('gen-primary-color').value,
},
customer_name: customerName,
customer_email: customerEmail,
due_date: dtype === 'invoice' ? (document.getElementById('gen-due-date').value || null) : null,
line_items: lineItems,
notes: document.getElementById('gen-notes')?.value.trim() || '',
tax_rate: parseFloat(document.getElementById('gen-tax-rate').value) || 0,
payment_terms: document.getElementById('gen-payment-terms')?.value.trim() || '',
payment_link: document.getElementById('gen-payment-link')?.value.trim() || '',
payment_link_text: document.getElementById('gen-payment-link-text')?.value.trim() || 'Pay Now',
};
if (dtype === 'quote') {
payload.due_date = document.getElementById('gen-expiry-date').value || null;
}
if (dtype === 'proposal') {
payload.scope = document.getElementById('gen-scope')?.value.trim() || '';
payload.due_date = document.getElementById('gen-valid-until').value || null;
}
if (dtype === 'receipt') {
payload.due_date = document.getElementById('gen-receipt-date').value || null;
}
const res = await fetch('/documents/generate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
const result = await res.json();
if (!res.ok) throw new Error(result.error || 'Generation failed');
closeGenerateModal();
// Show preview of the generated document
openPreview(result.document_id, result.invoice_number || 'Invoice Generated');
showToast('Document generated!');
} catch (e) {
showFormError('gen-error', e.message);
}
}
// ---- Portal Actions ----
async function copyPortalLink(documentId, btnEl) {
try {
const res = await fetch(`/documents/${documentId}/share-link`);
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to get link');
await navigator.clipboard.writeText(data.portal_url);
const orig = btnEl.textContent;
btnEl.textContent = '✓';
btnEl.style.color = '#10b981';
showToast('Portal link copied to clipboard');
setTimeout(() => { btnEl.textContent = orig; btnEl.style.color = ''; }, 2000);
} catch (e) {
showToast('Error: ' + e.message);
}
}
async function sendInvoice(documentId, customerEmail, btnEl) {
if (!confirm(`Send invoice to ${customerEmail}?`)) return;
const orig = btnEl.textContent;
btnEl.textContent = '⏳';
btnEl.disabled = true;
try {
const res = await fetch(`/documents/${documentId}/send`, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to send');
btnEl.textContent = '✓';
btnEl.style.color = '#10b981';
showToast('Invoice sent to ' + customerEmail);
setTimeout(() => { btnEl.textContent = orig; btnEl.style.color = ''; btnEl.disabled = false; }, 2000);
} catch (e) {
btnEl.textContent = orig;
btnEl.disabled = false;
showToast('Error: ' + e.message);
}
}
// ---- PDF Preview ----
function openPreview(documentId, title) {
document.getElementById('preview-title').textContent = title;
document.getElementById('preview-iframe').src = '/documents/' + documentId + '/embed?_t=' + Date.now();
document.getElementById('preview-download').href = '/documents/' + documentId + '/download';
document.getElementById('preview-fullscreen').href = '/documents/' + documentId + '/download';
document.getElementById('preview-modal').classList.add('open');
document.body.style.overflow = 'hidden';
document.querySelector('nav').style.display = 'none';
}
function printPreview() {
const iframe = document.getElementById('preview-iframe');
if (iframe.src) {
const printWin = window.open(iframe.src, '_blank');
printWin.onload = function() {
printWin.focus();
printWin.print();
};
}
}
function closePreview() {
document.getElementById('preview-modal').classList.remove('open');
document.getElementById('preview-iframe').src = '';
document.body.style.overflow = '';
document.querySelector('nav').style.display = '';
}
// ---- Helpers ----
function showFormError(elId, msg) {
const el = document.getElementById(elId);
el.textContent = msg;
el.style.display = 'block';
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}