move reports
This commit is contained in:
@@ -30,6 +30,7 @@
|
|||||||
<button onclick="showTab('customers')" id="tab-customers" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Customers</button>
|
<button onclick="showTab('customers')" id="tab-customers" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Customers</button>
|
||||||
<button onclick="showTab('accounting')" id="tab-accounting" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Accounting</button>
|
<button onclick="showTab('accounting')" id="tab-accounting" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Accounting</button>
|
||||||
<button onclick="showTab('reports')" id="tab-reports" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Reports</button>
|
<button onclick="showTab('reports')" id="tab-reports" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Reports</button>
|
||||||
|
<button onclick="showTab('salestax')" id="tab-salestax" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Sales Tax</button>
|
||||||
<button onclick="showTab('settings')" id="tab-settings" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Settings</button>
|
<button onclick="showTab('settings')" id="tab-settings" class="px-4 py-2 rounded hover:bg-blue-800 tab-btn">Settings</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,15 +139,15 @@
|
|||||||
<div id="accounting-expenses"></div>
|
<div id="accounting-expenses"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mb-6">
|
</div>
|
||||||
<h3 class="text-md font-semibold text-gray-700 mb-3">Reports</h3>
|
|
||||||
<div id="accounting-reports"></div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="mb-6">
|
<!-- Sales Tax Tab -->
|
||||||
<h3 class="text-md font-semibold text-gray-700 mb-3">Sales Tax</h3>
|
<div id="salestax-tab" class="tab-content hidden">
|
||||||
<div id="accounting-sales-tax"></div>
|
<div class="mb-6">
|
||||||
</section>
|
<h2 class="text-3xl font-bold text-gray-800">Sales Tax</h2>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">Manual period tracking. Payments must be recorded in QBO separately.</p>
|
||||||
|
</div>
|
||||||
|
<div id="salestax-content"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Reports Tab -->
|
<!-- Reports Tab -->
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import './modals/email-modal.js';
|
|||||||
import { setDefaultDate } from './utils/helpers.js';
|
import { setDefaultDate } from './utils/helpers.js';
|
||||||
import { renderAccountingView } from './views/accounting-view.js';
|
import { renderAccountingView } from './views/accounting-view.js';
|
||||||
import { renderReportsView } from './views/reports-view.js';
|
import { renderReportsView } from './views/reports-view.js';
|
||||||
|
import { renderSalesTaxView } from './views/salestax-view.js';
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Tab Management
|
// Tab Management
|
||||||
@@ -54,6 +55,8 @@ function showTab(tabName) {
|
|||||||
renderAccountingView();
|
renderAccountingView();
|
||||||
} else if (tabName === 'reports') {
|
} else if (tabName === 'reports') {
|
||||||
renderReportsView();
|
renderReportsView();
|
||||||
|
} else if (tabName === 'salestax') {
|
||||||
|
renderSalesTaxView();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +82,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Hash-based navigation (e.g. after OAuth redirect /#settings)
|
// Hash-based navigation (e.g. after OAuth redirect /#settings)
|
||||||
if (window.location.hash) {
|
if (window.location.hash) {
|
||||||
const hashTab = window.location.hash.replace('#', '');
|
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);
|
showTab(hashTab);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
173
public/js/utils/report-helpers.js
Normal file
173
public/js/utils/report-helpers.js
Normal file
@@ -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, '"').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 = `
|
||||||
|
<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p class="font-semibold text-red-800">${escapeHtml(title)}</p>
|
||||||
|
<p class="text-sm text-red-600 mt-1">${escapeHtml(message)}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showLoading(slotId, message = 'Loading…') {
|
||||||
|
const el = document.getElementById(slotId);
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="flex items-center gap-3 p-4 text-gray-500">
|
||||||
|
<svg class="animate-spin h-5 w-5 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>${escapeHtml(message)}</span>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 `<p class="text-sm text-gray-500">No report data.</p>`;
|
||||||
|
const cols = (report.Columns && report.Columns.Column) || [];
|
||||||
|
const headerRow = cols.map(c => `<th class="px-3 py-2 text-right font-medium text-gray-700 first:text-left">${escapeHtml(c.ColTitle || '')}</th>`).join('');
|
||||||
|
const body = report.Rows && report.Rows.Row ? renderReportRows(report.Rows.Row, 0) : '';
|
||||||
|
return `
|
||||||
|
<div class="text-xs text-gray-500 mb-2">
|
||||||
|
${escapeHtml(report.Header.ReportName || '')}
|
||||||
|
${report.Header.StartPeriod ? '· ' + escapeHtml(report.Header.StartPeriod) + ' – ' + escapeHtml(report.Header.EndPeriod) : ''}
|
||||||
|
${report.Header.ReportBasis ? '· ' + escapeHtml(report.Header.ReportBasis) : ''}
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto border border-gray-200 rounded">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 border-b"><tr>${headerRow}</tr></thead>
|
||||||
|
<tbody>${body}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
? `<td class="px-3 py-1.5 font-semibold text-gray-800" style="padding-left:${12 + indent}px">${escapeHtml(c.value || '')}</td>`
|
||||||
|
: `<td class="px-3 py-1.5 text-right text-gray-500"></td>`
|
||||||
|
).join('');
|
||||||
|
html += `<tr class="bg-gray-50">${cells}</tr>`;
|
||||||
|
}
|
||||||
|
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
|
||||||
|
? `<td class="px-3 py-1.5 font-semibold text-gray-700 border-t" style="padding-left:${12 + indent}px">${escapeHtml(c.value || '')}</td>`
|
||||||
|
: `<td class="px-3 py-1.5 text-right font-semibold text-gray-900 border-t">${escapeHtml(c.value || '')}</td>`
|
||||||
|
).join('');
|
||||||
|
html += `<tr>${cells}</tr>`;
|
||||||
|
}
|
||||||
|
if (!isSection && row.ColData) {
|
||||||
|
const cells = row.ColData.map((c, i) =>
|
||||||
|
i === 0
|
||||||
|
? `<td class="px-3 py-1.5" style="padding-left:${12 + indent}px">${escapeHtml(c.value || '')}</td>`
|
||||||
|
: `<td class="px-3 py-1.5 text-right">${escapeHtml(c.value || '')}</td>`
|
||||||
|
).join('');
|
||||||
|
html += `<tr>${cells}</tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -9,6 +9,10 @@ import '../utils/api.js';
|
|||||||
import { formatDate } from '../utils/helpers.js';
|
import { formatDate } from '../utils/helpers.js';
|
||||||
import { openExpenseModal } from '../modals/expense-modal.js';
|
import { openExpenseModal } from '../modals/expense-modal.js';
|
||||||
import { openRefundModal } from '../modals/refund-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)
|
// State (modul-lokal)
|
||||||
@@ -20,22 +24,6 @@ let registerStartDate = null;
|
|||||||
let registerEndDate = null;
|
let registerEndDate = null;
|
||||||
let registerLoadSeq = 0;
|
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 expStartDate = null;
|
||||||
let expEndDate = null;
|
let expEndDate = null;
|
||||||
let expOnlyMine = false;
|
let expOnlyMine = false;
|
||||||
@@ -43,67 +31,6 @@ let expOnlyMine = false;
|
|||||||
// Auto-Sync nur einmal pro View-Mount
|
// Auto-Sync nur einmal pro View-Mount
|
||||||
let autoSyncDoneThisOpen = false;
|
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, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showError(slotId, message) {
|
|
||||||
const el = document.getElementById(slotId);
|
|
||||||
if (!el) return;
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
|
||||||
<p class="font-semibold text-red-800">QBO Error</p>
|
|
||||||
<p class="text-sm text-red-600 mt-1">${escapeHtml(message)}</p>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLoading(slotId, message = 'Loading…') {
|
|
||||||
const el = document.getElementById(slotId);
|
|
||||||
if (!el) return;
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="flex items-center gap-3 p-4 text-gray-500">
|
|
||||||
<svg class="animate-spin h-5 w-5 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
||||||
</svg>
|
|
||||||
<span>${escapeHtml(message)}</span>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
function makeCollapsible(headerText, contentId, startCollapsed = false) {
|
function makeCollapsible(headerText, contentId, startCollapsed = false) {
|
||||||
return `
|
return `
|
||||||
<div class="flex items-center gap-2 cursor-pointer select-none mb-2"
|
<div class="flex items-center gap-2 cursor-pointer select-none mb-2"
|
||||||
@@ -425,280 +352,6 @@ function renderSplitCell(r) {
|
|||||||
return `<div class="space-y-0.5">${lines}</div>`;
|
return `<div class="space-y-0.5">${lines}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
// 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')}
|
|
||||||
<div id="reports-section-body">
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
|
||||||
<div class="px-4 py-3 border-b bg-gray-50"><h3 class="font-semibold text-gray-800">Profit & Loss</h3></div>
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="flex flex-wrap items-end gap-3 mb-3">
|
|
||||||
<div><label class="block text-xs font-medium text-gray-700 mb-1">Start</label>
|
|
||||||
<input type="date" id="pl-start" value="${plStartDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
|
|
||||||
<div><label class="block text-xs font-medium text-gray-700 mb-1">End</label>
|
|
||||||
<input type="date" id="pl-end" value="${plEndDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
|
|
||||||
<div><label class="block text-xs font-medium text-gray-700 mb-1">Method</label>
|
|
||||||
<select id="pl-method" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
|
|
||||||
<option value="Accrual" ${plAccountingMethod === 'Accrual' ? 'selected' : ''}>Accrual</option>
|
|
||||||
<option value="Cash" ${plAccountingMethod === 'Cash' ? 'selected' : ''}>Cash</option>
|
|
||||||
</select></div>
|
|
||||||
<button onclick="window.accountingView.loadProfitLoss()" class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
|
|
||||||
<button onclick="window.accountingView.exportProfitLossPdf()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">📄 PDF</button>
|
|
||||||
</div>
|
|
||||||
<div id="pl-result"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
|
||||||
<div class="px-4 py-3 border-b bg-gray-50"><h3 class="font-semibold text-gray-800">Balance Sheet</h3></div>
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="flex flex-wrap items-end gap-3 mb-3">
|
|
||||||
<div><label class="block text-xs font-medium text-gray-700 mb-1">As of</label>
|
|
||||||
<input type="date" id="bs-asof" value="${bsAsOfDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
|
|
||||||
<div><label class="block text-xs font-medium text-gray-700 mb-1">Method</label>
|
|
||||||
<select id="bs-method" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
|
|
||||||
<option value="Accrual" ${bsAccountingMethod === 'Accrual' ? 'selected' : ''}>Accrual</option>
|
|
||||||
<option value="Cash" ${bsAccountingMethod === 'Cash' ? 'selected' : ''}>Cash</option>
|
|
||||||
</select></div>
|
|
||||||
<button onclick="window.accountingView.loadBalanceSheet()" class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
|
|
||||||
<button onclick="window.accountingView.exportBalanceSheetPdf()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">📄 PDF</button>
|
|
||||||
</div>
|
|
||||||
<div id="bs-result"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
|
||||||
<div class="px-4 py-3 border-b bg-gray-50"><h3 class="font-semibold text-gray-800">Sales Tax (QBO)</h3></div>
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="flex flex-wrap items-end gap-3 mb-3">
|
|
||||||
<div><label class="block text-xs font-medium text-gray-700 mb-1">Month</label>
|
|
||||||
<input type="month" id="ts-month" value="${tsMonth}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
|
|
||||||
<div><label class="block text-xs font-medium text-gray-700 mb-1">Method</label>
|
|
||||||
<select id="ts-method" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
|
|
||||||
<option value="Accrual" ${tsAccountingMethod === 'Accrual' ? 'selected' : ''}>Accrual</option>
|
|
||||||
<option value="Cash" ${tsAccountingMethod === 'Cash' ? 'selected' : ''}>Cash</option>
|
|
||||||
</select></div>
|
|
||||||
<button onclick="window.accountingView.loadTaxSummary()" class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
|
|
||||||
<button onclick="window.accountingView.exportTaxSummaryPdf()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">📄 PDF</button>
|
|
||||||
</div>
|
|
||||||
<div id="ts-result"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
|
||||||
<div class="px-4 py-3 border-b bg-gray-50"><h3 class="font-semibold text-gray-800">Customer Revenue</h3></div>
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="flex flex-wrap items-end gap-3 mb-3">
|
|
||||||
<div><label class="block text-xs font-medium text-gray-700 mb-1">Start</label>
|
|
||||||
<input type="date" id="cr-start" value="${crStartDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
|
|
||||||
<div><label class="block text-xs font-medium text-gray-700 mb-1">End</label>
|
|
||||||
<input type="date" id="cr-end" value="${crEndDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
|
|
||||||
<button onclick="window.accountingView.loadCustomerRevenue()" class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
|
|
||||||
<button onclick="window.accountingView.exportCustomerRevenuePdf()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">📄 Export PDF</button>
|
|
||||||
<label class="flex items-center gap-1 pt-5 text-xs text-gray-600 cursor-pointer">
|
|
||||||
<input type="checkbox" id="cr-anonymize" class="h-4 w-4 text-blue-600 border-gray-300 rounded">
|
|
||||||
Anonymize
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div id="cr-result"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = '<p class="text-sm text-gray-500">No invoices found in this period.</p>';
|
|
||||||
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 += `<tr class="border-t hover:bg-gray-50">
|
|
||||||
<td class="px-3 py-2 text-sm font-medium">${rank}. ${escapeHtml(maskName(r.customer_name))}</td>
|
|
||||||
<td class="px-3 py-2 text-sm text-center">${r.invoice_count}</td>
|
|
||||||
<td class="px-3 py-2 text-sm text-right">${fmtMoney(rev)}</td>
|
|
||||||
<td class="px-3 py-2 text-sm text-right text-gray-500">${pct}%</td>
|
|
||||||
</tr>`;
|
|
||||||
}
|
|
||||||
rowsHtml += `<tr class="border-t bg-gray-50 font-semibold">
|
|
||||||
<td class="px-3 py-2 text-sm">TOTAL (${data.length} customers)</td>
|
|
||||||
<td class="px-3 py-2 text-sm text-center">${totalInvoices}</td>
|
|
||||||
<td class="px-3 py-2 text-sm text-right">${fmtMoney(grandTotal)}</td>
|
|
||||||
<td class="px-3 py-2 text-sm text-right">100.0%</td>
|
|
||||||
</tr>`;
|
|
||||||
|
|
||||||
document.getElementById('cr-result').innerHTML = `
|
|
||||||
<div class="overflow-x-auto border border-gray-200 rounded">
|
|
||||||
<table class="min-w-full text-sm">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th class="px-3 py-2 text-left font-medium text-gray-700">Customer</th>
|
|
||||||
<th class="px-3 py-2 text-center font-medium text-gray-700">Invoices</th>
|
|
||||||
<th class="px-3 py-2 text-right font-medium text-gray-700">Revenue (net)</th>
|
|
||||||
<th class="px-3 py-2 text-right font-medium text-gray-700">% of Total</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>${rowsHtml}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>`;
|
|
||||||
} 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 `<p class="text-sm text-gray-500">No report data.</p>`;
|
|
||||||
const cols = (report.Columns && report.Columns.Column) || [];
|
|
||||||
const headerRow = cols.map(c => `<th class="px-3 py-2 text-right font-medium text-gray-700 first:text-left">${escapeHtml(c.ColTitle || '')}</th>`).join('');
|
|
||||||
const body = report.Rows && report.Rows.Row ? renderReportRows(report.Rows.Row, 0) : '';
|
|
||||||
return `
|
|
||||||
<div class="text-xs text-gray-500 mb-2">
|
|
||||||
${escapeHtml(report.Header.ReportName || '')}
|
|
||||||
${report.Header.StartPeriod ? '· ' + escapeHtml(report.Header.StartPeriod) + ' – ' + escapeHtml(report.Header.EndPeriod) : ''}
|
|
||||||
${report.Header.ReportBasis ? '· ' + escapeHtml(report.Header.ReportBasis) : ''}
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto border border-gray-200 rounded">
|
|
||||||
<table class="min-w-full text-sm">
|
|
||||||
<thead class="bg-gray-50 border-b"><tr>${headerRow}</tr></thead>
|
|
||||||
<tbody>${body}</tbody>
|
|
||||||
</table>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
? `<td class="px-3 py-1.5 font-semibold text-gray-800" style="padding-left:${12 + indent}px">${escapeHtml(c.value || '')}</td>`
|
|
||||||
: `<td class="px-3 py-1.5 text-right text-gray-500"></td>`
|
|
||||||
).join('');
|
|
||||||
html += `<tr class="bg-gray-50">${cells}</tr>`;
|
|
||||||
}
|
|
||||||
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
|
|
||||||
? `<td class="px-3 py-1.5 font-semibold text-gray-700 border-t" style="padding-left:${12 + indent}px">${escapeHtml(c.value || '')}</td>`
|
|
||||||
: `<td class="px-3 py-1.5 text-right font-semibold text-gray-900 border-t">${escapeHtml(c.value || '')}</td>`
|
|
||||||
).join('');
|
|
||||||
html += `<tr>${cells}</tr>`;
|
|
||||||
}
|
|
||||||
if (!isSection && row.ColData) {
|
|
||||||
const cells = row.ColData.map((c, i) =>
|
|
||||||
i === 0
|
|
||||||
? `<td class="px-3 py-1.5" style="padding-left:${12 + indent}px">${escapeHtml(c.value || '')}</td>`
|
|
||||||
: `<td class="px-3 py-1.5 text-right">${escapeHtml(c.value || '')}</td>`
|
|
||||||
).join('');
|
|
||||||
html += `<tr>${cells}</tr>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
// Phase 2 Lieferung 2 — Expenses Section
|
// Phase 2 Lieferung 2 — Expenses Section
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
@@ -829,382 +482,6 @@ function renderExpensesTable(expenses) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────────
|
|
||||||
// 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')}
|
|
||||||
<div id="sales-tax-section-body">
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
|
||||||
<div class="px-4 py-3 border-b bg-gray-50 flex flex-wrap items-center gap-3">
|
|
||||||
<h3 class="font-semibold text-gray-800 text-sm">Period Overview</h3>
|
|
||||||
<div class="ml-auto flex gap-2">
|
|
||||||
<button onclick="window.accountingView.openNewTaxPeriod()"
|
|
||||||
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">
|
|
||||||
+ New Period
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div id="sales-tax-periods-table"></div>
|
|
||||||
</div>
|
|
||||||
<div id="sales-tax-detail" class="mt-4 hidden"></div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = `<div class="p-4 text-gray-500 text-sm">No sales tax periods recorded yet. Click "+ New Period" to get started.</div>`;
|
|
||||||
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 `
|
|
||||||
<tr class="border-t hover:bg-gray-50">
|
|
||||||
<td class="px-3 py-2 text-sm font-medium text-gray-800">${monthLabel}</td>
|
|
||||||
<td class="px-3 py-2 text-sm">
|
|
||||||
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${statusColor}">${statusHtml}</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-3 py-2 text-sm text-right">${fmtMoney(parseFloat(p.tax_collected) || 0)}</td>
|
|
||||||
<td class="px-3 py-2 text-sm text-right">${adjStr}</td>
|
|
||||||
<td class="px-3 py-2 text-sm text-right font-semibold">${fmtMoney(netPaid)}</td>
|
|
||||||
<td class="px-3 py-2 text-sm text-gray-500">${paidOn}</td>
|
|
||||||
<td class="px-3 py-2 text-sm text-center">
|
|
||||||
<button onclick="window.accountingView.openTaxPeriod(${p.id})"
|
|
||||||
class="text-blue-600 hover:text-blue-800 text-xs font-medium">View summary</button>
|
|
||||||
</td>
|
|
||||||
</tr>`;
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="min-w-full text-sm">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th class="px-3 py-2 text-left font-medium text-gray-700">Period</th>
|
|
||||||
<th class="px-3 py-2 text-left font-medium text-gray-700">Status</th>
|
|
||||||
<th class="px-3 py-2 text-right font-medium text-gray-700">Tax Amount</th>
|
|
||||||
<th class="px-3 py-2 text-right font-medium text-gray-700">Adjustment</th>
|
|
||||||
<th class="px-3 py-2 text-right font-medium text-gray-700">Net Due</th>
|
|
||||||
<th class="px-3 py-2 text-left font-medium text-gray-700">Paid On</th>
|
|
||||||
<th class="px-3 py-2 text-center font-medium text-gray-700">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>${rows}</tbody>
|
|
||||||
</table>
|
|
||||||
<div class="px-3 py-2 text-xs text-gray-500 border-t bg-gray-50">${stPeriods.length} period${stPeriods.length === 1 ? '' : 's'}</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = `
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<div class="flex items-center justify-between mb-3">
|
|
||||||
<h3 class="font-semibold text-gray-800">${mLabel} \u2014 Sales Tax Detail (Paid)</h3>
|
|
||||||
<button onclick="window.accountingView.closeTaxPeriodDetail()" class="px-2 py-1 text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-green-700 font-semibold mb-4">\u2705 Paid on ${paidOn}</p>
|
|
||||||
<table class="min-w-full text-sm border border-gray-200 rounded">
|
|
||||||
<tbody>
|
|
||||||
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Total Sales</td><td class="px-3 py-2 text-right font-medium">${fmtMoney(parseFloat(existingPeriod.total_sales) || 0)}</td></tr>
|
|
||||||
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Nontaxable Sales</td><td class="px-3 py-2 text-right font-medium">${fmtMoney(parseFloat(existingPeriod.nontaxable_sales) || 0)}</td></tr>
|
|
||||||
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Taxable Sales</td><td class="px-3 py-2 text-right font-medium">${fmtMoney(parseFloat(existingPeriod.taxable_sales) || 0)}</td></tr>
|
|
||||||
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Tax Collected</td><td class="px-3 py-2 text-right font-medium">${fmtMoney(parseFloat(existingPeriod.tax_collected) || 0)}</td></tr>
|
|
||||||
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Adjustment</td><td class="px-3 py-2 text-right font-medium ${adj !== 0 ? 'text-red-600' : ''}">${adjStr}</td></tr>
|
|
||||||
${existingPeriod.adjustment_reason ? `<tr class="border-t"><td class="px-3 py-2 text-gray-500 text-xs pl-8" colspan="2">Reason: ${escapeHtml(existingPeriod.adjustment_reason)}</td></tr>` : ''}
|
|
||||||
<tr class="border-t bg-gray-50 font-semibold"><td class="px-3 py-2">Net Paid</td><td class="px-3 py-2 text-right">${fmtMoney(parseFloat(existingPeriod.net_paid) || 0)}</td></tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>`;
|
|
||||||
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) : `<p class="text-sm text-gray-500">No tax data for this period.</p>`;
|
|
||||||
|
|
||||||
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 = `
|
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
|
||||||
<div class="flex items-center justify-between mb-3">
|
|
||||||
<h3 class="font-semibold text-gray-800">${monthLabel} — Sales Tax Detail</h3>
|
|
||||||
<button onclick="window.accountingView.closeTaxPeriodDetail()"
|
|
||||||
class="px-2 py-1 text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
|
||||||
</div>
|
|
||||||
${reportHtml}
|
|
||||||
|
|
||||||
<div class="mt-4 border-t pt-4">
|
|
||||||
<div class="flex items-center gap-4 text-sm mb-3" id="st-summary-bar">
|
|
||||||
<span><strong>Tax Collected:</strong> ${fmtMoney(taxCollected)}</span>
|
|
||||||
<span id="st-adj-display" class="${adjustments !== 0 ? 'text-red-600' : ''}"><strong>Adjustment:</strong> ${adjustments > 0 ? `−${fmtMoney(adjustments)}` : adjustments < 0 ? `+$${Math.abs(adjustments).toFixed(2)}` : '—'}</span>
|
|
||||||
<span id="st-net-due" class="text-lg font-bold text-gray-900"><strong>Net Due:</strong> ${fmtMoney(netPaid)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${isEditable ? `
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Adjustment Amount</label>
|
|
||||||
<input type="number" step="0.01" id="st-adjustment" value="${adjustments !== 0 ? adjustments : ''}" placeholder="e.g. 6.15 (discount)"
|
|
||||||
oninput="window.accountingView.updateTaxPreview()"
|
|
||||||
class="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm">
|
|
||||||
<p class="text-xs text-gray-400 mt-0.5">Positive = discount (reduces net due)</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Reason</label>
|
|
||||||
<input type="text" id="st-adjustment-reason" value="${escapeHtml(adjReason)}" placeholder="e.g. Timely filing discount"
|
|
||||||
class="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">Paid Date (QBO)</label>
|
|
||||||
<input type="date" id="st-paid-date" value="${today}"
|
|
||||||
class="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between border-t pt-3 mt-1">
|
|
||||||
<div id="st-je-lines">
|
|
||||||
<p class="text-sm"><strong>Summary:</strong></p>
|
|
||||||
<p class="text-xs text-gray-500">Tax Collected: ${fmtMoney(taxCollected)}</p>
|
|
||||||
${adjustments > 0 ? `<p class="text-xs text-gray-500">Discount: \u2212${fmtMoney(adjustments)}</p>` : ''}
|
|
||||||
${adjustments < 0 ? `<p class="text-xs text-gray-500">Penalty: +$${Math.abs(adjustments).toFixed(2)}</p>` : ''}
|
|
||||||
<p class="text-xs text-gray-500 font-semibold">Net Due: ${fmtMoney(netPaid)}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button onclick="window.accountingView.saveTaxPeriodDraft()"
|
|
||||||
class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">
|
|
||||||
💾 Save Draft
|
|
||||||
</button>
|
|
||||||
<button onclick="window.accountingView.markPeriodPaid()"
|
|
||||||
class="px-4 py-2 bg-green-600 text-white rounded-md text-sm font-semibold hover:bg-green-700">
|
|
||||||
✅ Mark as Paid
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
` : periodStatus === 'booked' ? `
|
|
||||||
<div class="border-t pt-3 mt-1">
|
|
||||||
<p class="text-sm text-green-700 font-semibold">✅ Booked — Journal Entry #${escapeHtml(existingPeriod.qbo_journal_entry_id)} on ${formatDate(existingPeriod.booked_at)}</p>
|
|
||||||
</div>
|
|
||||||
` : `
|
|
||||||
<div class="border-t pt-3 mt-1">
|
|
||||||
<p class="text-sm text-green-700 font-semibold">✅ Paid on ${formatDate(existingPeriod.booked_at)}</p>
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
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
|
|
||||||
? `<strong>Adjustment:</strong> −${fmtMoney(adj)}`
|
|
||||||
: adj < 0
|
|
||||||
? `<strong>Adjustment:</strong> +$${Math.abs(adj).toFixed(2)}`
|
|
||||||
: `<strong>Adjustment:</strong> —`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const netDue = document.getElementById('st-net-due');
|
|
||||||
if (netDue) {
|
|
||||||
netDue.innerHTML = `<strong>Net Due:</strong> ${fmtMoney(netPaid)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const jeLines = document.getElementById('st-je-lines');
|
|
||||||
if (jeLines) {
|
|
||||||
jeLines.innerHTML = `
|
|
||||||
<p class="text-sm"><strong>Summary:</strong></p>
|
|
||||||
<p class="text-xs text-gray-500">Tax Collected: ${fmtMoney(taxCollected)}</p>
|
|
||||||
${adj > 0 ? `<p class="text-xs text-gray-500">Discount: \u2212${fmtMoney(adj)}</p>` : ''}
|
|
||||||
${adj < 0 ? `<p class="text-xs text-gray-500">Penalty: +$${Math.abs(adj).toFixed(2)}</p>` : ''}
|
|
||||||
<p class="text-xs text-gray-500 font-semibold">Net Due: ${fmtMoney(netPaid)}</p>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
export async function openNewExpense() {
|
||||||
await openExpenseModal({
|
await openExpenseModal({
|
||||||
onSaved: () => loadExpenses()
|
onSaved: () => loadExpenses()
|
||||||
@@ -1219,10 +496,7 @@ export function renderAccountingView() {
|
|||||||
autoSyncDoneThisOpen = false;
|
autoSyncDoneThisOpen = false;
|
||||||
injectToolbar();
|
injectToolbar();
|
||||||
injectRegisterControls();
|
injectRegisterControls();
|
||||||
injectReportsControls();
|
|
||||||
injectExpensesSection();
|
injectExpensesSection();
|
||||||
injectSalesTaxSection();
|
|
||||||
loadTaxPeriods();
|
|
||||||
|
|
||||||
maybeAutoSyncCaches().then(() => {
|
maybeAutoSyncCaches().then(() => {
|
||||||
loadAccountsOverview();
|
loadAccountsOverview();
|
||||||
@@ -1260,26 +534,10 @@ window.accountingView = {
|
|||||||
manualSync,
|
manualSync,
|
||||||
loadAccountsOverview,
|
loadAccountsOverview,
|
||||||
loadRegister,
|
loadRegister,
|
||||||
loadProfitLoss,
|
|
||||||
loadBalanceSheet,
|
|
||||||
loadTaxSummary,
|
|
||||||
loadExpenses,
|
loadExpenses,
|
||||||
openNewExpense,
|
openNewExpense,
|
||||||
openNewRefund,
|
openNewRefund,
|
||||||
editExpense,
|
editExpense,
|
||||||
selectRegisterAccount,
|
selectRegisterAccount,
|
||||||
toggleSection,
|
toggleSection
|
||||||
injectSalesTaxSection,
|
|
||||||
loadTaxPeriods,
|
|
||||||
openNewTaxPeriod,
|
|
||||||
openTaxPeriod,
|
|
||||||
closeTaxPeriodDetail,
|
|
||||||
updateTaxPreview,
|
|
||||||
saveTaxPeriodDraft,
|
|
||||||
markPeriodPaid,
|
|
||||||
loadCustomerRevenue,
|
|
||||||
exportCustomerRevenuePdf,
|
|
||||||
exportProfitLossPdf,
|
|
||||||
exportBalanceSheetPdf,
|
|
||||||
exportTaxSummaryPdf
|
|
||||||
};
|
};
|
||||||
@@ -12,7 +12,13 @@
|
|||||||
* formatiert. Ins CSV gehen die Rohwerte, damit Excel sauber rechnen kann.
|
* formatiert. Ins CSV gehen die Rohwerte, damit Excel sauber rechnen kann.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import '../utils/api.js';
|
||||||
import { formatDate } from '../utils/helpers.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 ───────────────────────────────────────────────────────────
|
// ── State ───────────────────────────────────────────────────────────
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
@@ -28,6 +34,20 @@ let revAnonymize = false;
|
|||||||
let revGroupByMonth = false;
|
let revGroupByMonth = false;
|
||||||
let revData = null;
|
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 = [
|
const AR_BUCKETS = [
|
||||||
{ key: 'current', label: 'Current' },
|
{ key: 'current', label: 'Current' },
|
||||||
{ key: 'd1_30', label: '1–30 days' },
|
{ 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]));
|
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. */
|
/** showError mit dem Reports-spezifischen Titel. */
|
||||||
function fmtMoney(v) {
|
function reportError(slotId, message) {
|
||||||
if (v == null || v === '') return '—';
|
showError(slotId, message, 'Report Error');
|
||||||
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, '"').replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function showError(slotId, message) {
|
|
||||||
const el = document.getElementById(slotId);
|
|
||||||
if (!el) return;
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
|
||||||
<p class="font-semibold text-red-800">Report Error</p>
|
|
||||||
<p class="text-sm text-red-600 mt-1">${escapeHtml(message)}</p>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showLoading(slotId, message = 'Loading…') {
|
|
||||||
const el = document.getElementById(slotId);
|
|
||||||
if (!el) return;
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="flex items-center gap-3 p-4 text-gray-500">
|
|
||||||
<svg class="animate-spin h-5 w-5 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
||||||
</svg>
|
|
||||||
<span>${escapeHtml(message)}</span>
|
|
||||||
</div>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function methodologyBox(text, anonymized) {
|
function methodologyBox(text, anonymized) {
|
||||||
@@ -121,19 +106,39 @@ function downloadCsv(filename, lines) {
|
|||||||
|
|
||||||
// ── Shell ───────────────────────────────────────────────────────────
|
// ── 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 `
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200" x-data="{ open: false }">
|
||||||
|
<div class="px-4 py-3 border-b bg-gray-50 flex items-center gap-2 cursor-pointer select-none"
|
||||||
|
@click="open = !open">
|
||||||
|
<svg class="w-4 h-4 text-gray-500 transition-transform" :class="open ? 'rotate-90' : ''"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="font-semibold text-gray-800">${title}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-4" x-show="open" style="display:none">
|
||||||
|
${bodyHtml}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderReportsView() {
|
export function renderReportsView() {
|
||||||
const el = document.getElementById('reports-content');
|
const el = document.getElementById('reports-content');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
el.innerHTML = `
|
// Defaults der übernommenen Reports — 1:1 aus injectReportsControls()
|
||||||
<div class="space-y-6">
|
if (!plStartDate) plStartDate = firstOfYearISO();
|
||||||
|
if (!plEndDate) plEndDate = todayISO();
|
||||||
|
if (!bsAsOfDate) bsAsOfDate = todayISO();
|
||||||
|
if (!tsMonth) tsMonth = prevMonthISO();
|
||||||
|
if (!crStartDate) crStartDate = firstOfYearISO();
|
||||||
|
if (!crEndDate) crEndDate = todayISO();
|
||||||
|
|
||||||
<!-- Report 1: AR Aging -->
|
const arAgingBody = `
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
|
||||||
<div class="px-4 py-3 border-b bg-gray-50">
|
|
||||||
<h3 class="font-semibold text-gray-800">Accounts Receivable Aging</h3>
|
|
||||||
</div>
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="flex flex-wrap items-end gap-3 mb-3">
|
<div class="flex flex-wrap items-end gap-3 mb-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">As of</label>
|
<label class="block text-xs font-medium text-gray-700 mb-1">As of</label>
|
||||||
@@ -150,16 +155,9 @@ export function renderReportsView() {
|
|||||||
Anonymize customers
|
Anonymize customers
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="ar-result"></div>
|
<div id="ar-result"></div>`;
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Report 2: Invoice-Level Revenue -->
|
const revenueBody = `
|
||||||
<div class="bg-white rounded-lg shadow-sm border border-gray-200">
|
|
||||||
<div class="px-4 py-3 border-b bg-gray-50">
|
|
||||||
<h3 class="font-semibold text-gray-800">Invoice-Level Revenue</h3>
|
|
||||||
</div>
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="flex flex-wrap items-end gap-3 mb-3">
|
<div class="flex flex-wrap items-end gap-3 mb-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-700 mb-1">From</label>
|
<label class="block text-xs font-medium text-gray-700 mb-1">From</label>
|
||||||
@@ -186,10 +184,80 @@ export function renderReportsView() {
|
|||||||
Anonymize customers
|
Anonymize customers
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div id="rev-result"></div>
|
<div id="rev-result"></div>`;
|
||||||
|
|
||||||
|
// ── Ab hier 1:1 aus accounting-view.js injectReportsControls() ──
|
||||||
|
const profitLossBody = `
|
||||||
|
<div class="flex flex-wrap items-end gap-3 mb-3">
|
||||||
|
<div><label class="block text-xs font-medium text-gray-700 mb-1">Start</label>
|
||||||
|
<input type="date" id="pl-start" value="${plStartDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
|
||||||
|
<div><label class="block text-xs font-medium text-gray-700 mb-1">End</label>
|
||||||
|
<input type="date" id="pl-end" value="${plEndDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
|
||||||
|
<div><label class="block text-xs font-medium text-gray-700 mb-1">Method</label>
|
||||||
|
<select id="pl-method" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
|
||||||
|
<option value="Accrual" ${plAccountingMethod === 'Accrual' ? 'selected' : ''}>Accrual</option>
|
||||||
|
<option value="Cash" ${plAccountingMethod === 'Cash' ? 'selected' : ''}>Cash</option>
|
||||||
|
</select></div>
|
||||||
|
<button onclick="window.reportsView.loadProfitLoss()" class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
|
||||||
|
<button onclick="window.reportsView.exportProfitLossPdf()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">📄 PDF</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="pl-result"></div>`;
|
||||||
|
|
||||||
|
const balanceSheetBody = `
|
||||||
|
<div class="flex flex-wrap items-end gap-3 mb-3">
|
||||||
|
<div><label class="block text-xs font-medium text-gray-700 mb-1">As of</label>
|
||||||
|
<input type="date" id="bs-asof" value="${bsAsOfDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
|
||||||
|
<div><label class="block text-xs font-medium text-gray-700 mb-1">Method</label>
|
||||||
|
<select id="bs-method" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
|
||||||
|
<option value="Accrual" ${bsAccountingMethod === 'Accrual' ? 'selected' : ''}>Accrual</option>
|
||||||
|
<option value="Cash" ${bsAccountingMethod === 'Cash' ? 'selected' : ''}>Cash</option>
|
||||||
|
</select></div>
|
||||||
|
<button onclick="window.reportsView.loadBalanceSheet()" class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
|
||||||
|
<button onclick="window.reportsView.exportBalanceSheetPdf()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">📄 PDF</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="bs-result"></div>`;
|
||||||
|
|
||||||
|
const taxSummaryBody = `
|
||||||
|
<div class="flex flex-wrap items-end gap-3 mb-3">
|
||||||
|
<div><label class="block text-xs font-medium text-gray-700 mb-1">Month</label>
|
||||||
|
<input type="month" id="ts-month" value="${tsMonth}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
|
||||||
|
<div><label class="block text-xs font-medium text-gray-700 mb-1">Method</label>
|
||||||
|
<select id="ts-method" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm">
|
||||||
|
<option value="Accrual" ${tsAccountingMethod === 'Accrual' ? 'selected' : ''}>Accrual</option>
|
||||||
|
<option value="Cash" ${tsAccountingMethod === 'Cash' ? 'selected' : ''}>Cash</option>
|
||||||
|
</select></div>
|
||||||
|
<button onclick="window.reportsView.loadTaxSummary()" class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
|
||||||
|
<button onclick="window.reportsView.exportTaxSummaryPdf()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">📄 PDF</button>
|
||||||
|
</div>
|
||||||
|
<div id="ts-result"></div>`;
|
||||||
|
|
||||||
|
const customerRevenueBody = `
|
||||||
|
<div class="flex flex-wrap items-end gap-3 mb-3">
|
||||||
|
<div><label class="block text-xs font-medium text-gray-700 mb-1">Start</label>
|
||||||
|
<input type="date" id="cr-start" value="${crStartDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
|
||||||
|
<div><label class="block text-xs font-medium text-gray-700 mb-1">End</label>
|
||||||
|
<input type="date" id="cr-end" value="${crEndDate}" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm"></div>
|
||||||
|
<button onclick="window.reportsView.loadCustomerRevenue()" class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">Run</button>
|
||||||
|
<button onclick="window.reportsView.exportCustomerRevenuePdf()" class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">📄 Export PDF</button>
|
||||||
|
<label class="flex items-center gap-1 pt-5 text-xs text-gray-600 cursor-pointer">
|
||||||
|
<input type="checkbox" id="cr-anonymize" class="h-4 w-4 text-blue-600 border-gray-300 rounded">
|
||||||
|
Anonymize
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="cr-result"></div>`;
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="space-y-4">
|
||||||
|
${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)}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
// Alpine scannt das DOM nur beim Start — nachgereichtes Markup selbst anmelden.
|
||||||
|
initAlpineTree(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Report 1: AR Aging ──────────────────────────────────────────────
|
// ── Report 1: AR Aging ──────────────────────────────────────────────
|
||||||
@@ -200,11 +268,11 @@ export async function loadArAging() {
|
|||||||
showLoading('ar-result', 'Loading receivables…');
|
showLoading('ar-result', 'Loading receivables…');
|
||||||
try {
|
try {
|
||||||
const data = await window.API.reports.getArAging(arAsOf, arAnonymize);
|
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;
|
arData = data;
|
||||||
renderArAging(data);
|
renderArAging(data);
|
||||||
} catch (err) {
|
} 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() {
|
export function exportArAgingCsv() {
|
||||||
if (!arData || !arData.rows.length) {
|
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 = [];
|
const lines = [];
|
||||||
lines.push(csvRow([`Accounts Receivable Aging as of ${arData.asOf}`]));
|
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;
|
revAnonymize = document.getElementById('rev-anonymize')?.checked || false;
|
||||||
revGroupByMonth = document.getElementById('rev-group-month')?.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 reportError('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', 'The from date must not be after the to date.');
|
||||||
|
|
||||||
showLoading('rev-result', 'Loading revenue…');
|
showLoading('rev-result', 'Loading revenue…');
|
||||||
try {
|
try {
|
||||||
const data = await window.API.reports.getRevenue(revFrom, revTo, revAnonymize, revGroupByMonth);
|
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;
|
revData = data;
|
||||||
renderRevenue(data);
|
renderRevenue(data);
|
||||||
} catch (err) {
|
} 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() {
|
export function exportRevenueCsv() {
|
||||||
if (!revData || !revData.rows.length) {
|
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 = [];
|
const lines = [];
|
||||||
lines.push(csvRow([`Invoice-Level Revenue ${revData.from} to ${revData.to}`]));
|
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);
|
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 = '<p class="text-sm text-gray-500">No invoices found in this period.</p>';
|
||||||
|
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 += `<tr class="border-t hover:bg-gray-50">
|
||||||
|
<td class="px-3 py-2 text-sm font-medium">${rank}. ${escapeHtml(maskName(r.customer_name))}</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-center">${r.invoice_count}</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-right">${fmtMoney(rev)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-right text-gray-500">${pct}%</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
rowsHtml += `<tr class="border-t bg-gray-50 font-semibold">
|
||||||
|
<td class="px-3 py-2 text-sm">TOTAL (${data.length} customers)</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-center">${totalInvoices}</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-right">${fmtMoney(grandTotal)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-right">100.0%</td>
|
||||||
|
</tr>`;
|
||||||
|
|
||||||
|
document.getElementById('cr-result').innerHTML = `
|
||||||
|
<div class="overflow-x-auto border border-gray-200 rounded">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left font-medium text-gray-700">Customer</th>
|
||||||
|
<th class="px-3 py-2 text-center font-medium text-gray-700">Invoices</th>
|
||||||
|
<th class="px-3 py-2 text-right font-medium text-gray-700">Revenue (net)</th>
|
||||||
|
<th class="px-3 py-2 text-right font-medium text-gray-700">% of Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rowsHtml}</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
} 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 ─────────────────────────────────────
|
// ── Expose for onclick handlers ─────────────────────────────────────
|
||||||
|
|
||||||
window.reportsView = {
|
window.reportsView = {
|
||||||
|
renderReportsView,
|
||||||
loadArAging,
|
loadArAging,
|
||||||
exportArAgingCsv,
|
exportArAgingCsv,
|
||||||
loadRevenue,
|
loadRevenue,
|
||||||
exportRevenueCsv
|
exportRevenueCsv,
|
||||||
|
loadProfitLoss,
|
||||||
|
loadBalanceSheet,
|
||||||
|
loadTaxSummary,
|
||||||
|
loadCustomerRevenue,
|
||||||
|
exportProfitLossPdf,
|
||||||
|
exportBalanceSheetPdf,
|
||||||
|
exportTaxSummaryPdf,
|
||||||
|
exportCustomerRevenuePdf
|
||||||
};
|
};
|
||||||
|
|||||||
422
public/js/views/salestax-view.js
Normal file
422
public/js/views/salestax-view.js
Normal file
@@ -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 = `
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b bg-gray-50 flex flex-wrap items-center gap-3">
|
||||||
|
<h3 class="font-semibold text-gray-800 text-sm">Period Overview</h3>
|
||||||
|
<div class="ml-auto flex gap-2">
|
||||||
|
<button onclick="window.salesTaxView.openNewTaxPeriod()"
|
||||||
|
class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700">
|
||||||
|
+ New Period
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="sales-tax-periods-table"></div>
|
||||||
|
</div>
|
||||||
|
<div id="sales-tax-detail" class="mt-4 hidden"></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `<div class="p-4 text-gray-500 text-sm">No sales tax periods recorded yet. Click "+ New Period" to get started.</div>`;
|
||||||
|
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 `
|
||||||
|
<tr class="border-t hover:bg-gray-50">
|
||||||
|
<td class="px-3 py-2 text-sm font-medium text-gray-800">${monthLabel}</td>
|
||||||
|
<td class="px-3 py-2 text-sm">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${statusColor}">${statusHtml}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-right">${fmtMoney(parseFloat(p.tax_collected) || 0)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-right">${adjStr}</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-right font-semibold">${fmtMoney(netPaid)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-gray-500">${paidOn}</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-center">
|
||||||
|
<button onclick="window.salesTaxView.openTaxPeriod(${p.id})"
|
||||||
|
class="text-blue-600 hover:text-blue-800 text-xs font-medium">View summary</button>
|
||||||
|
</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full text-sm">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-left font-medium text-gray-700">Period</th>
|
||||||
|
<th class="px-3 py-2 text-left font-medium text-gray-700">Status</th>
|
||||||
|
<th class="px-3 py-2 text-right font-medium text-gray-700">Tax Amount</th>
|
||||||
|
<th class="px-3 py-2 text-right font-medium text-gray-700">Adjustment</th>
|
||||||
|
<th class="px-3 py-2 text-right font-medium text-gray-700">Net Due</th>
|
||||||
|
<th class="px-3 py-2 text-left font-medium text-gray-700">Paid On</th>
|
||||||
|
<th class="px-3 py-2 text-center font-medium text-gray-700">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="px-3 py-2 text-xs text-gray-500 border-t bg-gray-50">${stPeriods.length} period${stPeriods.length === 1 ? '' : 's'}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="font-semibold text-gray-800">${mLabel} — Sales Tax Detail (Paid)</h3>
|
||||||
|
<button onclick="window.salesTaxView.closeTaxPeriodDetail()" class="px-2 py-1 text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-green-700 font-semibold mb-4">✅ Paid on ${paidOn}</p>
|
||||||
|
<table class="min-w-full text-sm border border-gray-200 rounded">
|
||||||
|
<tbody>
|
||||||
|
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Total Sales</td><td class="px-3 py-2 text-right font-medium">${fmtMoney(parseFloat(existingPeriod.total_sales) || 0)}</td></tr>
|
||||||
|
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Nontaxable Sales</td><td class="px-3 py-2 text-right font-medium">${fmtMoney(parseFloat(existingPeriod.nontaxable_sales) || 0)}</td></tr>
|
||||||
|
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Taxable Sales</td><td class="px-3 py-2 text-right font-medium">${fmtMoney(parseFloat(existingPeriod.taxable_sales) || 0)}</td></tr>
|
||||||
|
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Tax Collected</td><td class="px-3 py-2 text-right font-medium">${fmtMoney(parseFloat(existingPeriod.tax_collected) || 0)}</td></tr>
|
||||||
|
<tr class="border-t"><td class="px-3 py-2 text-gray-600">Adjustment</td><td class="px-3 py-2 text-right font-medium ${adj !== 0 ? 'text-red-600' : ''}">${adjStr}</td></tr>
|
||||||
|
${existingPeriod.adjustment_reason ? `<tr class="border-t"><td class="px-3 py-2 text-gray-500 text-xs pl-8" colspan="2">Reason: ${escapeHtml(existingPeriod.adjustment_reason)}</td></tr>` : ''}
|
||||||
|
<tr class="border-t bg-gray-50 font-semibold"><td class="px-3 py-2">Net Paid</td><td class="px-3 py-2 text-right">${fmtMoney(parseFloat(existingPeriod.net_paid) || 0)}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>`;
|
||||||
|
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) : `<p class="text-sm text-gray-500">No tax data for this period.</p>`;
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div class="bg-white rounded-lg shadow-sm border border-gray-200 p-4">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h3 class="font-semibold text-gray-800">${monthLabel} — Sales Tax Detail</h3>
|
||||||
|
<button onclick="window.salesTaxView.closeTaxPeriodDetail()"
|
||||||
|
class="px-2 py-1 text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
${reportHtml}
|
||||||
|
|
||||||
|
<div class="mt-4 border-t pt-4">
|
||||||
|
<div class="flex items-center gap-4 text-sm mb-3" id="st-summary-bar">
|
||||||
|
<span><strong>Tax Collected:</strong> ${fmtMoney(taxCollected)}</span>
|
||||||
|
<span id="st-adj-display" class="${adjustments !== 0 ? 'text-red-600' : ''}"><strong>Adjustment:</strong> ${adjustments > 0 ? `−${fmtMoney(adjustments)}` : adjustments < 0 ? `+$${Math.abs(adjustments).toFixed(2)}` : '—'}</span>
|
||||||
|
<span id="st-net-due" class="text-lg font-bold text-gray-900"><strong>Net Due:</strong> ${fmtMoney(netPaid)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${isEditable ? `
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 mb-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-700 mb-1">Adjustment Amount</label>
|
||||||
|
<input type="number" step="0.01" id="st-adjustment" value="${adjustments !== 0 ? adjustments : ''}" placeholder="e.g. 6.15 (discount)"
|
||||||
|
oninput="window.salesTaxView.updateTaxPreview()"
|
||||||
|
class="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm">
|
||||||
|
<p class="text-xs text-gray-400 mt-0.5">Positive = discount (reduces net due)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-700 mb-1">Reason</label>
|
||||||
|
<input type="text" id="st-adjustment-reason" value="${escapeHtml(adjReason)}" placeholder="e.g. Timely filing discount"
|
||||||
|
class="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-700 mb-1">Paid Date (QBO)</label>
|
||||||
|
<input type="date" id="st-paid-date" value="${today}"
|
||||||
|
class="w-full px-3 py-1.5 border border-gray-300 rounded-md text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between border-t pt-3 mt-1">
|
||||||
|
<div id="st-je-lines">
|
||||||
|
<p class="text-sm"><strong>Summary:</strong></p>
|
||||||
|
<p class="text-xs text-gray-500">Tax Collected: ${fmtMoney(taxCollected)}</p>
|
||||||
|
${adjustments > 0 ? `<p class="text-xs text-gray-500">Discount: −${fmtMoney(adjustments)}</p>` : ''}
|
||||||
|
${adjustments < 0 ? `<p class="text-xs text-gray-500">Penalty: +$${Math.abs(adjustments).toFixed(2)}</p>` : ''}
|
||||||
|
<p class="text-xs text-gray-500 font-semibold">Net Due: ${fmtMoney(netPaid)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick="window.salesTaxView.saveTaxPeriodDraft()"
|
||||||
|
class="px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-md text-sm font-medium border border-gray-300">
|
||||||
|
💾 Save Draft
|
||||||
|
</button>
|
||||||
|
<button onclick="window.salesTaxView.markPeriodPaid()"
|
||||||
|
class="px-4 py-2 bg-green-600 text-white rounded-md text-sm font-semibold hover:bg-green-700">
|
||||||
|
✅ Mark as Paid
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : periodStatus === 'booked' ? `
|
||||||
|
<div class="border-t pt-3 mt-1">
|
||||||
|
<p class="text-sm text-green-700 font-semibold">✅ Booked — Journal Entry #${escapeHtml(existingPeriod.qbo_journal_entry_id)} on ${formatDate(existingPeriod.booked_at)}</p>
|
||||||
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="border-t pt-3 mt-1">
|
||||||
|
<p class="text-sm text-green-700 font-semibold">✅ Paid on ${formatDate(existingPeriod.booked_at)}</p>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
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
|
||||||
|
? `<strong>Adjustment:</strong> −${fmtMoney(adj)}`
|
||||||
|
: adj < 0
|
||||||
|
? `<strong>Adjustment:</strong> +$${Math.abs(adj).toFixed(2)}`
|
||||||
|
: `<strong>Adjustment:</strong> —`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const netDue = document.getElementById('st-net-due');
|
||||||
|
if (netDue) {
|
||||||
|
netDue.innerHTML = `<strong>Net Due:</strong> ${fmtMoney(netPaid)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jeLines = document.getElementById('st-je-lines');
|
||||||
|
if (jeLines) {
|
||||||
|
jeLines.innerHTML = `
|
||||||
|
<p class="text-sm"><strong>Summary:</strong></p>
|
||||||
|
<p class="text-xs text-gray-500">Tax Collected: ${fmtMoney(taxCollected)}</p>
|
||||||
|
${adj > 0 ? `<p class="text-xs text-gray-500">Discount: −${fmtMoney(adj)}</p>` : ''}
|
||||||
|
${adj < 0 ? `<p class="text-xs text-gray-500">Penalty: +$${Math.abs(adj).toFixed(2)}</p>` : ''}
|
||||||
|
<p class="text-xs text-gray-500 font-semibold">Net Due: ${fmtMoney(netPaid)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user