Initial dashboard commit
This commit is contained in:
parent
45d0d5b5f2
commit
01c8715005
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"name": "newt-customer-dashboard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.19.2",
|
||||
"cookie-parser": "^1.4.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,430 @@
|
|||
const MONTH_NAMES = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
function fmtMoney(v) {
|
||||
const n = Number(v || 0);
|
||||
return n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 });
|
||||
}
|
||||
function fmtMoney2(v) {
|
||||
const n = Number(v || 0);
|
||||
return n.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 2 });
|
||||
}
|
||||
function fmtNum(v) {
|
||||
return Number(v || 0).toLocaleString('en-US');
|
||||
}
|
||||
function fmtMonth(year, month) {
|
||||
if (!year || !month) return '—';
|
||||
return `${MONTH_NAMES[month]} ${year}`;
|
||||
}
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '—';
|
||||
return iso;
|
||||
}
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(path, {
|
||||
...opts,
|
||||
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
||||
});
|
||||
if (res.status === 401) {
|
||||
showLogin();
|
||||
throw new Error('unauthorized');
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || data.message || 'Request failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
// ---------- Auth ----------
|
||||
function showLogin() {
|
||||
document.getElementById('login-screen').classList.remove('hidden');
|
||||
document.getElementById('app-shell').classList.add('hidden');
|
||||
}
|
||||
function showApp() {
|
||||
document.getElementById('login-screen').classList.add('hidden');
|
||||
document.getElementById('app-shell').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const data = await api('/api/me');
|
||||
if (data.authenticated) {
|
||||
showApp();
|
||||
initApp();
|
||||
} else {
|
||||
showLogin();
|
||||
}
|
||||
} catch {
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('login-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const password = document.getElementById('login-password').value;
|
||||
const errEl = document.getElementById('login-error');
|
||||
errEl.textContent = '';
|
||||
try {
|
||||
const res = await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showApp();
|
||||
initApp();
|
||||
} else {
|
||||
errEl.textContent = data.message || 'Invalid password.';
|
||||
}
|
||||
} catch {
|
||||
errEl.textContent = 'Something went wrong. Try again.';
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('logout-btn').addEventListener('click', async () => {
|
||||
await fetch('/api/logout', { method: 'POST' });
|
||||
showLogin();
|
||||
});
|
||||
|
||||
// ---------- Tabs ----------
|
||||
let appInitialized = false;
|
||||
let filterOptions = null;
|
||||
|
||||
function switchTab(tabName) {
|
||||
document.querySelectorAll('.nav-item[data-tab]').forEach((btn) => {
|
||||
btn.classList.toggle('active', btn.dataset.tab === tabName);
|
||||
});
|
||||
document.querySelectorAll('.tab').forEach((sec) => {
|
||||
sec.classList.toggle('hidden', sec.id !== `tab-${tabName}`);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.nav-item[data-tab]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => switchTab(btn.dataset.tab));
|
||||
});
|
||||
|
||||
async function initApp() {
|
||||
if (appInitialized) return;
|
||||
appInitialized = true;
|
||||
|
||||
filterOptions = await api('/api/filters/options');
|
||||
populateFilterDropdowns();
|
||||
|
||||
await loadOverview();
|
||||
await loadProducts();
|
||||
await loadNewPackages(0);
|
||||
await loadSearch(0);
|
||||
}
|
||||
|
||||
function populateFilterDropdowns() {
|
||||
const fillSelect = (el, values, labelFn) => {
|
||||
values.forEach((v) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = v;
|
||||
opt.textContent = labelFn ? labelFn(v) : v;
|
||||
el.appendChild(opt);
|
||||
});
|
||||
};
|
||||
|
||||
fillSelect(document.getElementById('np-package'), filterOptions.packages);
|
||||
fillSelect(document.getElementById('s-product'), filterOptions.products);
|
||||
fillSelect(document.getElementById('s-package'), filterOptions.packages);
|
||||
fillSelect(document.getElementById('s-customertype'), filterOptions.customer_types);
|
||||
fillSelect(document.getElementById('s-brand'), filterOptions.brands);
|
||||
fillSelect(document.getElementById('s-year'), filterOptions.years);
|
||||
fillSelect(document.getElementById('s-month'), [1,2,3,4,5,6,7,8,9,10,11,12], (m) => MONTH_NAMES[m]);
|
||||
}
|
||||
|
||||
// ---------- Overview ----------
|
||||
let overviewChart = null;
|
||||
|
||||
async function loadOverview() {
|
||||
const [overview, products] = await Promise.all([
|
||||
api('/api/overview'),
|
||||
api('/api/products/summary'),
|
||||
]);
|
||||
|
||||
const statsEl = document.getElementById('overview-stats');
|
||||
statsEl.innerHTML = '';
|
||||
const cards = [
|
||||
{ label: 'Total Revenue', value: fmtMoney(overview.total_amount) },
|
||||
{ label: 'Line Items', value: fmtNum(overview.row_count) },
|
||||
{ label: 'Unique Customers', value: fmtNum(overview.customer_count) },
|
||||
{ label: 'Date Range', value: `${fmtDate(overview.min_period)} → ${fmtDate(overview.max_period)}` },
|
||||
];
|
||||
cards.forEach((c) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'stat-card';
|
||||
div.innerHTML = `<div class="label">${c.label}</div><div class="value">${c.value}</div>`;
|
||||
statsEl.appendChild(div);
|
||||
});
|
||||
|
||||
const top = products.slice(0, 12);
|
||||
const ctx = document.getElementById('overview-chart');
|
||||
if (overviewChart) overviewChart.destroy();
|
||||
overviewChart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: top.map((p) => p.product),
|
||||
datasets: [{ label: 'Total $', data: top.map((p) => p.total_amount), backgroundColor: '#2563eb' }],
|
||||
},
|
||||
options: {
|
||||
plugins: { legend: { display: false } },
|
||||
scales: { y: { ticks: { callback: (v) => fmtMoney(v) } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Products ----------
|
||||
let productsData = [];
|
||||
let productsSort = { col: 'total_amount', dir: 'desc' };
|
||||
let trendChart = null;
|
||||
|
||||
async function loadProducts() {
|
||||
const [summary, trend] = await Promise.all([
|
||||
api('/api/products/summary'),
|
||||
api('/api/products/trend'),
|
||||
]);
|
||||
productsData = summary;
|
||||
renderProductsTable();
|
||||
renderTrendChart(summary, trend);
|
||||
}
|
||||
|
||||
function renderProductsTable() {
|
||||
const { col, dir } = productsSort;
|
||||
const sorted = [...productsData].sort((a, b) => {
|
||||
const v = typeof a[col] === 'string' ? a[col].localeCompare(b[col]) : a[col] - b[col];
|
||||
return dir === 'asc' ? v : -v;
|
||||
});
|
||||
const tbody = document.querySelector('#products-table tbody');
|
||||
tbody.innerHTML = '';
|
||||
sorted.forEach((p) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${p.product}</td>
|
||||
<td class="num">${fmtMoney(p.total_amount)}</td>
|
||||
<td class="num">${fmtNum(p.txn_count)}</td>
|
||||
<td class="num">${fmtNum(p.customer_count)}</td>
|
||||
<td class="explore-link">Explore →</td>
|
||||
`;
|
||||
tr.addEventListener('click', () => openDrilldown({ product: p.product }, 'customer'));
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('#products-table th[data-sort]').forEach((th) => {
|
||||
th.addEventListener('click', () => {
|
||||
const col = th.dataset.sort;
|
||||
if (productsSort.col === col) {
|
||||
productsSort.dir = productsSort.dir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
productsSort = { col, dir: 'desc' };
|
||||
}
|
||||
renderProductsTable();
|
||||
});
|
||||
});
|
||||
|
||||
function renderTrendChart(summary, trend) {
|
||||
const topProducts = [...summary].sort((a, b) => b.total_amount - a.total_amount).slice(0, 8).map((p) => p.product);
|
||||
const periods = [...new Set(trend.map((r) => `${r.year}-${String(r.month).padStart(2, '0')}`))].sort();
|
||||
|
||||
const datasets = topProducts.map((prod, i) => {
|
||||
const byPeriod = {};
|
||||
trend.filter((r) => r.product === prod).forEach((r) => {
|
||||
byPeriod[`${r.year}-${String(r.month).padStart(2, '0')}`] = Number(r.amount);
|
||||
});
|
||||
const palette = ['#2563eb', '#dc2626', '#16a34a', '#d97706', '#7c3aed', '#0891b2', '#db2777', '#65a30d'];
|
||||
return {
|
||||
label: prod,
|
||||
data: periods.map((p) => byPeriod[p] || 0),
|
||||
borderColor: palette[i % palette.length],
|
||||
backgroundColor: palette[i % palette.length],
|
||||
fill: false,
|
||||
tension: 0.2,
|
||||
};
|
||||
});
|
||||
|
||||
const ctx = document.getElementById('trend-chart');
|
||||
if (trendChart) trendChart.destroy();
|
||||
trendChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { labels: periods, datasets },
|
||||
options: {
|
||||
plugins: { legend: { position: 'bottom' } },
|
||||
scales: { y: { ticks: { callback: (v) => fmtMoney(v) } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- New Package Adoption ----------
|
||||
let npState = { offset: 0, limit: 50, total: 0 };
|
||||
|
||||
async function loadNewPackages(offset) {
|
||||
npState.offset = offset;
|
||||
const params = new URLSearchParams({
|
||||
mode: document.getElementById('np-mode').value,
|
||||
package: document.getElementById('np-package').value,
|
||||
search: document.getElementById('np-search').value,
|
||||
sort: document.getElementById('np-sort').value,
|
||||
limit: npState.limit,
|
||||
offset,
|
||||
});
|
||||
const data = await api(`/api/new-packages?${params}`);
|
||||
npState.total = data.total;
|
||||
renderNewPackagesTable(data.rows);
|
||||
renderPagination('newpkg-pagination', npState, loadNewPackages);
|
||||
}
|
||||
|
||||
function renderNewPackagesTable(rows) {
|
||||
const tbody = document.querySelector('#newpkg-table tbody');
|
||||
tbody.innerHTML = '';
|
||||
rows.forEach((r) => {
|
||||
const tr = document.createElement('tr');
|
||||
const badge = r.is_new_customer
|
||||
? '<span class="badge new">New customer</span>'
|
||||
: '<span class="badge upsell">Upsell</span>';
|
||||
tr.innerHTML = `
|
||||
<td>${r.customer_name || r.customer_number}</td>
|
||||
<td>${r.package}</td>
|
||||
<td>${fmtMonth(r.first_year, r.first_month)}</td>
|
||||
<td>${badge}</td>
|
||||
<td class="num">${fmtMoney2(r.first_month_amount)}</td>
|
||||
<td class="explore-link">Explore →</td>
|
||||
`;
|
||||
tr.addEventListener('click', () => openDrilldown({
|
||||
customer: r.customer_number,
|
||||
package: r.package,
|
||||
}, 'detail'));
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('np-search-btn').addEventListener('click', () => loadNewPackages(0));
|
||||
document.getElementById('np-search').addEventListener('keydown', (e) => { if (e.key === 'Enter') loadNewPackages(0); });
|
||||
|
||||
// ---------- Search / Drill-down ----------
|
||||
let searchState = { offset: 0, limit: 50, total: 0 };
|
||||
|
||||
function currentSearchFilters() {
|
||||
return {
|
||||
customer: document.getElementById('s-customer').value,
|
||||
product: document.getElementById('s-product').value,
|
||||
package: document.getElementById('s-package').value,
|
||||
customerType: document.getElementById('s-customertype').value,
|
||||
brand: document.getElementById('s-brand').value,
|
||||
text: document.getElementById('s-text').value,
|
||||
year: document.getElementById('s-year').value,
|
||||
month: document.getElementById('s-month').value,
|
||||
level: document.getElementById('s-level').value,
|
||||
includeVoided: document.getElementById('s-voided').checked,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSearch(offset) {
|
||||
searchState.offset = offset;
|
||||
const f = currentSearchFilters();
|
||||
const params = new URLSearchParams({
|
||||
...Object.fromEntries(Object.entries(f).filter(([, v]) => v !== '' && v !== false)),
|
||||
limit: searchState.limit,
|
||||
offset,
|
||||
});
|
||||
const data = await api(`/api/transactions?${params}`);
|
||||
searchState.total = data.count;
|
||||
renderSearchSummary(data);
|
||||
renderSearchTable(f.level, data.rows);
|
||||
renderPagination('search-pagination', searchState, loadSearch);
|
||||
}
|
||||
|
||||
function renderSearchSummary(data) {
|
||||
document.getElementById('s-summary').innerHTML =
|
||||
`<b>${fmtNum(data.count)}</b> matching transactions, totaling <b>${fmtMoney2(data.total_amount)}</b>`;
|
||||
}
|
||||
|
||||
function renderSearchTable(level, rows) {
|
||||
const thead = document.querySelector('#search-table thead');
|
||||
const tbody = document.querySelector('#search-table tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (level === 'customer') {
|
||||
thead.innerHTML = `<tr><th>Customer</th><th>Customer #</th><th class="num"># Transactions</th><th class="num">Total $</th><th></th></tr>`;
|
||||
rows.forEach((r) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${r.customer_name || ''}</td>
|
||||
<td>${r.customer_number}</td>
|
||||
<td class="num">${fmtNum(r.txn_count)}</td>
|
||||
<td class="num">${fmtMoney2(r.total_amount)}</td>
|
||||
<td class="explore-link">View lines →</td>
|
||||
`;
|
||||
tr.addEventListener('click', () => {
|
||||
document.getElementById('s-customer').value = r.customer_number;
|
||||
document.getElementById('s-level').value = 'detail';
|
||||
loadSearch(0);
|
||||
});
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
} else {
|
||||
thead.innerHTML = `<tr>
|
||||
<th>Date</th><th>Customer</th><th>Product</th><th>Package</th>
|
||||
<th>Item Description</th><th>SOP #</th><th>Status</th><th class="num">Amount</th>
|
||||
</tr>`;
|
||||
rows.forEach((r) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${fmtDate(r.gl_date)}</td>
|
||||
<td>${r.customer_name || ''}</td>
|
||||
<td>${r.product || ''}</td>
|
||||
<td>${r.package || ''}</td>
|
||||
<td>${r.item_description || ''}</td>
|
||||
<td>${r.sop_number || ''}</td>
|
||||
<td>${r.void_status || ''}</td>
|
||||
<td class="num">${fmtMoney2(r.amount)}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderPagination(elId, state, loadFn) {
|
||||
const el = document.getElementById(elId);
|
||||
const page = Math.floor(state.offset / state.limit) + 1;
|
||||
const totalPages = Math.max(1, Math.ceil(state.total / state.limit));
|
||||
el.innerHTML = '';
|
||||
const prevBtn = document.createElement('button');
|
||||
prevBtn.textContent = '← Prev';
|
||||
prevBtn.disabled = state.offset <= 0;
|
||||
prevBtn.addEventListener('click', () => loadFn(Math.max(0, state.offset - state.limit)));
|
||||
const nextBtn = document.createElement('button');
|
||||
nextBtn.textContent = 'Next →';
|
||||
nextBtn.disabled = state.offset + state.limit >= state.total;
|
||||
nextBtn.addEventListener('click', () => loadFn(state.offset + state.limit));
|
||||
const label = document.createElement('span');
|
||||
label.textContent = `Page ${page} of ${totalPages} (${fmtNum(state.total)} results)`;
|
||||
el.appendChild(prevBtn);
|
||||
el.appendChild(label);
|
||||
el.appendChild(nextBtn);
|
||||
}
|
||||
|
||||
document.getElementById('s-search-btn').addEventListener('click', () => loadSearch(0));
|
||||
document.getElementById('s-clear-btn').addEventListener('click', () => {
|
||||
['s-customer', 's-text'].forEach((id) => (document.getElementById(id).value = ''));
|
||||
['s-product', 's-package', 's-customertype', 's-brand', 's-year', 's-month'].forEach((id) => (document.getElementById(id).value = ''));
|
||||
document.getElementById('s-level').value = 'customer';
|
||||
document.getElementById('s-voided').checked = false;
|
||||
loadSearch(0);
|
||||
});
|
||||
['s-customer', 's-text'].forEach((id) => {
|
||||
document.getElementById(id).addEventListener('keydown', (e) => { if (e.key === 'Enter') loadSearch(0); });
|
||||
});
|
||||
|
||||
function openDrilldown(filters, level) {
|
||||
document.getElementById('s-customer').value = filters.customer || '';
|
||||
document.getElementById('s-product').value = filters.product || '';
|
||||
document.getElementById('s-package').value = filters.package || '';
|
||||
document.getElementById('s-level').value = level || 'customer';
|
||||
switchTab('search');
|
||||
loadSearch(0);
|
||||
}
|
||||
|
||||
// ---------- boot ----------
|
||||
checkAuth();
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NEWT Customer Dashboard</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ---------- LOGIN SCREEN ---------- -->
|
||||
<div id="login-screen" class="login-screen">
|
||||
<form id="login-form" class="login-card">
|
||||
<h1>NEWT Customer Dashboard</h1>
|
||||
<p class="subtitle">Enter the password to continue</p>
|
||||
<input type="password" id="login-password" placeholder="Password" autocomplete="current-password" required />
|
||||
<button type="submit">Sign in</button>
|
||||
<p id="login-error" class="error-text"></p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- ---------- APP SHELL ---------- -->
|
||||
<div id="app-shell" class="app-shell hidden">
|
||||
<nav class="sidebar">
|
||||
<div class="brand">NEWT<span>Dashboard</span></div>
|
||||
<button class="nav-item active" data-tab="overview">Overview</button>
|
||||
<button class="nav-item" data-tab="products">Product Summary</button>
|
||||
<button class="nav-item" data-tab="newpkg">New Package Adoption</button>
|
||||
<button class="nav-item" data-tab="search">Search & Drill-down</button>
|
||||
<button id="logout-btn" class="nav-item logout">Log out</button>
|
||||
</nav>
|
||||
|
||||
<main class="content">
|
||||
|
||||
<!-- OVERVIEW TAB -->
|
||||
<section id="tab-overview" class="tab">
|
||||
<h2>Overview</h2>
|
||||
<div class="stat-grid" id="overview-stats"></div>
|
||||
<div class="panel">
|
||||
<h3>Top Products by Revenue</h3>
|
||||
<canvas id="overview-chart" height="90"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PRODUCT SUMMARY TAB -->
|
||||
<section id="tab-products" class="tab hidden">
|
||||
<h2>Product Summary</h2>
|
||||
<div class="panel">
|
||||
<h3>Monthly Trend — Top Products</h3>
|
||||
<canvas id="trend-chart" height="90"></canvas>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>Sum of $ Amount by Product</h3>
|
||||
<span class="hint">Click a row to drill into that product's transactions</span>
|
||||
</div>
|
||||
<table class="data-table" id="products-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="product">Product</th>
|
||||
<th data-sort="total_amount" class="num">Total $</th>
|
||||
<th data-sort="txn_count" class="num"># Transactions</th>
|
||||
<th data-sort="customer_count" class="num"># Customers</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- NEW PACKAGE ADOPTION TAB -->
|
||||
<section id="tab-newpkg" class="tab hidden">
|
||||
<h2>New Package Adoption</h2>
|
||||
<p class="tab-desc">For every customer + package combination, the first month it appeared in billing. "New customer" means it was their very first billed month; "Upsell" means the customer already existed and added this package later.</p>
|
||||
<div class="panel">
|
||||
<div class="filter-row">
|
||||
<select id="np-mode">
|
||||
<option value="all">All</option>
|
||||
<option value="new">New customers only</option>
|
||||
<option value="upsell">Upsells only (existing customer, new package)</option>
|
||||
</select>
|
||||
<select id="np-package"><option value="">All packages</option></select>
|
||||
<input type="text" id="np-search" placeholder="Search customer name/number..." />
|
||||
<select id="np-sort">
|
||||
<option value="first_seen_desc">Most recent first</option>
|
||||
<option value="first_seen_asc">Oldest first</option>
|
||||
<option value="amount_desc">Largest first-month $</option>
|
||||
<option value="customer_asc">Customer name A-Z</option>
|
||||
</select>
|
||||
<button id="np-search-btn">Apply</button>
|
||||
</div>
|
||||
<table class="data-table" id="newpkg-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer</th>
|
||||
<th>Package</th>
|
||||
<th>First Month</th>
|
||||
<th>Type</th>
|
||||
<th class="num">First-Month $</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div class="pagination" id="newpkg-pagination"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SEARCH / DRILLDOWN TAB -->
|
||||
<section id="tab-search" class="tab hidden">
|
||||
<h2>Search & Drill-down</h2>
|
||||
<div class="panel">
|
||||
<div class="filter-grid">
|
||||
<input type="text" id="s-customer" placeholder="Customer name or number" />
|
||||
<select id="s-product"><option value="">All products</option></select>
|
||||
<select id="s-package"><option value="">All packages</option></select>
|
||||
<select id="s-customertype"><option value="">All customer types</option></select>
|
||||
<select id="s-brand"><option value="">All brands</option></select>
|
||||
<input type="text" id="s-text" placeholder="Search item description / SOP / item #" />
|
||||
<select id="s-year"><option value="">Any year</option></select>
|
||||
<select id="s-month"><option value="">Any month</option></select>
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<label class="level-toggle">
|
||||
<span>Results level:</span>
|
||||
<select id="s-level">
|
||||
<option value="customer">Customer subtotals</option>
|
||||
<option value="detail">Individual invoice lines</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="checkbox-inline"><input type="checkbox" id="s-voided" /> Include voided transactions</label>
|
||||
<button id="s-search-btn">Search</button>
|
||||
<button id="s-clear-btn" class="secondary">Clear filters</button>
|
||||
</div>
|
||||
<div id="s-summary" class="search-summary"></div>
|
||||
<div class="table-scroll">
|
||||
<table class="data-table" id="search-table">
|
||||
<thead></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="search-pagination"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
:root {
|
||||
--bg: #f4f6f9;
|
||||
--panel: #ffffff;
|
||||
--border: #e2e6ec;
|
||||
--text: #1a2233;
|
||||
--muted: #6b7789;
|
||||
--accent: #2563eb;
|
||||
--accent-dark: #1d4ed8;
|
||||
--sidebar-bg: #111827;
|
||||
--sidebar-text: #cbd5e1;
|
||||
--sidebar-active: #1f2937;
|
||||
--danger: #dc2626;
|
||||
--success: #16a34a;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text); }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* ---------- Login ---------- */
|
||||
.login-screen {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, #111827, #1e3a8a);
|
||||
}
|
||||
.login-card {
|
||||
background: var(--panel);
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
width: 340px;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.3);
|
||||
text-align: center;
|
||||
}
|
||||
.login-card h1 { font-size: 1.3rem; margin: 0 0 4px; }
|
||||
.login-card .subtitle { color: var(--muted); margin: 0 0 20px; font-size: 0.9rem; }
|
||||
.login-card input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.login-card button {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.login-card button:hover { background: var(--accent-dark); }
|
||||
.error-text { color: var(--danger); font-size: 0.85rem; min-height: 1.2em; margin: 10px 0 0; }
|
||||
|
||||
/* ---------- App shell ---------- */
|
||||
.app-shell { display: flex; min-height: 100vh; }
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: var(--sidebar-bg);
|
||||
color: var(--sidebar-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.brand { color: white; font-weight: 700; font-size: 1.1rem; padding: 8px 12px 20px; }
|
||||
.brand span { display: block; font-weight: 400; font-size: 0.75rem; color: var(--sidebar-text); }
|
||||
.nav-item {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--sidebar-text);
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.92rem;
|
||||
cursor: pointer;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.nav-item:hover { background: var(--sidebar-active); color: white; }
|
||||
.nav-item.active { background: var(--accent); color: white; }
|
||||
.nav-item.logout { margin-top: auto; color: #f87171; }
|
||||
|
||||
.content { flex: 1; padding: 28px 36px; overflow-x: hidden; max-width: 100%; }
|
||||
.content h2 { margin: 0 0 6px; }
|
||||
.tab-desc { color: var(--muted); margin-top: 0; max-width: 720px; }
|
||||
|
||||
/* ---------- Stat cards ---------- */
|
||||
.stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; margin: 20px 0; }
|
||||
.stat-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.stat-card .label { color: var(--muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.stat-card .value { font-size: 1.6rem; font-weight: 700; margin-top: 4px; }
|
||||
|
||||
/* ---------- Panels ---------- */
|
||||
.panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.panel h3 { margin: 0 0 14px; font-size: 1rem; }
|
||||
.panel-header { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 8px; }
|
||||
.hint { color: var(--muted); font-size: 0.8rem; }
|
||||
|
||||
/* ---------- Tables ---------- */
|
||||
.table-scroll { overflow-x: auto; }
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
||||
.data-table th, .data-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
|
||||
.data-table th { color: var(--muted); font-weight: 600; cursor: pointer; user-select: none; position: sticky; top: 0; background: var(--panel); }
|
||||
.data-table th.num, .data-table td.num { text-align: right; }
|
||||
.data-table tbody tr:hover { background: #f8fafc; cursor: pointer; }
|
||||
.data-table td.explore-link { color: var(--accent); font-weight: 600; cursor: pointer; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 0.75rem; font-weight: 600; }
|
||||
.badge.new { background: #dcfce7; color: #15803d; }
|
||||
.badge.upsell { background: #dbeafe; color: #1d4ed8; }
|
||||
|
||||
/* ---------- Filters ---------- */
|
||||
.filter-row { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 16px; }
|
||||
.filter-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-bottom: 12px; }
|
||||
.filter-row input, .filter-row select, .filter-grid input, .filter-grid select {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.filter-row input[type=text] { flex: 1; min-width: 180px; }
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-size: 0.88rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
button:hover { background: var(--accent-dark); }
|
||||
button.secondary { background: #e5e7eb; color: var(--text); }
|
||||
button.secondary:hover { background: #d1d5db; }
|
||||
.checkbox-inline { display: flex; align-items: center; gap: 6px; font-size: 0.85rem; color: var(--muted); }
|
||||
.level-toggle { display: flex; align-items: center; gap: 8px; font-size: 0.85rem; color: var(--muted); }
|
||||
|
||||
.search-summary { margin: 10px 0; font-size: 0.95rem; }
|
||||
.search-summary b { color: var(--accent-dark); }
|
||||
|
||||
.pagination { display: flex; gap: 8px; align-items: center; margin-top: 14px; font-size: 0.85rem; }
|
||||
.pagination button { background: #e5e7eb; color: var(--text); padding: 6px 12px; }
|
||||
.pagination button:disabled { opacity: 0.4; cursor: default; }
|
||||
.pagination span { color: var(--muted); }
|
||||
|
|
@ -0,0 +1,377 @@
|
|||
const express = require('express');
|
||||
const cookieParser = require('cookie-parser');
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(cookieParser());
|
||||
|
||||
const MOSAIC_API = 'https://api.mosaic.site/v1/project';
|
||||
const SLUG = process.env.MOSAIC_SLUG || 'newt-customer-dash';
|
||||
const MOSAIC_KEY = process.env.MOSAIC_API_KEY;
|
||||
const DASHBOARD_PASSWORD = process.env.DASHBOARD_PASSWORD || '';
|
||||
const SESSION_SECRET = process.env.SESSION_SECRET || 'dev-secret-change-me';
|
||||
|
||||
// ---------- SQL helper (talks to Mosaic's hosted Postgres via HTTP API) ----------
|
||||
async function runQuery(query, params = []) {
|
||||
const r = await fetch(`${MOSAIC_API}/${SLUG}/sql`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-API-Key': MOSAIC_KEY, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, params }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
const err = new Error(data.message || 'SQL error');
|
||||
err.detail = data;
|
||||
throw err;
|
||||
}
|
||||
return data.rows;
|
||||
}
|
||||
|
||||
// ---------- auth ----------
|
||||
function b64url(buf) {
|
||||
return Buffer.from(buf).toString('base64url');
|
||||
}
|
||||
function sign(payload) {
|
||||
const body = b64url(JSON.stringify(payload));
|
||||
const sig = b64url(crypto.createHmac('sha256', SESSION_SECRET).update(body).digest());
|
||||
return `${body}.${sig}`;
|
||||
}
|
||||
function verify(token) {
|
||||
if (!token) return null;
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 2) return null;
|
||||
const [body, sig] = parts;
|
||||
const expected = b64url(crypto.createHmac('sha256', SESSION_SECRET).update(body).digest());
|
||||
const a = Buffer.from(sig);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(body, 'base64url').toString());
|
||||
if (!payload.exp || payload.exp < Date.now()) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const loginAttempts = new Map();
|
||||
function isRateLimited(ip) {
|
||||
const now = Date.now();
|
||||
const entry = loginAttempts.get(ip) || { count: 0, resetAt: now + 15 * 60 * 1000 };
|
||||
if (now > entry.resetAt) {
|
||||
entry.count = 0;
|
||||
entry.resetAt = now + 15 * 60 * 1000;
|
||||
}
|
||||
entry.count += 1;
|
||||
loginAttempts.set(ip, entry);
|
||||
return entry.count > 30;
|
||||
}
|
||||
|
||||
app.post('/api/login', (req, res) => {
|
||||
const ip = req.ip || 'unknown';
|
||||
if (isRateLimited(ip)) {
|
||||
return res.status(429).json({ success: false, message: 'Too many attempts. Try again later.' });
|
||||
}
|
||||
const { password } = req.body || {};
|
||||
if (typeof password !== 'string' || !DASHBOARD_PASSWORD) {
|
||||
return res.status(401).json({ success: false, message: 'Invalid password.' });
|
||||
}
|
||||
const a = Buffer.from(password);
|
||||
const b = Buffer.from(DASHBOARD_PASSWORD);
|
||||
const ok = a.length === b.length && crypto.timingSafeEqual(a, b);
|
||||
if (!ok) return res.status(401).json({ success: false, message: 'Invalid password.' });
|
||||
const token = sign({ exp: Date.now() + 8 * 60 * 60 * 1000 });
|
||||
res.cookie('session', token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: 8 * 60 * 60 * 1000,
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.post('/api/logout', (req, res) => {
|
||||
res.clearCookie('session');
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
app.get('/api/me', (req, res) => {
|
||||
res.json({ authenticated: !!verify(req.cookies.session) });
|
||||
});
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (!verify(req.cookies.session)) return res.status(401).json({ error: 'unauthorized' });
|
||||
next();
|
||||
}
|
||||
|
||||
app.use('/api', (req, res, next) => {
|
||||
if (req.path === '/login' || req.path === '/logout' || req.path === '/me') return next();
|
||||
return requireAuth(req, res, next);
|
||||
});
|
||||
|
||||
// ---------- data endpoints ----------
|
||||
|
||||
app.get('/api/overview', async (req, res) => {
|
||||
try {
|
||||
const rows = await runQuery(`
|
||||
SELECT COUNT(*)::int AS row_count,
|
||||
COALESCE(SUM(amount),0) AS total_amount,
|
||||
COUNT(DISTINCT customer_number)::int AS customer_count,
|
||||
MIN(make_date(year, month, 1)) AS min_period,
|
||||
MAX(make_date(year, month, 1)) AS max_period
|
||||
FROM rawdata WHERE void_status = 'Normal'
|
||||
`);
|
||||
res.json(rows[0]);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/products/summary', async (req, res) => {
|
||||
try {
|
||||
const rows = await runQuery(`
|
||||
SELECT product,
|
||||
COUNT(*)::int AS txn_count,
|
||||
COUNT(DISTINCT customer_number)::int AS customer_count,
|
||||
COALESCE(SUM(amount),0) AS total_amount
|
||||
FROM rawdata
|
||||
WHERE void_status = 'Normal' AND product IS NOT NULL AND product <> ''
|
||||
GROUP BY product
|
||||
ORDER BY total_amount DESC
|
||||
`);
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/products/trend', async (req, res) => {
|
||||
try {
|
||||
const rows = await runQuery(`
|
||||
SELECT product, year, month, COALESCE(SUM(amount),0) AS amount
|
||||
FROM rawdata
|
||||
WHERE void_status = 'Normal' AND product IS NOT NULL AND product <> ''
|
||||
GROUP BY product, year, month
|
||||
ORDER BY year, month
|
||||
`);
|
||||
res.json(rows);
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/filters/options', async (req, res) => {
|
||||
try {
|
||||
const [products, packages, types, brands, years] = await Promise.all([
|
||||
runQuery(`SELECT DISTINCT product FROM rawdata WHERE product IS NOT NULL AND product <> '' ORDER BY product`),
|
||||
runQuery(`SELECT DISTINCT package FROM rawdata WHERE package IS NOT NULL AND package <> '' ORDER BY package`),
|
||||
runQuery(`SELECT DISTINCT customer_type FROM rawdata WHERE customer_type IS NOT NULL AND customer_type <> '' ORDER BY customer_type`),
|
||||
runQuery(`SELECT DISTINCT brand FROM rawdata WHERE brand IS NOT NULL AND brand <> '' ORDER BY brand`),
|
||||
runQuery(`SELECT DISTINCT year FROM rawdata ORDER BY year`),
|
||||
]);
|
||||
res.json({
|
||||
products: products.map((r) => r.product),
|
||||
packages: packages.map((r) => r.package),
|
||||
customer_types: types.map((r) => r.customer_type),
|
||||
brands: brands.map((r) => r.brand),
|
||||
years: years.map((r) => r.year),
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ----- New package adoption report -----
|
||||
const NEW_PKG_CTE = `
|
||||
WITH cust_first AS (
|
||||
SELECT customer_number, MIN(make_date(year, month, 1)) AS first_period
|
||||
FROM rawdata WHERE void_status = 'Normal'
|
||||
GROUP BY customer_number
|
||||
),
|
||||
pkg_first AS (
|
||||
SELECT customer_number, customer_name, package, MIN(make_date(year, month, 1)) AS first_seen
|
||||
FROM rawdata
|
||||
WHERE void_status = 'Normal' AND package IS NOT NULL AND package <> ''
|
||||
GROUP BY customer_number, customer_name, package
|
||||
),
|
||||
first_amt AS (
|
||||
SELECT r.customer_number, r.package, SUM(r.amount) AS first_month_amount
|
||||
FROM rawdata r
|
||||
JOIN pkg_first pf ON r.customer_number = pf.customer_number AND r.package = pf.package
|
||||
WHERE r.void_status = 'Normal' AND make_date(r.year, r.month, 1) = pf.first_seen
|
||||
GROUP BY r.customer_number, r.package
|
||||
),
|
||||
base AS (
|
||||
SELECT pf.customer_number, pf.customer_name, pf.package,
|
||||
EXTRACT(YEAR FROM pf.first_seen)::int AS first_year,
|
||||
EXTRACT(MONTH FROM pf.first_seen)::int AS first_month,
|
||||
pf.first_seen,
|
||||
(pf.first_seen = cf.first_period) AS is_new_customer,
|
||||
fa.first_month_amount
|
||||
FROM pkg_first pf
|
||||
JOIN cust_first cf ON cf.customer_number = pf.customer_number
|
||||
JOIN first_amt fa ON fa.customer_number = pf.customer_number AND fa.package = pf.package
|
||||
)
|
||||
`;
|
||||
|
||||
const NEW_PKG_SORTS = {
|
||||
first_seen_desc: 'first_seen DESC, customer_name ASC',
|
||||
first_seen_asc: 'first_seen ASC, customer_name ASC',
|
||||
amount_desc: 'first_month_amount DESC',
|
||||
customer_asc: 'customer_name ASC',
|
||||
};
|
||||
|
||||
function buildNewPackageWhere(query) {
|
||||
const { mode, package: pkg, search } = query;
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (mode === 'upsell') where.push('is_new_customer = false');
|
||||
if (mode === 'new') where.push('is_new_customer = true');
|
||||
if (pkg) {
|
||||
params.push(pkg);
|
||||
where.push(`package = $${params.length}`);
|
||||
}
|
||||
if (search) {
|
||||
params.push(`%${search.toLowerCase()}%`);
|
||||
const idx = params.length;
|
||||
where.push(`(LOWER(customer_name) LIKE $${idx} OR customer_number LIKE $${idx})`);
|
||||
}
|
||||
return { whereSql: where.length ? `WHERE ${where.join(' AND ')}` : '', params };
|
||||
}
|
||||
|
||||
app.get('/api/new-packages', async (req, res) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 500);
|
||||
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
||||
const sort = NEW_PKG_SORTS[req.query.sort] || NEW_PKG_SORTS.first_seen_desc;
|
||||
const { whereSql, params } = buildNewPackageWhere(req.query);
|
||||
|
||||
const dataParams = [...params, limit, offset];
|
||||
const rows = await runQuery(
|
||||
`${NEW_PKG_CTE} SELECT * FROM base ${whereSql} ORDER BY ${sort} LIMIT $${dataParams.length - 1} OFFSET $${dataParams.length}`,
|
||||
dataParams
|
||||
);
|
||||
const countRows = await runQuery(
|
||||
`${NEW_PKG_CTE} SELECT COUNT(*)::int AS total FROM base ${whereSql}`,
|
||||
params
|
||||
);
|
||||
res.json({ rows, total: countRows[0].total });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, detail: e.detail });
|
||||
}
|
||||
});
|
||||
|
||||
// ----- Search / drill-down -----
|
||||
function buildTransactionFilters(query) {
|
||||
const {
|
||||
product, package: pkg, customer, text, customerType, brand,
|
||||
year, month, yearFrom, monthFrom, yearTo, monthTo, includeVoided,
|
||||
} = query;
|
||||
const where = [];
|
||||
const params = [];
|
||||
|
||||
if (!includeVoided || includeVoided === 'false') {
|
||||
where.push(`void_status = 'Normal'`);
|
||||
}
|
||||
if (product) {
|
||||
params.push(product);
|
||||
where.push(`product = $${params.length}`);
|
||||
}
|
||||
if (pkg) {
|
||||
params.push(pkg);
|
||||
where.push(`package = $${params.length}`);
|
||||
}
|
||||
if (customerType) {
|
||||
params.push(customerType);
|
||||
where.push(`customer_type = $${params.length}`);
|
||||
}
|
||||
if (brand) {
|
||||
params.push(brand);
|
||||
where.push(`brand = $${params.length}`);
|
||||
}
|
||||
if (customer) {
|
||||
params.push(`%${customer.toLowerCase()}%`);
|
||||
const idx = params.length;
|
||||
where.push(`(LOWER(customer_name) LIKE $${idx} OR customer_number LIKE $${idx})`);
|
||||
}
|
||||
if (text) {
|
||||
params.push(`%${text.toLowerCase()}%`);
|
||||
const idx = params.length;
|
||||
where.push(`(LOWER(item_description) LIKE $${idx} OR LOWER(item_number) LIKE $${idx} OR sop_number LIKE $${idx})`);
|
||||
}
|
||||
if (year) {
|
||||
params.push(parseInt(year, 10));
|
||||
where.push(`year = $${params.length}`);
|
||||
}
|
||||
if (month) {
|
||||
params.push(parseInt(month, 10));
|
||||
where.push(`month = $${params.length}`);
|
||||
}
|
||||
if (yearFrom && monthFrom) {
|
||||
params.push(parseInt(yearFrom, 10) * 12 + parseInt(monthFrom, 10));
|
||||
where.push(`(year * 12 + month) >= $${params.length}`);
|
||||
}
|
||||
if (yearTo && monthTo) {
|
||||
params.push(parseInt(yearTo, 10) * 12 + parseInt(monthTo, 10));
|
||||
where.push(`(year * 12 + month) <= $${params.length}`);
|
||||
}
|
||||
|
||||
return { whereSql: where.length ? `WHERE ${where.join(' AND ')}` : '', params };
|
||||
}
|
||||
|
||||
app.get('/api/transactions', async (req, res) => {
|
||||
try {
|
||||
const level = req.query.level === 'customer' ? 'customer' : 'detail';
|
||||
const limit = Math.min(parseInt(req.query.limit, 10) || 50, 500);
|
||||
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
||||
const { whereSql, params } = buildTransactionFilters(req.query);
|
||||
|
||||
const aggRows = await runQuery(
|
||||
`SELECT COUNT(*)::int AS cnt, COALESCE(SUM(amount),0) AS total FROM rawdata ${whereSql}`,
|
||||
params
|
||||
);
|
||||
|
||||
let rows;
|
||||
if (level === 'customer') {
|
||||
const dataParams = [...params, limit, offset];
|
||||
rows = await runQuery(
|
||||
`SELECT customer_number, customer_name,
|
||||
COUNT(*)::int AS txn_count,
|
||||
COALESCE(SUM(amount),0) AS total_amount
|
||||
FROM rawdata ${whereSql}
|
||||
GROUP BY customer_number, customer_name
|
||||
ORDER BY total_amount DESC
|
||||
LIMIT $${dataParams.length - 1} OFFSET $${dataParams.length}`,
|
||||
dataParams
|
||||
);
|
||||
} else {
|
||||
const dataParams = [...params, limit, offset];
|
||||
rows = await runQuery(
|
||||
`SELECT id, sop_number, customer_number, customer_name, year, month, gl_date,
|
||||
item_number, item_description, void_status, amount, product, package,
|
||||
brand, customer_type
|
||||
FROM rawdata ${whereSql}
|
||||
ORDER BY year DESC, month DESC, gl_date DESC, id DESC
|
||||
LIMIT $${dataParams.length - 1} OFFSET $${dataParams.length}`,
|
||||
dataParams
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ rows, count: aggRows[0].cnt, total_amount: aggRows[0].total });
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e.message, detail: e.detail });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- static frontend ----------
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 8080;
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`NEWT customer dashboard listening on port ${PORT}`);
|
||||
});
|
||||
Loading…
Reference in New Issue