tag when the popup parses it.
return JSON.stringify(obj).replace(/ 0 && Array.from(boxes).every(b => b.checked);
}
}
function toggleSelectAllSales(checked) {
document.querySelectorAll('.salesRowCheck').forEach(box => {
box.checked = checked;
if(checked) salesSelectedIds.add(box.value); else salesSelectedIds.delete(box.value);
});
}
// Opens the embedded invoice generator in a sandboxed full-screen iframe,
// same approach as the Inventory app's openInvoiceGenerator() β no popup
// window (browsers can block/ignore those), just an in-app overlay. The
// generator's markup/CSS/JS are self-contained, so srcdoc keeps it fully
// isolated from this page's own styles/globals.
function openInvoiceGeneratorOverlay(prefillItems, clientName, clientPhone, currency, clientAddress) {
const prefill = { items: prefillItems, clientName: clientName || '', clientPhone: clientPhone || '', currency: currency || '', clientAddress: clientAddress || '' };
const openTag = '<' + 'script>';
const closeTag = '<' + '/script>';
const prefillTag = openTag + 'window.__SMS_PREFILL__ = ' + safeScriptJson(prefill) + ';' + closeTag;
const injected = INVOICE_GENERATOR_HTML.replace('', '' + prefillTag);
const root = document.getElementById('modalRoot');
root.innerHTML = `
Invoice Generator
`;
document.getElementById('invoiceGenFrame').srcdoc = injected;
}
/* =====================================================================
Date range helpers
===================================================================== */
function getDateBounds(preset = state.datePreset, from = state.dateFrom, to = state.dateTo) {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
const pad = (n) => String(n).padStart(2,'0');
const iso = (d) => `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`;
if(preset === 'all') return { from:null, to:null };
if(preset === 'custom') return { from: from || null, to: to || null };
if(preset === 'month') {
return { from: iso(new Date(y,m,1)), to: iso(new Date(y,m+1,0)) };
}
if(preset === 'quarter') {
const qStart = Math.floor(m/3)*3;
return { from: iso(new Date(y,qStart,1)), to: iso(new Date(y,qStart+3,0)) };
}
// 'year'
return { from: `${y}-01-01`, to: `${y}-12-31` };
}
// Costs keeps its own independent date filter, same reasoning as its
// already-separate costsBaseCurrency: you might want to check last
// quarter's costs while Dashboard/Sales are still showing this year.
function getCostsDateBounds() {
return getDateBounds(state.costsDatePreset, state.costsDateFrom, state.costsDateTo);
}
function inRange(dateStr, bounds) {
if(!dateStr) return false;
if(bounds.from && dateStr < bounds.from) return false;
if(bounds.to && dateStr > bounds.to) return false;
return true;
}
/* =====================================================================
General Sales view
===================================================================== */
let ratesLoadFailed = false;
// Dashboard: the numbers only (revenue/commission/net, currency breakdown)
// β no per-sale/artwork detail. That list lives on the Sales tab instead;
// the two share date-range/currency filter state (see state.datePreset,
// state.baseCurrency) so switching between them keeps the same view.
function filteredSubsidies() {
const bounds = getDateBounds();
return state.subsidies
.filter(sub => inRange(sub.date, bounds))
.sort((a,b) => (b.date||'').localeCompare(a.date||''));
}
// Auto-fills Staff Commission as 10% of the entered amount, same "auto-fill
// but stay editable" pattern Sales already uses for artist commission β
// only writes the suggested value, never overwrites something the user has
// already typed into that field on their own.
function handleSubsidyAmountChange() {
const amountRaw = document.getElementById('fSubsidyAmount').value;
const commissionEl = document.getElementById('fSubsidyCommissionAmount');
if(!commissionEl || commissionEl.dataset.touched === 'true') return;
const amount = parseFloat(amountRaw);
commissionEl.value = Number.isFinite(amount) ? (Math.round(amount * 0.1 * 100) / 100) : '';
}
function handleSubsidyCommissionTouched() {
document.getElementById('fSubsidyCommissionAmount').dataset.touched = 'true';
}
// Read-only summary shown when a subsidy row is clicked β mirrors
// openExpenseOverviewModal's pattern, so you're not dropped straight into
// an edit form just to glance at the details.
function openSubsidyOverviewModal(id) {
const sub = state.subsidies.find(x => x.id === id);
if(!sub) return;
const root = document.getElementById('modalRoot');
root.innerHTML = `
`;
renderStatCards(sales, subsidies);
renderPaymentStatusBreakdown(sales);
renderTopArtists(sales);
renderEventTypeBreakdown(sales);
}
// Sales: every recorded sale (across every event β Art Fair/Exhibition tags
// just let you drill into one specific event once those tabs exist; they
// don't hide a sale from this list), as a plain list. No financial totals
// here β those live on the Dashboard tab instead.
function filteredSortedSales() {
const bounds = getDateBounds();
const search = state.salesSearch.trim().toLowerCase();
return state.sales
.filter(s => inRange(s.saleDate, bounds))
.filter(s => !search || [s.artworkTitle, s.artist, s.buyer, s.artworkId].some(v => (v||'').toLowerCase().includes(search)))
.sort((a,b) => (b.saleDate||'').localeCompare(a.saleDate||''));
}
function handleSalesSearchInput() {
state.salesSearch = document.getElementById('salesSearch').value;
render();
}
function renderSalesList() {
const root = document.getElementById('mainContent');
const sales = filteredSortedSales();
root.innerHTML = `
`}
`;
}
function openGeneralExportModal() {
const all = filteredSortedSales();
const sales = salesSelectedIds.size > 0 ? all.filter(s => salesSelectedIds.has(s.id)) : all;
openExportModal({ type: 'general', sales });
}
function eventBadge(s) {
const type = s.eventType || 'General';
const label = type === 'General' ? 'General' : `${type}: ${s.eventName || 'β'}`;
return `${esc(label)}`;
}
function paymentBadge(status) {
const cls = status === 'Paid in Full' ? 'pay-full' : status === 'Deposit Paid' ? 'pay-deposit' : 'pay-unpaid';
return `${esc(status || 'Unpaid')}`;
}
// Shared aggregation for a set of sales/subsidies/costs already scoped to
// whatever period they belong to β used for the current period and, for the
// period-over-period comparison, the previous one, so both go through the
// exact same math.
function computeDashboardFinancials(sales, subsidies, generalCosts, rates, baseCurrency) {
let revenue = 0, commission = 0, subsidyEarned = 0, staffCommission = 0, costsTotal = 0, conversionFailed = !rates;
sales.forEach(s => {
if(s.saleAmount) {
const converted = rates && s.saleCurrency ? convertAmount(Number(s.saleAmount), s.saleCurrency, baseCurrency, rates) : null;
if(converted === null) conversionFailed = true; else revenue += converted;
}
if(s.commissionAmount) {
const converted = rates && s.commissionCurrency ? convertAmount(Number(s.commissionAmount), s.commissionCurrency, baseCurrency, rates) : null;
if(converted === null) conversionFailed = true; else commission += converted;
}
});
subsidies.forEach(sub => {
if(sub.amount) {
const converted = rates && sub.currency ? convertAmount(Number(sub.amount), sub.currency, baseCurrency, rates) : null;
if(converted === null) conversionFailed = true; else subsidyEarned += converted;
}
if(sub.staffCommissionAmount) {
const converted = rates && sub.staffCommissionCurrency ? convertAmount(Number(sub.staffCommissionAmount), sub.staffCommissionCurrency, baseCurrency, rates) : null;
if(converted === null) conversionFailed = true; else staffCommission += converted;
}
});
generalCosts.forEach(c => {
(c.payments || []).forEach(p => {
if(!p.amount) return;
const converted = rates && c.currency ? convertAmount(Number(p.amount), c.currency, baseCurrency, rates) : null;
if(converted === null) conversionFailed = true; else costsTotal += converted;
});
});
const combinedCosts = costsTotal + staffCommission;
const net = revenue + subsidyEarned - commission - combinedCosts;
return { revenue, commission, subsidyEarned, staffCommission, costsTotal, combinedCosts, net, conversionFailed };
}
// Sales still owed money β a live snapshot, not scoped to the Dashboard's
// selected date range, since a receivable from three months ago is still
// money you're owed today regardless of which period you're looking at.
// Sorted largest-remaining-first so the Outstanding Balance modal leads with
// what matters most.
function getOutstandingSales() {
return state.sales
.filter(s => s.paymentStatus !== 'Paid in Full' && s.saleAmount !== null && s.saleAmount !== undefined && s.saleAmount !== '')
.map(s => {
const paid = (s.payments || []).reduce((sum, p) => sum + (Number(p.amount) || 0), 0);
return { sale: s, remaining: Number(s.saleAmount) - paid };
})
.filter(({ remaining }) => remaining > 0)
.sort((a, b) => b.remaining - a.remaining);
}
function computeOutstandingByCurrency() {
const byCcy = {};
getOutstandingSales().forEach(({ sale, remaining }) => {
const ccy = sale.saleCurrency || 'β';
byCcy[ccy] = (byCcy[ccy] || 0) + remaining;
});
return byCcy;
}
function openOutstandingBalanceModal() {
const outstanding = getOutstandingSales();
const root = document.getElementById('modalRoot');
root.innerHTML = `
Outstanding Balance
${outstanding.length === 0 ? `
Nothing outstanding β every sale on file is Paid in Full.
` : `
Money still owed on sales that aren't Paid in Full, as of today. Click a row to open its sale record.
Artwork
Buyer
Status
Remaining
${outstanding.map(({ sale: s, remaining }) => `
${esc(s.artworkTitle)}
${esc(s.artist)}
${esc(s.buyer || 'β')}
${paymentBadge(s.paymentStatus || 'Unpaid')}
${fmtMoney(remaining, s.saleCurrency || '')}
`).join('')}
`}
`;
}
async function renderStatCards(sales, subsidies) {
const grid = document.getElementById('statGrid');
if(!grid) return;
subsidies = subsidies || [];
// Same date range Dashboard itself is scoped to (state.datePreset), not
// the Costs tab's own independent one β this is "the costs relevant to
// the period you're currently looking at on this tab." Only General
// costs: Art Fair has its own dedicated cost/profit view already, and
// Dashboard's Revenue figure below isn't itself split by scope, so this
// is called out explicitly in the card's own subtext rather than silently
// implying an all-in profit figure it doesn't actually compute.
const bounds = getDateBounds();
const generalCosts = state.costs.filter(c => c.scope === 'General' && inRange(latestOccurrenceDate(c), bounds));
const outstandingByCcy = computeOutstandingByCurrency();
if(state.baseCurrency === 'ALL') {
renderCurrencyBreakdown(grid, sales, subsidies, generalCosts, outstandingByCcy);
return;
}
let rates = null;
try {
const r = await getExchangeRates();
rates = r.rates;
ratesLoadFailed = false;
} catch(e) {
ratesLoadFailed = true;
}
const current = computeDashboardFinancials(sales, subsidies, generalCosts, rates, state.baseCurrency);
let outstanding = 0, outstandingConversionFailed = !rates;
Object.entries(outstandingByCcy).forEach(([ccy, amt]) => {
const converted = rates ? convertAmount(amt, ccy, state.baseCurrency, rates) : null;
if(converted === null) outstandingConversionFailed = true; else outstanding += converted;
});
const conversionFailed = current.conversionFailed || outstandingConversionFailed;
if(conversionFailed) {
renderCurrencyBreakdown(grid, sales, subsidies, generalCosts, outstandingByCcy);
grid.insertAdjacentHTML('afterbegin', `
Conversion unavailable
${ratesLoadFailed ? 'Exchange rates could not be loaded' : 'One or more currencies here have no available rate'}
Showing per-currency totals below instead of a combined figure.
${currencies.map(c => {
const rev = revenueByCcy[c] || 0;
const com = commissionByCcy[c] || 0;
const sub = subsidyByCcy[c] || 0;
// Staff commission (paid on subsidies) is folded into Costs here
// too, same as the converted-total view above β not its own column.
const costs = (costsByCcy[c] || 0) + (staffCommissionByCcy[c] || 0);
const net = rev + sub - com - costs;
return `
${esc(c)}
${fmtMoney(rev, c)}
${fmtMoney(sub, c)}
${fmtMoney(com, c)}
${fmtMoney(costs, c)}
${fmtMoney(net, c)}
`;
}).join('')}
No conversion applied β each row totals only amounts actually recorded in that currency. Costs = General costs + staff commission on subsidies (Art Fair costs are tracked separately).
Revenue minus artist commission β General costs aren't attributed per artist.
`;
return;
}
}
}
// ALL-currency mode, or conversion unavailable β can't safely rank a
// blended profit figure, so rank by number of works sold instead.
const counts = groupSalesCount(sales, s => s.artist);
const top = Object.entries(counts).sort((a,b) => b[1] - a[1]).slice(0, 5);
el.innerHTML = `
Top Artists
Artist
Sold
${top.map(([artist, count]) => `
${esc(artist)}
${count}
`).join('')}
Ranked by number sold β pick a single currency above to rank by profit.
Pick a single currency above to see revenue instead of just counts.
`;
}
// Artworks Inventory considers Sold that have no matching sale record here
// β almost always because Sold was set directly in Inventory rather than
// through "+ Create Sale Record". These are surfaced for manual review
// instead of auto-created (audit #3): auto-creating a "Paid in Full" sale
// with no payment evidence risks contaminating financial reporting, and
// silently recreates sales the user deliberately deleted (see the
// "Delete Record Only" option in the sale-delete modal).
function getOrphanSoldArtworks() {
return state.artworks.filter(a =>
a.status === 'Sold' &&
!state.sales.some(s => s.artworkId === a.id) &&
!state.dismissedOrphanIds.includes(a.id)
);
}
function renderOrphanBanner() {
const container = document.getElementById('orphanBanner');
if(!container) return;
const orphans = getOrphanSoldArtworks();
if(orphans.length === 0) { container.innerHTML = ''; return; }
container.innerHTML = `
${orphans.length} artwork${orphans.length===1?'':'s'} marked Sold in Inventory ${orphans.length===1?'has':'have'} no matching sale record here.
These are marked Sold in Inventory but have no sale recorded here β likely set directly in Inventory. Create a proper sale record for each, or dismiss it if it doesn't need one (e.g. sold before this system was in use).
`;
}
// Opens the full sale modal pre-filled from the artwork's Inventory
// record, but deliberately leaves payment status at its default
// ("Paid in Full" is NOT assumed) β Inventory doesn't track payment
// evidence, so the user must consciously confirm it here.
function createSaleFromOrphan(artworkId) {
document.getElementById('modalRoot').innerHTML = '';
const a = state.artworks.find(x => x.id === artworkId);
if(!a) return;
openSaleModal(null, {
artworkId: a.id,
eventType: 'General',
eventName: '',
});
// Pre-fill what we can from Inventory once the modal's in the DOM β
// openSaleModal doesn't accept these directly since they're normal
// form values, not part of the eventType/eventName preset it supports.
setTimeout(() => {
const buyerEl = document.getElementById('fBuyer');
const amtEl = document.getElementById('fSaleAmount');
const ccyEl = document.getElementById('fSaleCurrency');
const dateEl = document.getElementById('fSaleDate');
const statusEl = document.getElementById('fPaymentStatus');
if(buyerEl && a.owner) buyerEl.value = a.owner;
if(amtEl && a.soldAmount !== null && a.soldAmount !== undefined) amtEl.value = a.soldAmount;
if(ccyEl && a.soldCurrency) ccyEl.value = a.soldCurrency;
if(dateEl && a.soldDate) dateEl.value = a.soldDate;
// Inventory doesn't track payment evidence, so this must not default
// to "Paid in Full" the way a normal new sale does β the user has to
// consciously choose it if that's actually true (audit #3).
if(statusEl) statusEl.value = 'Unpaid';
}, 0);
}
async function dismissOrphan(artworkId) {
const newDismissed = state.dismissedOrphanIds.concat(artworkId);
isSyncing = true;
renderSyncStatus();
const result = await commitSteps([
{ label: 'Reconciliation list', write: () => db.saveDismissedOrphans(newDismissed), apply: () => { state.dismissedOrphanIds = newDismissed; } },
]);
isSyncing = false;
if(!result.ok) {
syncFailed = true;
toast(partialCommitMessage(result, 1));
} else {
syncFailed = false;
toast('Dismissed β won\'t be shown again.');
}
renderSyncStatus();
openOrphanReconciliationModal();
renderOrphanBanner();
}
function renderRecurringDueBanner() {
const container = document.getElementById('recurringDueBanner');
if(!container) return;
const due = getDueRecurringExpenses();
if(due.length === 0) { container.innerHTML = ''; return; }
container.innerHTML = `
These recurring expenses look due. Create this period's entry β you'll review/edit it before it's saved β or skip this occurrence if it doesn't apply.
Due ${esc(dueDate)}${template.eventName ? ' Β· ' + esc(template.eventName) : ''}
`).join('')}
`;
}
// Opens the ordinary, unmodified expense modal and pre-fills it from the
// template β this never writes anything itself. The user still reviews and
// clicks the modal's own Save (saveExpenseRecord), which is what keeps
// this "detect and prompt" rather than a silent auto-write (audit #3).
function createExpenseFromRecurringDue(seriesKey, dueDate) {
document.getElementById('modalRoot').innerHTML = '';
const due = getDueRecurringExpenses();
const entry = due.find(x => x.seriesKey === seriesKey && x.dueDate === dueDate);
if(!entry) { toast('That occurrence is no longer due.'); renderRecurringDueBanner(); return; }
const t = entry.template;
openExpenseModal(t.scope, t.eventName, null);
setTimeout(() => {
const set = (id, val) => { const el = document.getElementById(id); if(el && val !== undefined && val !== null && val !== '') el.value = val; };
set('fExpContent', t.content);
set('fExpCategory', t.category);
set('fExpVendor', t.vendor);
set('fExpCurrency', t.currency);
const recurBox = document.getElementById('fExpRecurring');
if(recurBox) { recurBox.checked = true; handleExpenseRecurringToggle(); }
set('fExpFrequency', t.recurringFrequency);
handleExpenseFrequencyChange();
if(t.recurringFrequency === 'weekly') set('fExpWeekday', t.recurringWeekday);
if(t.recurringFrequency === 'monthly') set('fExpMonthlyDay', t.recurringDayOfMonth);
if(t.recurringFrequency === 'yearly') { set('fExpYearMonth', t.recurringMonth); set('fExpYearlyDay', t.recurringDayOfMonth); }
// Pre-fill the (default, single) payment row β the user still reviews
// date/amount before Save actually writes anything.
set('payDate-0', dueDate);
const lastPayment = (t.payments || []).slice().sort((a,b) => (a.date||'').localeCompare(b.date||'')).pop();
if(lastPayment && lastPayment.amount !== undefined) set('payAmount-0', lastPayment.amount);
}, 0);
}
async function dismissRecurringDue(seriesKey, dueDate) {
const newDismissed = state.dismissedRecurringOccurrences.concat({ seriesKey, dueDate });
isSyncing = true;
renderSyncStatus();
const result = await commitSteps([
{ label: 'Recurring due list', write: () => db.saveDismissedRecurringOccurrences(newDismissed), apply: () => { state.dismissedRecurringOccurrences = newDismissed; } },
]);
isSyncing = false;
if(!result.ok) {
syncFailed = true;
toast(partialCommitMessage(result, 1));
} else {
syncFailed = false;
toast('Skipped β won\'t be shown again for this occurrence.');
}
renderSyncStatus();
openRecurringDueModal();
renderRecurringDueBanner();
}
// A sale record missing its date and/or amount is invisible in the Sales
// list under every date filter (inRange() rejects an empty saleDate
// unconditionally) and isn't caught by orphan detection either, since a
// sale record does technically exist for its artwork β it falls through a
// gap between both existing safety nets. Surfaced here the same way, so it
// doesn't require someone to already know to go looking for it.
function getIncompleteSaleRecords() {
return state.sales.filter(s =>
(!s.saleDate || s.saleAmount === null || s.saleAmount === undefined || s.saleAmount === '') &&
!state.dismissedIncompleteSaleIds.includes(s.id)
);
}
function renderIncompleteSalesBanner() {
const container = document.getElementById('incompleteSalesBanner');
if(!container) return;
const incomplete = getIncompleteSaleRecords();
if(incomplete.length === 0) { container.innerHTML = ''; return; }
container.innerHTML = `
${incomplete.length} sale record${incomplete.length===1?'':'s'} ${incomplete.length===1?'is':'are'} missing a sale date or amount, so ${incomplete.length===1?'it doesn\'t':'they don\'t'} show up in the Sales list.
These sale records are missing a date and/or amount, which hides them from the Sales list under every date filter. Complete them with the real values, or dismiss one if it's genuinely fine as-is.
Changing this moves the sale between General/Art Fair/Exhibition views, and updates the fair's artwork list to match.
` : ''}
Track the deposit and any later installments β remaining balance updates automatically.
${salePaymentsHtml}
Auto-fills from the artwork's Inventory record when you pick it β edit if this sale's terms differ.
${!isNew ? `` : ''}
`;
renderArtworkPicker(s, preset?.artworkId);
if(initialEventType !== 'General') populateEventNameOptions(initialEventType);
if(initialPaymentStatus === 'Deposit Paid') updateSaleRemainingAmount();
}
// Tracks how many payment rows have ever been added in the currently-open
// sale modal, so each gets a stable, unique DOM id β including rows added
// after the modal first opened.
let salePaymentRowCount = 0;
function salePaymentRowHtml(payment, idx) {
return `