Initial dashboard commit

This commit is contained in:
mosaic 2026-07-08 15:35:26 +00:00
parent 45d0d5b5f2
commit 01c8715005
5 changed files with 1134 additions and 0 deletions

16
package.json Normal file
View File

@ -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"
}
}

430
public/app.js Normal file
View File

@ -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();

153
public/index.html Normal file
View File

@ -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 &amp; 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 &amp; 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>

158
public/styles.css Normal file
View File

@ -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); }

377
server.js Normal file
View File

@ -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}`);
});