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 = `
`;
}
function openSubsidyModal(id) {
const sub = id ? state.subsidies.find(x => x.id === id) : null;
const isNew = !sub;
const root = document.getElementById('modalRoot');
root.innerHTML = `
${isNew ? 'New subsidy' : 'Edit subsidy'}
Defaults to 10% of the amount as you type it β edit if the rate differs.
${!isNew ? `` : ''}
`;
}
async function saveSubsidyRecord(isNew, oldId) {
const source = document.getElementById('fSubsidySource').value.trim();
if(!source) { toast('Please enter a source.'); return; }
const amountRaw = document.getElementById('fSubsidyAmount').value;
if(!amountRaw || !(parseFloat(amountRaw) >= 0)) { toast('Please enter a valid amount.'); return; }
const date = document.getElementById('fSubsidyDate').value;
if(!date) { toast('Date is required.'); return; }
const commissionRaw = document.getElementById('fSubsidyCommissionAmount').value;
if(commissionRaw && !(parseFloat(commissionRaw) >= 0)) { toast('Staff commission cannot be negative.'); return; }
const prevRecord = state.subsidies.find(x => x.id === oldId);
const record = {
id: oldId || `SUBSIDY-${Date.now()}`,
source,
currency: document.getElementById('fSubsidyCurrency').value,
amount: parseFloat(amountRaw),
date,
staffCommissionCurrency: document.getElementById('fSubsidyCommissionCurrency').value,
staffCommissionAmount: commissionRaw ? parseFloat(commissionRaw) : null,
notes: document.getElementById('fSubsidyNotes').value,
createdAt: prevRecord?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const newSubsidies = isNew ? state.subsidies.concat(record) : state.subsidies.map(x => x.id === record.id ? record : x);
isSyncing = true;
renderSyncStatus();
const result = await commitSteps([
{ label: 'Subsidy', write: () => db.saveSubsidies(newSubsidies), apply: () => { state.subsidies = newSubsidies; } },
]);
isSyncing = false;
if(!result.ok) {
syncFailed = true;
toast(partialCommitMessage(result, 1));
renderSyncStatus();
return;
}
syncFailed = false;
toast(isNew ? 'Subsidy added.' : 'Subsidy updated.');
renderSyncStatus();
document.getElementById('modalRoot').innerHTML = '';
render();
}
async function deleteSubsidyRecord(id) {
if(!confirm("Delete this subsidy record? This can't be undone.")) return;
const newSubsidies = state.subsidies.filter(x => x.id !== id);
isSyncing = true;
renderSyncStatus();
const result = await commitSteps([
{ label: 'Subsidy', write: () => db.saveSubsidies(newSubsidies), apply: () => { state.subsidies = newSubsidies; } },
]);
isSyncing = false;
if(!result.ok) {
syncFailed = true;
toast(partialCommitMessage(result, 1));
renderSyncStatus();
return;
}
syncFailed = false;
toast('Deleted.');
renderSyncStatus();
document.getElementById('modalRoot').innerHTML = '';
render();
}
// Dashboard's period-scoped sales list β shared by renderDashboard itself
// and by every stat-card/row click-through modal below, so a modal opened
// from a click always reflects the exact same period as what's on screen.
function getDashboardSalesInPeriod() {
const bounds = getDateBounds();
return state.sales
.filter(s => inRange(s.saleDate, bounds))
.sort((a,b) => (b.saleDate||'').localeCompare(a.saleDate||''));
}
function renderDashboard() {
const root = document.getElementById('mainContent');
const sales = getDashboardSalesInPeriod();
const subsidies = filteredSubsidies();
root.innerHTML = `
${state.datePreset === 'custom' ? `
to
` : ''}
Click any card or row below to see the sales/records behind it.
Loadingβ¦
Subsidies & Other Income
${subsidies.length === 0 ? `
No subsidies recorded for this period.
` : `
Date
Source
Amount
${subsidies.map(sub => `
${esc(sub.date)||'β'}
${esc(sub.source)||'β'}${sub.notes ? `
${esc(sub.notes)}
` : ''}
${fmtMoney(sub.amount, sub.currency)}
`).join('')}
`}
`;
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('')}
`}
`;
}
// Generic drill-down for any Dashboard stat/row backed by a set of sales β
// every card and table row on the Dashboard opens one of these (or
// openSubsidiesListModal/openCostsBreakdownModal for the two cards that
// aren't sale-backed), so no number is ever a dead end. Click a row to open
// its full sale record via the existing openSaleOverviewModal.
function openSalesListModal(title, hint, salesList, extraColumns) {
extraColumns = extraColumns || [];
const root = document.getElementById('modalRoot');
root.innerHTML = `
${esc(title)}
${salesList.length === 0 ? `
No sales here.
` : `
${esc(hint)}
Artwork
Buyer
Status
${extraColumns.map(c => `
${esc(c.header)}
`).join('')}
${salesList.map(s => `
${esc(s.artworkTitle)}
${esc(s.artist)}
${esc(s.buyer || 'β')}
${paymentBadge(s.paymentStatus || 'Unpaid')}
${extraColumns.map(c => `
${c.fn(s)}
`).join('')}
`).join('')}
`}
`;
}
// Same shape as openSalesListModal, for the one card (Subsidy Earned) whose
// records are subsidies rather than sales.
function openSubsidiesListModal(title, hint, subsidiesList) {
const root = document.getElementById('modalRoot');
root.innerHTML = `
${esc(title)}
${subsidiesList.length === 0 ? `
No subsidies here.
` : `
${esc(hint)}
Date
Source
Amount
${subsidiesList.map(sub => `
${esc(sub.date)||'β'}
${esc(sub.source)||'β'}
${fmtMoney(sub.amount, sub.currency)}
`).join('')}
`}
`;
}
function openRevenueModal() {
const sales = getDashboardSalesInPeriod().filter(s => s.saleAmount);
openSalesListModal('Revenue', 'Sales counted toward Revenue for the period above, each shown in its own recorded currency.', sales, [
{ header: 'Sale Amount', fn: s => fmtMoney(s.saleAmount, s.saleCurrency) },
]);
}
function openArtworksSoldModal() {
const sales = getDashboardSalesInPeriod();
openSalesListModal('Artworks Sold', 'Every sale recorded for the period above.', sales, [
{ header: 'Sale Date', fn: s => esc(s.saleDate) || 'β' },
{ header: 'Sale Amount', fn: s => fmtMoney(s.saleAmount, s.saleCurrency) },
]);
}
function openArtistCommissionModal() {
const sales = getDashboardSalesInPeriod().filter(s => s.commissionAmount);
openSalesListModal('Artist Commission', 'Sales with a recorded artist commission for the period above.', sales, [
{ header: 'Commission', fn: s => fmtMoney(s.commissionAmount, s.commissionCurrency) },
]);
}
function openSubsidyEarnedModal() {
const subsidies = filteredSubsidies().filter(sub => sub.amount);
openSubsidiesListModal('Subsidy Earned', 'Subsidies recorded for the period above.', subsidies);
}
// ALL-currency mode's per-currency breakdown table has no single converted
// Revenue/Commission figure to attach a card-click to, so these filter by
// the specific currency of the row that was clicked instead.
function openCurrencyRevenueModal(currency) {
const sales = getDashboardSalesInPeriod().filter(s => s.saleAmount && s.saleCurrency === currency);
openSalesListModal(`Revenue (${currency})`, `Sales in ${currency} counted toward Revenue for the period above.`, sales, [
{ header: 'Sale Amount', fn: s => fmtMoney(s.saleAmount, s.saleCurrency) },
]);
}
function openCurrencyCommissionModal(currency) {
const sales = getDashboardSalesInPeriod().filter(s => s.commissionAmount && s.commissionCurrency === currency);
openSalesListModal(`Artist Commission (${currency})`, `Sales with a ${currency} artist commission for the period above.`, sales, [
{ header: 'Commission', fn: s => fmtMoney(s.commissionAmount, s.commissionCurrency) },
]);
}
function openPaymentStatusModal(status) {
const sales = getDashboardSalesInPeriod().filter(s => (s.paymentStatus || 'Paid in Full') === status);
openSalesListModal(status, `Sales with payment status "${status}" for the period above.`, sales, [
{ header: 'Sale Amount', fn: s => fmtMoney(s.saleAmount, s.saleCurrency) },
]);
}
function openArtistSalesModal(artist) {
const sales = getDashboardSalesInPeriod().filter(s => (s.artist || 'Unknown') === artist);
openSalesListModal(artist, `Sales by ${artist} for the period above.`, sales, [
{ header: 'Sale Amount', fn: s => fmtMoney(s.saleAmount, s.saleCurrency) },
{ header: 'Commission', fn: s => fmtMoney(s.commissionAmount, s.commissionCurrency) },
]);
}
function openEventTypeModal(type) {
const sales = getDashboardSalesInPeriod().filter(s => (s.eventType || 'General') === type);
openSalesListModal(type, `Sales tagged "${type}" for the period above.`, sales, [
{ header: 'Sale Amount', fn: s => fmtMoney(s.saleAmount, s.saleCurrency) },
]);
}
// Costs card isn't a single list β it's General costs plus staff commission
// paid on subsidies (the same two components computeDashboardFinancials
// sums into combinedCosts) β so its drill-down shows both, each linking to
// its own existing overview modal.
function openCostsBreakdownModal() {
const generalCosts = getDashboardGeneralCostsInPeriod();
const staffCommissionSubsidies = filteredSubsidies().filter(sub => sub.staffCommissionAmount);
const root = document.getElementById('modalRoot');
root.innerHTML = `
Costs
General costs for the period above, plus staff commission paid on subsidies. Art Fair costs are tracked separately and aren't included here.
`;
}
// Net Profit has no sale list of its own β it's revenue + subsidy β commission
// β costs. Its drill-down is a breakdown of that formula using the snapshot
// renderStatCards stores each render, with each other card's own click
// already covering "what's behind this number" for its component.
function openNetProfitBreakdownModal() {
if(!lastDashboardFinancials) return;
const { current, baseCurrency } = lastDashboardFinancials;
const root = document.getElementById('modalRoot');
root.innerHTML = `
Net Profit
How Net Profit is calculated for the period above. Close this and click Revenue, Subsidy Earned, Artist Commission, or Costs to see the records behind each figure.
`;
}
// Same date range Dashboard itself is scoped to (state.datePreset), not the
// Costs tab's own independent one β "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.
function getDashboardGeneralCostsInPeriod() {
const bounds = getDateBounds();
return state.costs.filter(c => c.scope === 'General' && inRange(latestOccurrenceDate(c), bounds));
}
// Snapshot of the last successfully-computed single-currency financials,
// read by openNetProfitBreakdownModal() β Net Profit is a derived figure
// with no sale list of its own, so its drill-down is a breakdown of the
// other cards' numbers rather than a table of records.
let lastDashboardFinancials = null;
async function renderStatCards(sales, subsidies) {
const grid = document.getElementById('statGrid');
if(!grid) return;
subsidies = subsidies || [];
const generalCosts = getDashboardGeneralCostsInPeriod();
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). Click a Revenue or Artist Comm. figure to see the sales behind it.
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 `