diff --git a/public/index.html b/public/index.html index e121bf7..d6c0cd1 100644 --- a/public/index.html +++ b/public/index.html @@ -30,6 +30,7 @@ + @@ -138,15 +139,15 @@
-
-

Reports

-
-
+ -
-

Sales Tax

-
-
+ + diff --git a/public/js/app.js b/public/js/app.js index 250455f..bc94610 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -26,6 +26,7 @@ import './modals/email-modal.js'; import { setDefaultDate } from './utils/helpers.js'; import { renderAccountingView } from './views/accounting-view.js'; import { renderReportsView } from './views/reports-view.js'; +import { renderSalesTaxView } from './views/salestax-view.js'; // ============================================================ // Tab Management @@ -54,6 +55,8 @@ function showTab(tabName) { renderAccountingView(); } else if (tabName === 'reports') { renderReportsView(); + } else if (tabName === 'salestax') { + renderSalesTaxView(); } } @@ -79,7 +82,7 @@ document.addEventListener('DOMContentLoaded', () => { // Hash-based navigation (e.g. after OAuth redirect /#settings) if (window.location.hash) { const hashTab = window.location.hash.replace('#', ''); - if (['quotes', 'invoices', 'customers', 'accounting', 'reports', 'settings'].includes(hashTab)) { + if (['quotes', 'invoices', 'customers', 'accounting', 'reports', 'salestax', 'settings'].includes(hashTab)) { showTab(hashTab); } } diff --git a/public/js/utils/report-helpers.js b/public/js/utils/report-helpers.js new file mode 100644 index 0000000..ca96d64 --- /dev/null +++ b/public/js/utils/report-helpers.js @@ -0,0 +1,173 @@ +/** + * report-helpers.js — geteilte Bausteine der Report-/Accounting-Views + * + * Extrahiert aus accounting-view.js im Zuge der UI-Reorganisation + * (Reports und Sales Tax haben eigene Tabs bekommen). Inhaltlich 1:1 + * übernommen — einzige Ausnahme ist showError, das den Fehlertitel jetzt + * als Parameter bekommt, damit "QBO Error" und "Report Error" beide + * erhalten bleiben. + * + * Genutzt von: accounting-view.js, reports-view.js, salestax-view.js + */ + +// ── Formatierung ──────────────────────────────────────────────────── + +/** + * Accounting-Variante, um eine Number()-Koersion ergänzt. + * + * Das Original rief toLocaleString direkt auf dem Argument auf. Bei einer + * Zahl korrekt — bei einem String greift aber String.prototype.toLocaleString, + * das die Optionen ignoriert und den Rohwert zurückgibt ("19152.81" statt + * "$19,152.81"). accounting-view.js übergibt ausschließlich Zahlen und war + * davon nie betroffen; reports-view.js übergibt numeric-Strings vom + * pg-Treiber und hätte sonst still unformatierte Beträge angezeigt. + * Für Zahl-Eingaben ist die Ausgabe unverändert. + */ +export function fmtMoney(n) { + if (n == null || n === '' || isNaN(n)) return ''; + return Number(n).toLocaleString('en-US', { + style: 'currency', currency: 'USD', + minimumFractionDigits: 2, maximumFractionDigits: 2 + }); +} + +export function escapeHtml(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +// ── Datums-Helfer ─────────────────────────────────────────────────── + +export function todayISO() { return new Date().toISOString().split('T')[0]; } + +export function firstOfMonthISO(year, month) { + const d = year != null ? new Date(year, month, 1) : new Date(); + return year != null ? d.toISOString().split('T')[0] : new Date(d.getFullYear(), d.getMonth(), 1).toISOString().split('T')[0]; +} + +export function lastOfMonthISO(year, month) { + const d = year != null ? new Date(year, month + 1, 0) : new Date(); + return year != null ? d.toISOString().split('T')[0] : new Date(d.getFullYear(), d.getMonth() + 1, 0).toISOString().split('T')[0]; +} + +export function prevMonthISO() { + const d = new Date(); + const m = d.getMonth() === 0 ? 11 : d.getMonth() - 1; + const y = d.getMonth() === 0 ? d.getFullYear() - 1 : d.getFullYear(); + return `${y}-${String(m + 1).padStart(2, '0')}`; +} + +export function firstOfYearISO() { + const d = new Date(); + return new Date(d.getFullYear(), 0, 1).toISOString().split('T')[0]; +} + +// ── Statusanzeigen ────────────────────────────────────────────────── + +/** + * @param {string} title Überschrift der Fehlerbox — "QBO Error" im + * Accounting-/Sales-Tax-Kontext, "Report Error" auf dem Reports-Tab. + */ +export function showError(slotId, message, title = 'QBO Error') { + const el = document.getElementById(slotId); + if (!el) return; + el.innerHTML = ` +
+

${escapeHtml(title)}

+

${escapeHtml(message)}

+
`; +} + +export function showLoading(slotId, message = 'Loading…') { + const el = document.getElementById(slotId); + if (!el) return; + el.innerHTML = ` +
+ + + + + ${escapeHtml(message)} +
`; +} + +// ── QBO-Report-Rendering ──────────────────────────────────────────── +// Wird von P&L / Balance Sheet / Sales Tax (QBO) auf dem Reports-Tab +// genutzt UND vom Sales-Tax-Detaildialog auf dem Sales-Tax-Tab. + +export function renderQboReport(report) { + if (!report || !report.Header) return `

No report data.

`; + const cols = (report.Columns && report.Columns.Column) || []; + const headerRow = cols.map(c => `${escapeHtml(c.ColTitle || '')}`).join(''); + const body = report.Rows && report.Rows.Row ? renderReportRows(report.Rows.Row, 0) : ''; + return ` +
+ ${escapeHtml(report.Header.ReportName || '')} + ${report.Header.StartPeriod ? '· ' + escapeHtml(report.Header.StartPeriod) + ' – ' + escapeHtml(report.Header.EndPeriod) : ''} + ${report.Header.ReportBasis ? '· ' + escapeHtml(report.Header.ReportBasis) : ''} +
+
+ + ${headerRow} + ${body} +
+
`; +} + +export function renderReportRows(rows, depth) { + if (!rows) return ''; + const arr = Array.isArray(rows) ? rows : [rows]; + let html = ''; + for (const row of arr) { + const isSection = row.type === 'Section' || row.Rows || row.Summary; + const indent = depth * 16; + if (row.Header && row.Header.ColData) { + const cells = row.Header.ColData.map((c, i) => + i === 0 + ? `${escapeHtml(c.value || '')}` + : `` + ).join(''); + html += `${cells}`; + } + if (isSection && row.Rows && row.Rows.Row) html += renderReportRows(row.Rows.Row, depth + 1); + if (row.Summary && row.Summary.ColData) { + const cells = row.Summary.ColData.map((c, i) => + i === 0 + ? `${escapeHtml(c.value || '')}` + : `${escapeHtml(c.value || '')}` + ).join(''); + html += `${cells}`; + } + if (!isSection && row.ColData) { + const cells = row.ColData.map((c, i) => + i === 0 + ? `${escapeHtml(c.value || '')}` + : `${escapeHtml(c.value || '')}` + ).join(''); + html += `${cells}`; + } + } + return html; +} + +// ── Alpine ────────────────────────────────────────────────────────── + +/** + * Alpine wird mit `defer` geladen und scannt das DOM nur einmal beim Start. + * Views, die ihr Markup per innerHTML nachträglich einhängen, müssen den + * neuen Teilbaum selbst anmelden — sonst bleiben x-data/x-show tot. + * + * Alpine markiert initialisierte Knoten intern; ein zweiter initTree-Lauf + * über denselben Baum würde doppelte Bindings und Warnungen erzeugen. + * Da innerHTML den Teilbaum jedes Mal komplett ersetzt, sind die Knoten + * hier immer frisch — der Guard prüft es trotzdem, damit ein künftiger + * Aufruf auf unverändertem DOM folgenlos bleibt. + */ +export function initAlpineTree(container) { + if (!container || !window.Alpine) return; + const alreadyInitialized = container.querySelector('[x-data]')?._x_dataStack; + if (alreadyInitialized) return; + window.Alpine.initTree(container); +} diff --git a/public/js/views/accounting-view.js b/public/js/views/accounting-view.js index 2746c18..e575882 100644 --- a/public/js/views/accounting-view.js +++ b/public/js/views/accounting-view.js @@ -9,6 +9,10 @@ import '../utils/api.js'; import { formatDate } from '../utils/helpers.js'; import { openExpenseModal } from '../modals/expense-modal.js'; import { openRefundModal } from '../modals/refund-modal.js'; +import { + fmtMoney, escapeHtml, showError, showLoading, + todayISO, firstOfMonthISO, lastOfMonthISO, prevMonthISO, firstOfYearISO +} from '../utils/report-helpers.js'; // ──────────────────────────────────────────────────────────────────── // State (modul-lokal) @@ -20,22 +24,6 @@ let registerStartDate = null; let registerEndDate = null; let registerLoadSeq = 0; -let plStartDate = null; -let plEndDate = null; -let plAccountingMethod = 'Accrual'; - -let bsAsOfDate = null; -let bsAccountingMethod = 'Accrual'; - -let tsMonth = null; // 'YYYY-MM' — selected month for tax summary -let tsAccountingMethod = 'Accrual'; - -let crStartDate = null; -let crEndDate = null; - -let stPeriods = []; -let stEditingPeriodId = null; // current period in detail dialog, null = new - let expStartDate = null; let expEndDate = null; let expOnlyMine = false; @@ -43,67 +31,6 @@ let expOnlyMine = false; // Auto-Sync nur einmal pro View-Mount let autoSyncDoneThisOpen = false; -// ──────────────────────────────────────────────────────────────────── -// Helpers -// ──────────────────────────────────────────────────────────────────── - -function fmtMoney(n) { - if (n == null || isNaN(n)) return ''; - return n.toLocaleString('en-US', { - style: 'currency', currency: 'USD', - minimumFractionDigits: 2, maximumFractionDigits: 2 - }); -} - -function todayISO() { return new Date().toISOString().split('T')[0]; } -function firstOfMonthISO(year, month) { - const d = year != null ? new Date(year, month, 1) : new Date(); - return year != null ? d.toISOString().split('T')[0] : new Date(d.getFullYear(), d.getMonth(), 1).toISOString().split('T')[0]; -} -function lastOfMonthISO(year, month) { - const d = year != null ? new Date(year, month + 1, 0) : new Date(); - return year != null ? d.toISOString().split('T')[0] : new Date(d.getFullYear(), d.getMonth() + 1, 0).toISOString().split('T')[0]; -} -function prevMonthISO() { - const d = new Date(); - const m = d.getMonth() === 0 ? 11 : d.getMonth() - 1; - const y = d.getMonth() === 0 ? d.getFullYear() - 1 : d.getFullYear(); - return `${y}-${String(m + 1).padStart(2, '0')}`; -} -function firstOfYearISO() { - const d = new Date(); - return new Date(d.getFullYear(), 0, 1).toISOString().split('T')[0]; -} - -function escapeHtml(s) { - if (s == null) return ''; - return String(s) - .replace(/&/g, '&').replace(//g, '>') - .replace(/"/g, '"').replace(/'/g, '''); -} - -function showError(slotId, message) { - const el = document.getElementById(slotId); - if (!el) return; - el.innerHTML = ` -
-

QBO Error

-

${escapeHtml(message)}

-
`; -} - -function showLoading(slotId, message = 'Loading…') { - const el = document.getElementById(slotId); - if (!el) return; - el.innerHTML = ` -
- - - - - ${escapeHtml(message)} -
`; -} function makeCollapsible(headerText, contentId, startCollapsed = false) { return `
${lines}
`; } -// ──────────────────────────────────────────────────────────────────── -// Reports -// ──────────────────────────────────────────────────────────────────── - -export function injectReportsControls() { - const c = document.getElementById('accounting-reports'); - if (!c) return; - if (!plStartDate) plStartDate = firstOfYearISO(); - if (!plEndDate) plEndDate = todayISO(); - if (!bsAsOfDate) bsAsOfDate = todayISO(); - if (!tsMonth) tsMonth = prevMonthISO(); - if (!crStartDate) crStartDate = firstOfYearISO(); - if (!crEndDate) crEndDate = todayISO(); - - c.innerHTML = ` - ${makeCollapsible('Reports', 'reports-section-body')} -
-
-
-

Profit & Loss

-
-
-
-
-
-
-
-
- - -
-
-
-
-
-

Balance Sheet

-
-
-
-
-
-
- - -
-
-
-
-
-

Sales Tax (QBO)

-
-
-
-
-
-
- - -
-
-
-
-
-

Customer Revenue

-
-
-
-
-
-
- - - -
-
-
-
-
-
`; -} - -export async function loadProfitLoss() { - plStartDate = document.getElementById('pl-start').value; - plEndDate = document.getElementById('pl-end').value; - plAccountingMethod = document.getElementById('pl-method').value; - showLoading('pl-result', 'Loading P&L from QBO…'); - try { - const data = await window.API.accounting.getProfitAndLoss(plStartDate, plEndDate, plAccountingMethod); - if (data.error) return showError('pl-result', data.error); - document.getElementById('pl-result').innerHTML = renderQboReport(data); - } catch (err) { showError('pl-result', err.message || 'Failed to load P&L'); } -} - -export async function loadBalanceSheet() { - bsAsOfDate = document.getElementById('bs-asof').value; - bsAccountingMethod = document.getElementById('bs-method').value; - showLoading('bs-result', 'Loading Balance Sheet from QBO…'); - try { - const data = await window.API.accounting.getBalanceSheet(bsAsOfDate, bsAccountingMethod); - if (data.error) return showError('bs-result', data.error); - document.getElementById('bs-result').innerHTML = renderQboReport(data); - } catch (err) { showError('bs-result', err.message || 'Failed to load Balance Sheet'); } -} - -export async function loadTaxSummary() { - tsMonth = document.getElementById('ts-month').value; - tsAccountingMethod = document.getElementById('ts-method').value; - if (!tsMonth) return showError('ts-result', 'Please select a month.'); - const [y, m] = tsMonth.split('-').map(Number); - const startDate = firstOfMonthISO(y, m - 1); - const endDate = lastOfMonthISO(y, m - 1); - showLoading('ts-result', 'Loading Sales Tax Liability from QBO…'); - try { - const data = await window.API.accounting.getTaxSummary(startDate, endDate, tsAccountingMethod); - if (data.error) return showError('ts-result', data.error); - document.getElementById('ts-result').innerHTML = renderQboReport(data); - } catch (err) { showError('ts-result', err.message || 'Failed to load Tax Summary'); } -} - -export async function loadCustomerRevenue() { - crStartDate = document.getElementById('cr-start').value; - crEndDate = document.getElementById('cr-end').value; - const anonymize = document.getElementById('cr-anonymize')?.checked || false; - if (!crStartDate || !crEndDate) return showError('cr-result', 'Please select both start and end dates.'); - showLoading('cr-result', 'Loading customer revenue...'); - const maskName = (name) => anonymize ? name.charAt(0) : name; - try { - const data = await window.API.accounting.getCustomerRevenue(crStartDate, crEndDate); - if (data.error) return showError('cr-result', data.error); - if (!data.length) { - document.getElementById('cr-result').innerHTML = '

No invoices found in this period.

'; - return; - } - const grandTotal = parseFloat(data[0].grand_total) || 0; - const totalInvoices = data.reduce((s, r) => s + parseInt(r.invoice_count), 0); - let rowsHtml = ''; - let rank = 0; - for (const r of data) { - rank++; - const rev = parseFloat(r.total_revenue) || 0; - const pct = grandTotal > 0 ? ((rev / grandTotal) * 100).toFixed(1) : '0.0'; - rowsHtml += ` - ${rank}. ${escapeHtml(maskName(r.customer_name))} - ${r.invoice_count} - ${fmtMoney(rev)} - ${pct}% - `; - } - rowsHtml += ` - TOTAL (${data.length} customers) - ${totalInvoices} - ${fmtMoney(grandTotal)} - 100.0% - `; - - document.getElementById('cr-result').innerHTML = ` -
- - - - - - - - - - ${rowsHtml} -
CustomerInvoicesRevenue (net)% of Total
-
`; - } catch (err) { showError('cr-result', err.message || 'Failed to load revenue report'); } -} - -export function exportCustomerRevenuePdf() { - const startEl = document.getElementById('cr-start'); - const endEl = document.getElementById('cr-end'); - const anonymize = document.getElementById('cr-anonymize')?.checked || false; - if (!startEl?.value || !endEl?.value) return alert('Please select start and end dates first.'); - let url = `/api/accounting/reports/customer-revenue/pdf?startDate=${startEl.value}&endDate=${endEl.value}`; - if (anonymize) url += '&anonymize=true'; - window.open(url, '_blank'); -} - -export function exportProfitLossPdf() { - const startEl = document.getElementById('pl-start'); - const endEl = document.getElementById('pl-end'); - const method = document.getElementById('pl-method')?.value || 'Accrual'; - if (!startEl?.value || !endEl?.value) return alert('Please select start and end dates first.'); - window.open(`/api/accounting/reports/profit-loss/pdf?startDate=${startEl.value}&endDate=${endEl.value}&accountingMethod=${method}`, '_blank'); -} - -export function exportBalanceSheetPdf() { - const asOf = document.getElementById('bs-asof'); - const method = document.getElementById('bs-method')?.value || 'Accrual'; - if (!asOf?.value) return alert('Please select an as-of date first.'); - window.open(`/api/accounting/reports/balance-sheet/pdf?asOfDate=${asOf.value}&accountingMethod=${method}`, '_blank'); -} - -export function exportTaxSummaryPdf() { - const monthEl = document.getElementById('ts-month'); - const method = document.getElementById('ts-method')?.value || 'Accrual'; - if (!monthEl?.value) return alert('Please select a month first.'); - const [y, m] = monthEl.value.split('-').map(Number); - const startDate = firstOfMonthISO(y, m - 1); - const endDate = lastOfMonthISO(y, m - 1); - window.open(`/api/accounting/reports/tax-summary/pdf?startDate=${startDate}&endDate=${endDate}&accountingMethod=${method}`, '_blank'); -} - -function renderQboReport(report) { - if (!report || !report.Header) return `

No report data.

`; - const cols = (report.Columns && report.Columns.Column) || []; - const headerRow = cols.map(c => `${escapeHtml(c.ColTitle || '')}`).join(''); - const body = report.Rows && report.Rows.Row ? renderReportRows(report.Rows.Row, 0) : ''; - return ` -
- ${escapeHtml(report.Header.ReportName || '')} - ${report.Header.StartPeriod ? '· ' + escapeHtml(report.Header.StartPeriod) + ' – ' + escapeHtml(report.Header.EndPeriod) : ''} - ${report.Header.ReportBasis ? '· ' + escapeHtml(report.Header.ReportBasis) : ''} -
-
- - ${headerRow} - ${body} -
-
`; -} - -function renderReportRows(rows, depth) { - if (!rows) return ''; - const arr = Array.isArray(rows) ? rows : [rows]; - let html = ''; - for (const row of arr) { - const isSection = row.type === 'Section' || row.Rows || row.Summary; - const indent = depth * 16; - if (row.Header && row.Header.ColData) { - const cells = row.Header.ColData.map((c, i) => - i === 0 - ? `${escapeHtml(c.value || '')}` - : `` - ).join(''); - html += `${cells}`; - } - if (isSection && row.Rows && row.Rows.Row) html += renderReportRows(row.Rows.Row, depth + 1); - if (row.Summary && row.Summary.ColData) { - const cells = row.Summary.ColData.map((c, i) => - i === 0 - ? `${escapeHtml(c.value || '')}` - : `${escapeHtml(c.value || '')}` - ).join(''); - html += `${cells}`; - } - if (!isSection && row.ColData) { - const cells = row.ColData.map((c, i) => - i === 0 - ? `${escapeHtml(c.value || '')}` - : `${escapeHtml(c.value || '')}` - ).join(''); - html += `${cells}`; - } - } - return html; -} - // ════════════════════════════════════════════════════════════════════ // Phase 2 Lieferung 2 — Expenses Section // ════════════════════════════════════════════════════════════════════ @@ -829,382 +482,6 @@ function renderExpensesTable(expenses) { `; } -// ──────────────────────────────────────────────────────────────────── -// Sales Tax Periods -// ──────────────────────────────────────────────────────────────────── - -export function injectSalesTaxSection() { - const c = document.getElementById('accounting-sales-tax'); - if (!c) return; - c.innerHTML = ` - ${makeCollapsible('Sales Tax', 'sales-tax-section-body')} -
-
-
-

Period Overview

-
- -
-
-
-
- -
`; -} - -export async function loadTaxPeriods() { - try { - stPeriods = await window.API.accounting.getTaxPeriods() || []; - } catch (e) { - stPeriods = []; - console.error('Failed to load tax periods:', e.message); - } - renderTaxPeriodsTable(); -} - -function renderTaxPeriodsTable() { - const el = document.getElementById('sales-tax-periods-table'); - if (!el) return; - - if (!stPeriods.length) { - el.innerHTML = `
No sales tax periods recorded yet. Click "+ New Period" to get started.
`; - return; - } - - const rows = stPeriods.map(p => { - const pStatus = p.status || (p.qbo_journal_entry_id ? 'booked' : 'open'); - let statusHtml, statusColor; - if (pStatus === 'booked') { statusHtml = 'Booked'; statusColor = 'bg-green-100 text-green-800'; } - else if (pStatus === 'external' || pStatus === 'paid') { statusHtml = 'Paid'; statusColor = 'bg-green-100 text-green-800'; } - else { statusHtml = 'Open'; statusColor = 'bg-yellow-100 text-yellow-800'; } - - const [py, pm] = String(p.period_start).split('T')[0].split('-'); - const monthLabel = new Date(Number(py), Number(pm) - 1).toLocaleDateString('en-US', { year: 'numeric', month: 'long', timeZone: 'UTC' }); - const adj = parseFloat(p.adjustment_amount) || 0; - const adjStr = adj !== 0 ? (adj > 0 ? `−$${adj.toFixed(2)}` : `+$${Math.abs(adj).toFixed(2)}`) : '—'; - const netPaid = parseFloat(p.net_paid) || parseFloat(p.tax_collected) || 0; - const paidOn = (pStatus === 'external' || pStatus === 'paid' || pStatus === 'booked') - ? (p.booked_at ? formatDate(p.booked_at) : '—') : '—'; - - return ` - - ${monthLabel} - - ${statusHtml} - - ${fmtMoney(parseFloat(p.tax_collected) || 0)} - ${adjStr} - ${fmtMoney(netPaid)} - ${paidOn} - - - - `; - }).join(''); - - el.innerHTML = ` -
- - - - - - - - - - - - - ${rows} -
PeriodStatusTax AmountAdjustmentNet DuePaid OnActions
-
${stPeriods.length} period${stPeriods.length === 1 ? '' : 's'}
-
`; -} - -export async function openNewTaxPeriod() { - const [y, m] = prevMonthISO().split('-').map(Number); - const startDate = firstOfMonthISO(y, m - 1); - const endDate = lastOfMonthISO(y, m - 1); - await openTaxPeriodDetail(startDate, endDate, null); -} - -export async function openTaxPeriod(periodId) { - const period = stPeriods.find(p => p.id === periodId); - if (!period) return alert('Period not found.'); - await openTaxPeriodDetail(period.period_start, period.period_end, period); -} - -async function openTaxPeriodDetail(startDate, endDate, existingPeriod) { - const detailEl = document.getElementById('sales-tax-detail'); - if (!detailEl) return; - - stEditingPeriodId = existingPeriod ? existingPeriod.id : null; - - if (existingPeriod && existingPeriod.status === 'paid') { - const [py, pm] = String(existingPeriod.period_start).split('T')[0].split('-'); - const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']; - const mLabel = MONTHS[Number(pm) - 1] + ' ' + py; - const adj = parseFloat(existingPeriod.adjustment_amount) || 0; - const adjStr = adj > 0 ? `\u2212${fmtMoney(adj)}` : adj < 0 ? `+$${Math.abs(adj).toFixed(2)}` : '\u2014'; - const paidOn = formatDate(existingPeriod.booked_at); - - detailEl.innerHTML = ` -
-
-

${mLabel} \u2014 Sales Tax Detail (Paid)

- -
-

\u2705 Paid on ${paidOn}

- - - - - - - - ${existingPeriod.adjustment_reason ? `` : ''} - - -
Total Sales${fmtMoney(parseFloat(existingPeriod.total_sales) || 0)}
Nontaxable Sales${fmtMoney(parseFloat(existingPeriod.nontaxable_sales) || 0)}
Taxable Sales${fmtMoney(parseFloat(existingPeriod.taxable_sales) || 0)}
Tax Collected${fmtMoney(parseFloat(existingPeriod.tax_collected) || 0)}
Adjustment${adjStr}
Reason: ${escapeHtml(existingPeriod.adjustment_reason)}
Net Paid${fmtMoney(parseFloat(existingPeriod.net_paid) || 0)}
-
`; - detailEl.classList.remove('hidden'); - detailEl.scrollIntoView({ behavior: 'smooth' }); - return; - } - - showLoading('sales-tax-detail', 'Loading tax summary from QBO…'); - let taxData; - try { - taxData = await window.API.accounting.getTaxSummary(startDate, endDate, 'Accrual'); - } catch (e) { - showError('sales-tax-detail', e.message || 'Failed to load tax summary'); - return; - } - - const reportHtml = taxData?.Rows ? renderQboReport(taxData) : `

No tax data for this period.

`; - - const parsed = parseTaxDataFromReport(taxData); - stCurrentTaxData = { ...parsed, startDate, endDate }; - const taxCollected = parsed.taxCollected; - const totalSales = parsed.totalSales; - const nontaxable = parsed.nontaxableSales; - const taxable = parsed.taxableSales; - const adjustments = existingPeriod?.adjustment_amount != null ? parseFloat(existingPeriod.adjustment_amount) || 0 : 0; - const adjReason = existingPeriod?.adjustment_reason || ''; - const netPaid = taxCollected - adjustments; - const periodStatus = existingPeriod?.status || (existingPeriod?.qbo_journal_entry_id ? 'booked' : 'open'); - const isOpen = periodStatus === 'open'; - const isEditable = isOpen; - - const today = todayISO(); - const [y, m] = startDate.split('-').map(Number); - const monthLabel = new Date(y, m - 1).toLocaleDateString('en-US', { year: 'numeric', month: 'long', timeZone: 'UTC' }); - - detailEl.innerHTML = ` -
-
-

${monthLabel} — Sales Tax Detail

- -
- ${reportHtml} - -
-
- Tax Collected: ${fmtMoney(taxCollected)} - Adjustment: ${adjustments > 0 ? `−${fmtMoney(adjustments)}` : adjustments < 0 ? `+$${Math.abs(adjustments).toFixed(2)}` : '—'} - Net Due: ${fmtMoney(netPaid)} -
- - ${isEditable ? ` -
-
- - -

Positive = discount (reduces net due)

-
-
- - -
-
- - -
-
- -
-
-

Summary:

-

Tax Collected: ${fmtMoney(taxCollected)}

- ${adjustments > 0 ? `

Discount: \u2212${fmtMoney(adjustments)}

` : ''} - ${adjustments < 0 ? `

Penalty: +$${Math.abs(adjustments).toFixed(2)}

` : ''} -

Net Due: ${fmtMoney(netPaid)}

-
-
- - -
-
- ` : periodStatus === 'booked' ? ` -
-

✅ Booked — Journal Entry #${escapeHtml(existingPeriod.qbo_journal_entry_id)} on ${formatDate(existingPeriod.booked_at)}

-
- ` : ` -
-

✅ Paid on ${formatDate(existingPeriod.booked_at)}

-
- `} -
-
`; - - detailEl.classList.remove('hidden'); - detailEl.scrollIntoView({ behavior: 'smooth' }); -} - -export function closeTaxPeriodDetail() { - const el = document.getElementById('sales-tax-detail'); - if (el) el.classList.add('hidden'); - stEditingPeriodId = null; -} - -export function updateTaxPreview() { - if (!stCurrentTaxData) return; - const adjInput = document.getElementById('st-adjustment'); - const adj = parseFloat(adjInput?.value) || 0; - const taxCollected = stCurrentTaxData.taxCollected; - const netPaid = taxCollected - adj; - - const adjDisplay = document.getElementById('st-adj-display'); - if (adjDisplay) { - adjDisplay.className = adj !== 0 ? 'text-red-600' : ''; - adjDisplay.innerHTML = adj > 0 - ? `Adjustment: −${fmtMoney(adj)}` - : adj < 0 - ? `Adjustment: +$${Math.abs(adj).toFixed(2)}` - : `Adjustment: —`; - } - - const netDue = document.getElementById('st-net-due'); - if (netDue) { - netDue.innerHTML = `Net Due: ${fmtMoney(netPaid)}`; - } - - const jeLines = document.getElementById('st-je-lines'); - if (jeLines) { - jeLines.innerHTML = ` -

Summary:

-

Tax Collected: ${fmtMoney(taxCollected)}

- ${adj > 0 ? `

Discount: \u2212${fmtMoney(adj)}

` : ''} - ${adj < 0 ? `

Penalty: +$${Math.abs(adj).toFixed(2)}

` : ''} -

Net Due: ${fmtMoney(netPaid)}

`; - } -} - -export async function saveTaxPeriodDraft() { - const adjAmount = parseFloat(document.getElementById('st-adjustment').value) || 0; - const adjReason = document.getElementById('st-adjustment-reason').value.trim(); - - if (!stCurrentTaxData) return alert('No tax data loaded.'); - - try { - const period = await window.API.accounting.upsertTaxPeriod({ - period_start: stCurrentTaxData.startDate, - period_end: stCurrentTaxData.endDate, - total_sales: stCurrentTaxData.totalSales, - nontaxable_sales: stCurrentTaxData.nontaxableSales, - taxable_sales: stCurrentTaxData.taxableSales, - tax_collected: stCurrentTaxData.taxCollected, - adjustment_amount: adjAmount, - adjustment_reason: adjReason || null, - net_paid: stCurrentTaxData.taxCollected - adjAmount, - status: 'open' - }); - await loadTaxPeriods(); - if (period?.period_start) { - await openTaxPeriodDetail(period.period_start, period.period_end, period); - } - } catch (e) { - alert('Failed to save: ' + e.message); - } -} - -export async function markPeriodPaid() { - const adjAmount = parseFloat(document.getElementById('st-adjustment').value) || 0; - const adjReason = document.getElementById('st-adjustment-reason').value.trim(); - const paidDate = document.getElementById('st-paid-date').value; - if (!paidDate) return alert('Please select a paid date.'); - - if (!stCurrentTaxData) return alert('No tax data loaded.'); - if (!stEditingPeriodId) return alert('Please save the period first (Save Draft).'); - - const netPaid = stCurrentTaxData.taxCollected - adjAmount; - - if (!confirm(`Mark this period as paid?\n\nPaid Date: ${paidDate}\nTax Collected: $${stCurrentTaxData.taxCollected.toFixed(2)}\nAdjustment: $${adjAmount.toFixed(2)}\nNet Paid: $${netPaid.toFixed(2)}\n\nThis does NOT write to QBO — record the payment in QBO first.`)) return; - - try { - await window.API.accounting.upsertTaxPeriod({ - period_start: stCurrentTaxData.startDate, - period_end: stCurrentTaxData.endDate, - total_sales: stCurrentTaxData.totalSales, - nontaxable_sales: stCurrentTaxData.nontaxableSales, - taxable_sales: stCurrentTaxData.taxableSales, - tax_collected: stCurrentTaxData.taxCollected, - adjustment_amount: adjAmount, - adjustment_reason: adjReason || null, - net_paid: netPaid, - status: 'open' - }); - } catch (e) { - return alert('Failed to save period: ' + e.message); - } - - try { - await window.API.accounting.markTaxPaidExternal(stEditingPeriodId, paidDate); - await loadTaxPeriods(); - const updated = stPeriods.find(p => p.id === stEditingPeriodId); - if (updated) { - await openTaxPeriodDetail(updated.period_start, updated.period_end, updated); - } - } catch (e) { - alert('Failed: ' + e.message); - } -} - -function parseTaxDataFromReport(taxData) { - if (!taxData?.Rows?.Row) return { totalSales: 0, nontaxableSales: 0, taxableSales: 0, taxCollected: 0 }; - const rows = Array.isArray(taxData.Rows.Row) ? taxData.Rows.Row : []; - for (const row of rows) { - if (row.type === 'Section' && row.Header?.ColData?.[0]?.value?.toLowerCase().includes('grand total')) { - const cd = row.Summary?.ColData || []; - return { - totalSales: parseFloat(cd[1]?.value || '0') || 0, - nontaxableSales: parseFloat(cd[2]?.value || '0') || 0, - taxableSales: parseFloat(cd[3]?.value || '0') || 0, - taxCollected: parseFloat(cd[4]?.value || '0') || 0 - }; - } - } - return { totalSales: 0, nontaxableSales: 0, taxableSales: 0, taxCollected: 0 }; -} - -let stCurrentTaxData = null; // parsed data for the open detail dialog - export async function openNewExpense() { await openExpenseModal({ onSaved: () => loadExpenses() @@ -1219,10 +496,7 @@ export function renderAccountingView() { autoSyncDoneThisOpen = false; injectToolbar(); injectRegisterControls(); - injectReportsControls(); injectExpensesSection(); - injectSalesTaxSection(); - loadTaxPeriods(); maybeAutoSyncCaches().then(() => { loadAccountsOverview(); @@ -1260,26 +534,10 @@ window.accountingView = { manualSync, loadAccountsOverview, loadRegister, - loadProfitLoss, - loadBalanceSheet, - loadTaxSummary, loadExpenses, openNewExpense, openNewRefund, editExpense, selectRegisterAccount, - toggleSection, - injectSalesTaxSection, - loadTaxPeriods, - openNewTaxPeriod, - openTaxPeriod, - closeTaxPeriodDetail, - updateTaxPreview, - saveTaxPeriodDraft, - markPeriodPaid, - loadCustomerRevenue, - exportCustomerRevenuePdf, - exportProfitLossPdf, - exportBalanceSheetPdf, - exportTaxSummaryPdf + toggleSection }; \ No newline at end of file diff --git a/public/js/views/reports-view.js b/public/js/views/reports-view.js index 423e02f..0a5da26 100644 --- a/public/js/views/reports-view.js +++ b/public/js/views/reports-view.js @@ -12,7 +12,13 @@ * formatiert. Ins CSV gehen die Rohwerte, damit Excel sauber rechnen kann. */ +import '../utils/api.js'; import { formatDate } from '../utils/helpers.js'; +import { + fmtMoney, escapeHtml, showError, showLoading, + todayISO, firstOfMonthISO, lastOfMonthISO, prevMonthISO, firstOfYearISO, + renderQboReport, initAlpineTree +} from '../utils/report-helpers.js'; // ── State ─────────────────────────────────────────────────────────── const today = new Date().toISOString().split('T')[0]; @@ -28,6 +34,20 @@ let revAnonymize = false; let revGroupByMonth = false; let revData = null; +// State der aus accounting-view.js übernommenen Reports (1:1) +let plStartDate = null; +let plEndDate = null; +let plAccountingMethod = 'Accrual'; + +let bsAsOfDate = null; +let bsAccountingMethod = 'Accrual'; + +let tsMonth = null; // 'YYYY-MM' — selected month for tax summary +let tsAccountingMethod = 'Accrual'; + +let crStartDate = null; +let crEndDate = null; + const AR_BUCKETS = [ { key: 'current', label: 'Current' }, { key: 'd1_30', label: '1–30 days' }, @@ -37,47 +57,12 @@ const AR_BUCKETS = [ ]; const BUCKET_LABEL = Object.fromEntries(AR_BUCKETS.map(b => [b.key, b.label])); -// ── Local helpers (Stil wie accounting-view.js) ───────────────────── +// ── Local helpers ─────────────────────────────────────────────────── +// fmtMoney/escapeHtml/showError/showLoading kommen aus report-helpers.js. -/** numeric-String → "$1,234.56". Kein Float-Zwischenschritt außer zur Anzeige. */ -function fmtMoney(v) { - if (v == null || v === '') return '—'; - const n = parseFloat(v); - if (isNaN(n)) return '—'; - const fixed = n.toFixed(2); - const [intPart, decPart] = fixed.replace('-', '').split('.'); - const grouped = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ','); - return `${n < 0 ? '-' : ''}$${grouped}.${decPart}`; -} - -function escapeHtml(s) { - if (s == null) return ''; - return String(s) - .replace(/&/g, '&').replace(//g, '>') - .replace(/"/g, '"').replace(/'/g, '''); -} - -function showError(slotId, message) { - const el = document.getElementById(slotId); - if (!el) return; - el.innerHTML = ` -
-

Report Error

-

${escapeHtml(message)}

-
`; -} - -function showLoading(slotId, message = 'Loading…') { - const el = document.getElementById(slotId); - if (!el) return; - el.innerHTML = ` -
- - - - - ${escapeHtml(message)} -
`; +/** showError mit dem Reports-spezifischen Titel. */ +function reportError(slotId, message) { + showError(slotId, message, 'Report Error'); } function methodologyBox(text, anonymized) { @@ -121,75 +106,158 @@ function downloadCsv(filename, lines) { // ── Shell ─────────────────────────────────────────────────────────── +function reportCard(title, bodyHtml) { + // x-show statt x-if: der Teilbaum bleibt im DOM, damit Datumsfelder und + // Checkboxen ihren Zustand über das Zu-/Aufklappen hinweg behalten. + // Das inline display:none verhindert ein Aufblitzen, bis Alpine initialisiert. + return ` +
+
+ + + +

${title}

+
+ +
`; +} + export function renderReportsView() { const el = document.getElementById('reports-content'); if (!el) return; + // Defaults der übernommenen Reports — 1:1 aus injectReportsControls() + if (!plStartDate) plStartDate = firstOfYearISO(); + if (!plEndDate) plEndDate = todayISO(); + if (!bsAsOfDate) bsAsOfDate = todayISO(); + if (!tsMonth) tsMonth = prevMonthISO(); + if (!crStartDate) crStartDate = firstOfYearISO(); + if (!crEndDate) crEndDate = todayISO(); + + const arAgingBody = ` +
+
+ + +
+ + + +
+
`; + + const revenueBody = ` +
+
+ + +
+
+ + +
+ + + + +
+
`; + + // ── Ab hier 1:1 aus accounting-view.js injectReportsControls() ── + const profitLossBody = ` +
+
+
+
+
+
+
+ + +
+
`; + + const balanceSheetBody = ` +
+
+
+
+
+ + +
+
`; + + const taxSummaryBody = ` +
+
+
+
+
+ + +
+
`; + + const customerRevenueBody = ` +
+
+
+
+
+ + + +
+
`; + el.innerHTML = ` -
- - -
-
-

Accounts Receivable Aging

-
-
-
-
- - -
- - - -
-
-
-
- - -
-
-

Invoice-Level Revenue

-
-
-
-
- - -
-
- - -
- - - - -
-
-
-
+
+ ${reportCard('Accounts Receivable Aging', arAgingBody)} + ${reportCard('Invoice-Level Revenue', revenueBody)} + ${reportCard('Customer Revenue (Summary)', customerRevenueBody)} + ${reportCard('Profit & Loss', profitLossBody)} + ${reportCard('Balance Sheet', balanceSheetBody)} + ${reportCard('Sales Tax (QBO)', taxSummaryBody)}
`; + + // Alpine scannt das DOM nur beim Start — nachgereichtes Markup selbst anmelden. + initAlpineTree(el); } // ── Report 1: AR Aging ────────────────────────────────────────────── @@ -200,11 +268,11 @@ export async function loadArAging() { showLoading('ar-result', 'Loading receivables…'); try { const data = await window.API.reports.getArAging(arAsOf, arAnonymize); - if (data.error) return showError('ar-result', data.error); + if (data.error) return reportError('ar-result', data.error); arData = data; renderArAging(data); } catch (err) { - showError('ar-result', err.message || 'Failed to load AR aging'); + reportError('ar-result', err.message || 'Failed to load AR aging'); } } @@ -293,7 +361,7 @@ function renderArAging(data) { export function exportArAgingCsv() { if (!arData || !arData.rows.length) { - return showError('ar-result', 'Run the report first — there is nothing to export.'); + return reportError('ar-result', 'Run the report first — there is nothing to export.'); } const lines = []; lines.push(csvRow([`Accounts Receivable Aging as of ${arData.asOf}`])); @@ -334,17 +402,17 @@ export async function loadRevenue() { revAnonymize = document.getElementById('rev-anonymize')?.checked || false; revGroupByMonth = document.getElementById('rev-group-month')?.checked || false; - if (!revFrom || !revTo) return showError('rev-result', 'Please select both a from and a to date.'); - if (revFrom > revTo) return showError('rev-result', 'The from date must not be after the to date.'); + if (!revFrom || !revTo) return reportError('rev-result', 'Please select both a from and a to date.'); + if (revFrom > revTo) return reportError('rev-result', 'The from date must not be after the to date.'); showLoading('rev-result', 'Loading revenue…'); try { const data = await window.API.reports.getRevenue(revFrom, revTo, revAnonymize, revGroupByMonth); - if (data.error) return showError('rev-result', data.error); + if (data.error) return reportError('rev-result', data.error); revData = data; renderRevenue(data); } catch (err) { - showError('rev-result', err.message || 'Failed to load revenue'); + reportError('rev-result', err.message || 'Failed to load revenue'); } } @@ -432,7 +500,7 @@ function renderRevenue(data) { export function exportRevenueCsv() { if (!revData || !revData.rows.length) { - return showError('rev-result', 'Run the report first — there is nothing to export.'); + return reportError('rev-result', 'Run the report first — there is nothing to export.'); } const lines = []; lines.push(csvRow([`Invoice-Level Revenue ${revData.from} to ${revData.to}`])); @@ -472,11 +540,152 @@ export function exportRevenueCsv() { downloadCsv(`revenue-${revData.from}_to_${revData.to}${revData.anonymized ? '-anonymized' : ''}.csv`, lines); } +// ──────────────────────────────────────────────────────────────────── +// Aus accounting-view.js übernommene Reports +// Funktionskörper 1:1; geändert wurden nur die onclick-Namespaces im +// zugehörigen Markup und der Titel-Parameter von showError. +// ──────────────────────────────────────────────────────────────────── + +export async function loadProfitLoss() { + plStartDate = document.getElementById('pl-start').value; + plEndDate = document.getElementById('pl-end').value; + plAccountingMethod = document.getElementById('pl-method').value; + showLoading('pl-result', 'Loading P&L from QBO…'); + try { + const data = await window.API.accounting.getProfitAndLoss(plStartDate, plEndDate, plAccountingMethod); + if (data.error) return showError('pl-result', data.error); + document.getElementById('pl-result').innerHTML = renderQboReport(data); + } catch (err) { showError('pl-result', err.message || 'Failed to load P&L'); } +} + +export async function loadBalanceSheet() { + bsAsOfDate = document.getElementById('bs-asof').value; + bsAccountingMethod = document.getElementById('bs-method').value; + showLoading('bs-result', 'Loading Balance Sheet from QBO…'); + try { + const data = await window.API.accounting.getBalanceSheet(bsAsOfDate, bsAccountingMethod); + if (data.error) return showError('bs-result', data.error); + document.getElementById('bs-result').innerHTML = renderQboReport(data); + } catch (err) { showError('bs-result', err.message || 'Failed to load Balance Sheet'); } +} + +export async function loadTaxSummary() { + tsMonth = document.getElementById('ts-month').value; + tsAccountingMethod = document.getElementById('ts-method').value; + if (!tsMonth) return showError('ts-result', 'Please select a month.'); + const [y, m] = tsMonth.split('-').map(Number); + const startDate = firstOfMonthISO(y, m - 1); + const endDate = lastOfMonthISO(y, m - 1); + showLoading('ts-result', 'Loading Sales Tax Liability from QBO…'); + try { + const data = await window.API.accounting.getTaxSummary(startDate, endDate, tsAccountingMethod); + if (data.error) return showError('ts-result', data.error); + document.getElementById('ts-result').innerHTML = renderQboReport(data); + } catch (err) { showError('ts-result', err.message || 'Failed to load Tax Summary'); } +} + +export async function loadCustomerRevenue() { + crStartDate = document.getElementById('cr-start').value; + crEndDate = document.getElementById('cr-end').value; + const anonymize = document.getElementById('cr-anonymize')?.checked || false; + if (!crStartDate || !crEndDate) return showError('cr-result', 'Please select both start and end dates.'); + showLoading('cr-result', 'Loading customer revenue...'); + const maskName = (name) => anonymize ? name.charAt(0) : name; + try { + const data = await window.API.accounting.getCustomerRevenue(crStartDate, crEndDate); + if (data.error) return showError('cr-result', data.error); + if (!data.length) { + document.getElementById('cr-result').innerHTML = '

No invoices found in this period.

'; + return; + } + const grandTotal = parseFloat(data[0].grand_total) || 0; + const totalInvoices = data.reduce((s, r) => s + parseInt(r.invoice_count), 0); + let rowsHtml = ''; + let rank = 0; + for (const r of data) { + rank++; + const rev = parseFloat(r.total_revenue) || 0; + const pct = grandTotal > 0 ? ((rev / grandTotal) * 100).toFixed(1) : '0.0'; + rowsHtml += ` + ${rank}. ${escapeHtml(maskName(r.customer_name))} + ${r.invoice_count} + ${fmtMoney(rev)} + ${pct}% + `; + } + rowsHtml += ` + TOTAL (${data.length} customers) + ${totalInvoices} + ${fmtMoney(grandTotal)} + 100.0% + `; + + document.getElementById('cr-result').innerHTML = ` +
+ + + + + + + + + + ${rowsHtml} +
CustomerInvoicesRevenue (net)% of Total
+
`; + } catch (err) { showError('cr-result', err.message || 'Failed to load revenue report'); } +} + +export function exportCustomerRevenuePdf() { + const startEl = document.getElementById('cr-start'); + const endEl = document.getElementById('cr-end'); + const anonymize = document.getElementById('cr-anonymize')?.checked || false; + if (!startEl?.value || !endEl?.value) return alert('Please select start and end dates first.'); + let url = `/api/accounting/reports/customer-revenue/pdf?startDate=${startEl.value}&endDate=${endEl.value}`; + if (anonymize) url += '&anonymize=true'; + window.open(url, '_blank'); +} + +export function exportProfitLossPdf() { + const startEl = document.getElementById('pl-start'); + const endEl = document.getElementById('pl-end'); + const method = document.getElementById('pl-method')?.value || 'Accrual'; + if (!startEl?.value || !endEl?.value) return alert('Please select start and end dates first.'); + window.open(`/api/accounting/reports/profit-loss/pdf?startDate=${startEl.value}&endDate=${endEl.value}&accountingMethod=${method}`, '_blank'); +} + +export function exportBalanceSheetPdf() { + const asOf = document.getElementById('bs-asof'); + const method = document.getElementById('bs-method')?.value || 'Accrual'; + if (!asOf?.value) return alert('Please select an as-of date first.'); + window.open(`/api/accounting/reports/balance-sheet/pdf?asOfDate=${asOf.value}&accountingMethod=${method}`, '_blank'); +} + +export function exportTaxSummaryPdf() { + const monthEl = document.getElementById('ts-month'); + const method = document.getElementById('ts-method')?.value || 'Accrual'; + if (!monthEl?.value) return alert('Please select a month first.'); + const [y, m] = monthEl.value.split('-').map(Number); + const startDate = firstOfMonthISO(y, m - 1); + const endDate = lastOfMonthISO(y, m - 1); + window.open(`/api/accounting/reports/tax-summary/pdf?startDate=${startDate}&endDate=${endDate}&accountingMethod=${method}`, '_blank'); +} + // ── Expose for onclick handlers ───────────────────────────────────── window.reportsView = { + renderReportsView, loadArAging, exportArAgingCsv, loadRevenue, - exportRevenueCsv + exportRevenueCsv, + loadProfitLoss, + loadBalanceSheet, + loadTaxSummary, + loadCustomerRevenue, + exportProfitLossPdf, + exportBalanceSheetPdf, + exportTaxSummaryPdf, + exportCustomerRevenuePdf }; diff --git a/public/js/views/salestax-view.js b/public/js/views/salestax-view.js new file mode 100644 index 0000000..ceab738 --- /dev/null +++ b/public/js/views/salestax-view.js @@ -0,0 +1,422 @@ +/** + * salestax-view.js — manuelle Sales-Tax-Verwaltung + * + * Aus accounting-view.js herausgelöst (vormals Sektion "Sales Tax" dort). + * Logik unverändert übernommen; angepasst wurden ausschließlich: + * - der onclick-Namespace: window.accountingView.X() → window.salesTaxView.X() + * - Helper kommen jetzt aus ../utils/report-helpers.js statt modul-lokal + * - der Sektions-Klapper makeCollapsible('Sales Tax', …) entfällt ersatzlos + * + * Hinweis: openTaxPeriodDetail() ruft API.accounting.getTaxSummary() auf — + * dieselbe QBO-Schnittstelle, die der Report "Sales Tax (QBO)" auf dem + * Reports-Tab nutzt. Die Abhängigkeit liegt auf API-Ebene, die Trennung der + * beiden Oberflächen berührt sie nicht. + */ + +import '../utils/api.js'; +import { formatDate } from '../utils/helpers.js'; +import { + fmtMoney, escapeHtml, showError, showLoading, + todayISO, firstOfMonthISO, lastOfMonthISO, prevMonthISO, + renderQboReport +} from '../utils/report-helpers.js'; + +// ──────────────────────────────────────────────────────────────────── +// State (modul-lokal) +// ──────────────────────────────────────────────────────────────────── + +let stPeriods = []; +let stEditingPeriodId = null; // current period in detail dialog, null = new +let stCurrentTaxData = null; // parsed data for the open detail dialog + +// ──────────────────────────────────────────────────────────────────── +// Sales Tax Periods +// ──────────────────────────────────────────────────────────────────── + +export function injectSalesTaxSection() { + const c = document.getElementById('salestax-content'); + if (!c) return; + c.innerHTML = ` +
+
+

Period Overview

+
+ +
+
+
+
+ `; +} + +export async function loadTaxPeriods() { + try { + stPeriods = await window.API.accounting.getTaxPeriods() || []; + } catch (e) { + stPeriods = []; + console.error('Failed to load tax periods:', e.message); + } + renderTaxPeriodsTable(); +} + +function renderTaxPeriodsTable() { + const el = document.getElementById('sales-tax-periods-table'); + if (!el) return; + + if (!stPeriods.length) { + el.innerHTML = `
No sales tax periods recorded yet. Click "+ New Period" to get started.
`; + return; + } + + const rows = stPeriods.map(p => { + const pStatus = p.status || (p.qbo_journal_entry_id ? 'booked' : 'open'); + let statusHtml, statusColor; + if (pStatus === 'booked') { statusHtml = 'Booked'; statusColor = 'bg-green-100 text-green-800'; } + else if (pStatus === 'external' || pStatus === 'paid') { statusHtml = 'Paid'; statusColor = 'bg-green-100 text-green-800'; } + else { statusHtml = 'Open'; statusColor = 'bg-yellow-100 text-yellow-800'; } + + const [py, pm] = String(p.period_start).split('T')[0].split('-'); + const monthLabel = new Date(Number(py), Number(pm) - 1).toLocaleDateString('en-US', { year: 'numeric', month: 'long', timeZone: 'UTC' }); + const adj = parseFloat(p.adjustment_amount) || 0; + const adjStr = adj !== 0 ? (adj > 0 ? `−$${adj.toFixed(2)}` : `+$${Math.abs(adj).toFixed(2)}`) : '—'; + const netPaid = parseFloat(p.net_paid) || parseFloat(p.tax_collected) || 0; + const paidOn = (pStatus === 'external' || pStatus === 'paid' || pStatus === 'booked') + ? (p.booked_at ? formatDate(p.booked_at) : '—') : '—'; + + return ` + + ${monthLabel} + + ${statusHtml} + + ${fmtMoney(parseFloat(p.tax_collected) || 0)} + ${adjStr} + ${fmtMoney(netPaid)} + ${paidOn} + + + + `; + }).join(''); + + el.innerHTML = ` +
+ + + + + + + + + + + + + ${rows} +
PeriodStatusTax AmountAdjustmentNet DuePaid OnActions
+
${stPeriods.length} period${stPeriods.length === 1 ? '' : 's'}
+
`; +} + +export async function openNewTaxPeriod() { + const [y, m] = prevMonthISO().split('-').map(Number); + const startDate = firstOfMonthISO(y, m - 1); + const endDate = lastOfMonthISO(y, m - 1); + await openTaxPeriodDetail(startDate, endDate, null); +} + +export async function openTaxPeriod(periodId) { + const period = stPeriods.find(p => p.id === periodId); + if (!period) return alert('Period not found.'); + await openTaxPeriodDetail(period.period_start, period.period_end, period); +} + +async function openTaxPeriodDetail(startDate, endDate, existingPeriod) { + const detailEl = document.getElementById('sales-tax-detail'); + if (!detailEl) return; + + stEditingPeriodId = existingPeriod ? existingPeriod.id : null; + + if (existingPeriod && existingPeriod.status === 'paid') { + const [py, pm] = String(existingPeriod.period_start).split('T')[0].split('-'); + const MONTHS = ['January','February','March','April','May','June','July','August','September','October','November','December']; + const mLabel = MONTHS[Number(pm) - 1] + ' ' + py; + const adj = parseFloat(existingPeriod.adjustment_amount) || 0; + const adjStr = adj > 0 ? `−${fmtMoney(adj)}` : adj < 0 ? `+$${Math.abs(adj).toFixed(2)}` : '—'; + const paidOn = formatDate(existingPeriod.booked_at); + + detailEl.innerHTML = ` +
+
+

${mLabel} — Sales Tax Detail (Paid)

+ +
+

✅ Paid on ${paidOn}

+ + + + + + + + ${existingPeriod.adjustment_reason ? `` : ''} + + +
Total Sales${fmtMoney(parseFloat(existingPeriod.total_sales) || 0)}
Nontaxable Sales${fmtMoney(parseFloat(existingPeriod.nontaxable_sales) || 0)}
Taxable Sales${fmtMoney(parseFloat(existingPeriod.taxable_sales) || 0)}
Tax Collected${fmtMoney(parseFloat(existingPeriod.tax_collected) || 0)}
Adjustment${adjStr}
Reason: ${escapeHtml(existingPeriod.adjustment_reason)}
Net Paid${fmtMoney(parseFloat(existingPeriod.net_paid) || 0)}
+
`; + detailEl.classList.remove('hidden'); + detailEl.scrollIntoView({ behavior: 'smooth' }); + return; + } + + showLoading('sales-tax-detail', 'Loading tax summary from QBO…'); + let taxData; + try { + taxData = await window.API.accounting.getTaxSummary(startDate, endDate, 'Accrual'); + } catch (e) { + showError('sales-tax-detail', e.message || 'Failed to load tax summary'); + return; + } + + const reportHtml = taxData?.Rows ? renderQboReport(taxData) : `

No tax data for this period.

`; + + const parsed = parseTaxDataFromReport(taxData); + stCurrentTaxData = { ...parsed, startDate, endDate }; + const taxCollected = parsed.taxCollected; + const totalSales = parsed.totalSales; + const nontaxable = parsed.nontaxableSales; + const taxable = parsed.taxableSales; + const adjustments = existingPeriod?.adjustment_amount != null ? parseFloat(existingPeriod.adjustment_amount) || 0 : 0; + const adjReason = existingPeriod?.adjustment_reason || ''; + const netPaid = taxCollected - adjustments; + const periodStatus = existingPeriod?.status || (existingPeriod?.qbo_journal_entry_id ? 'booked' : 'open'); + const isOpen = periodStatus === 'open'; + const isEditable = isOpen; + + const today = todayISO(); + const [y, m] = startDate.split('-').map(Number); + const monthLabel = new Date(y, m - 1).toLocaleDateString('en-US', { year: 'numeric', month: 'long', timeZone: 'UTC' }); + + detailEl.innerHTML = ` +
+
+

${monthLabel} — Sales Tax Detail

+ +
+ ${reportHtml} + +
+
+ Tax Collected: ${fmtMoney(taxCollected)} + Adjustment: ${adjustments > 0 ? `−${fmtMoney(adjustments)}` : adjustments < 0 ? `+$${Math.abs(adjustments).toFixed(2)}` : '—'} + Net Due: ${fmtMoney(netPaid)} +
+ + ${isEditable ? ` +
+
+ + +

Positive = discount (reduces net due)

+
+
+ + +
+
+ + +
+
+ +
+
+

Summary:

+

Tax Collected: ${fmtMoney(taxCollected)}

+ ${adjustments > 0 ? `

Discount: −${fmtMoney(adjustments)}

` : ''} + ${adjustments < 0 ? `

Penalty: +$${Math.abs(adjustments).toFixed(2)}

` : ''} +

Net Due: ${fmtMoney(netPaid)}

+
+
+ + +
+
+ ` : periodStatus === 'booked' ? ` +
+

✅ Booked — Journal Entry #${escapeHtml(existingPeriod.qbo_journal_entry_id)} on ${formatDate(existingPeriod.booked_at)}

+
+ ` : ` +
+

✅ Paid on ${formatDate(existingPeriod.booked_at)}

+
+ `} +
+
`; + + detailEl.classList.remove('hidden'); + detailEl.scrollIntoView({ behavior: 'smooth' }); +} + +export function closeTaxPeriodDetail() { + const el = document.getElementById('sales-tax-detail'); + if (el) el.classList.add('hidden'); + stEditingPeriodId = null; +} + +export function updateTaxPreview() { + if (!stCurrentTaxData) return; + const adjInput = document.getElementById('st-adjustment'); + const adj = parseFloat(adjInput?.value) || 0; + const taxCollected = stCurrentTaxData.taxCollected; + const netPaid = taxCollected - adj; + + const adjDisplay = document.getElementById('st-adj-display'); + if (adjDisplay) { + adjDisplay.className = adj !== 0 ? 'text-red-600' : ''; + adjDisplay.innerHTML = adj > 0 + ? `Adjustment: −${fmtMoney(adj)}` + : adj < 0 + ? `Adjustment: +$${Math.abs(adj).toFixed(2)}` + : `Adjustment: —`; + } + + const netDue = document.getElementById('st-net-due'); + if (netDue) { + netDue.innerHTML = `Net Due: ${fmtMoney(netPaid)}`; + } + + const jeLines = document.getElementById('st-je-lines'); + if (jeLines) { + jeLines.innerHTML = ` +

Summary:

+

Tax Collected: ${fmtMoney(taxCollected)}

+ ${adj > 0 ? `

Discount: −${fmtMoney(adj)}

` : ''} + ${adj < 0 ? `

Penalty: +$${Math.abs(adj).toFixed(2)}

` : ''} +

Net Due: ${fmtMoney(netPaid)}

`; + } +} + +export async function saveTaxPeriodDraft() { + const adjAmount = parseFloat(document.getElementById('st-adjustment').value) || 0; + const adjReason = document.getElementById('st-adjustment-reason').value.trim(); + + if (!stCurrentTaxData) return alert('No tax data loaded.'); + + try { + const period = await window.API.accounting.upsertTaxPeriod({ + period_start: stCurrentTaxData.startDate, + period_end: stCurrentTaxData.endDate, + total_sales: stCurrentTaxData.totalSales, + nontaxable_sales: stCurrentTaxData.nontaxableSales, + taxable_sales: stCurrentTaxData.taxableSales, + tax_collected: stCurrentTaxData.taxCollected, + adjustment_amount: adjAmount, + adjustment_reason: adjReason || null, + net_paid: stCurrentTaxData.taxCollected - adjAmount, + status: 'open' + }); + await loadTaxPeriods(); + if (period?.period_start) { + await openTaxPeriodDetail(period.period_start, period.period_end, period); + } + } catch (e) { + alert('Failed to save: ' + e.message); + } +} + +export async function markPeriodPaid() { + const adjAmount = parseFloat(document.getElementById('st-adjustment').value) || 0; + const adjReason = document.getElementById('st-adjustment-reason').value.trim(); + const paidDate = document.getElementById('st-paid-date').value; + if (!paidDate) return alert('Please select a paid date.'); + + if (!stCurrentTaxData) return alert('No tax data loaded.'); + if (!stEditingPeriodId) return alert('Please save the period first (Save Draft).'); + + const netPaid = stCurrentTaxData.taxCollected - adjAmount; + + if (!confirm(`Mark this period as paid?\n\nPaid Date: ${paidDate}\nTax Collected: $${stCurrentTaxData.taxCollected.toFixed(2)}\nAdjustment: $${adjAmount.toFixed(2)}\nNet Paid: $${netPaid.toFixed(2)}\n\nThis does NOT write to QBO — record the payment in QBO first.`)) return; + + try { + await window.API.accounting.upsertTaxPeriod({ + period_start: stCurrentTaxData.startDate, + period_end: stCurrentTaxData.endDate, + total_sales: stCurrentTaxData.totalSales, + nontaxable_sales: stCurrentTaxData.nontaxableSales, + taxable_sales: stCurrentTaxData.taxableSales, + tax_collected: stCurrentTaxData.taxCollected, + adjustment_amount: adjAmount, + adjustment_reason: adjReason || null, + net_paid: netPaid, + status: 'open' + }); + } catch (e) { + return alert('Failed to save period: ' + e.message); + } + + try { + await window.API.accounting.markTaxPaidExternal(stEditingPeriodId, paidDate); + await loadTaxPeriods(); + const updated = stPeriods.find(p => p.id === stEditingPeriodId); + if (updated) { + await openTaxPeriodDetail(updated.period_start, updated.period_end, updated); + } + } catch (e) { + alert('Failed: ' + e.message); + } +} + +function parseTaxDataFromReport(taxData) { + if (!taxData?.Rows?.Row) return { totalSales: 0, nontaxableSales: 0, taxableSales: 0, taxCollected: 0 }; + const rows = Array.isArray(taxData.Rows.Row) ? taxData.Rows.Row : []; + for (const row of rows) { + if (row.type === 'Section' && row.Header?.ColData?.[0]?.value?.toLowerCase().includes('grand total')) { + const cd = row.Summary?.ColData || []; + return { + totalSales: parseFloat(cd[1]?.value || '0') || 0, + nontaxableSales: parseFloat(cd[2]?.value || '0') || 0, + taxableSales: parseFloat(cd[3]?.value || '0') || 0, + taxCollected: parseFloat(cd[4]?.value || '0') || 0 + }; + } + } + return { totalSales: 0, nontaxableSales: 0, taxableSales: 0, taxCollected: 0 }; +} + +// ──────────────────────────────────────────────────────────────────── +// Init / Public Entry Point +// ──────────────────────────────────────────────────────────────────── + +export function renderSalesTaxView() { + injectSalesTaxSection(); + loadTaxPeriods(); +} + +window.salesTaxView = { + renderSalesTaxView, + injectSalesTaxSection, + loadTaxPeriods, + openNewTaxPeriod, + openTaxPeriod, + closeTaxPeriodDetail, + updateTaxPreview, + saveTaxPeriodDraft, + markPeriodPaid +};