Merge pull request #2 from amoussa1229/feature/auto-sync-pos

PO pagination + fix Google Sign-In client_id error
This commit is contained in:
Adam Moussa 2026-04-03 14:31:20 -04:00 • committed by GitHub
commit fcdf14d4f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 106 additions and 215 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
node_modules/
.DS_Store
config.local.js

View file

@ -31,7 +31,9 @@ ledgerflow_frontend/
## Features
### Purchase Orders
- Import POs from external DynamoDB table (Coupa purchase-orders)
- POs auto-sync from external DynamoDB table via DynamoDB Streams (near-real-time)
- Paginated table (20 per page) with page navigation controls
- Search/filter across all POs (resets to page 1)
- Add POs manually
- Edit POs after import (vendor, amount, status, dates, notes)
- Bill against POs to create invoices

View file

@ -256,6 +256,12 @@
/* TABLE */
.table-wrap { overflow-x: auto; }
.pagination { display:flex;align-items:center;justify-content:center;gap:4px;padding:12px 16px;border-top:1px solid var(--border); }
.pagination button { background:var(--surface2);border:1px solid var(--border);color:var(--text2);border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer;font-family:var(--mono);transition:all .15s; }
.pagination button:hover { border-color:var(--accent);color:var(--accent); }
.pagination button.active { background:var(--accent);color:var(--bg);border-color:var(--accent);font-weight:500; }
.pagination button:disabled { opacity:.35;cursor:default; }
.pagination .page-info { font-size:11px;color:var(--text3);margin:0 8px; }
table { width: 100%; border-collapse: collapse; }
thead th {
font-size: 10px;
@ -579,7 +585,6 @@
<div class="topbar">
<div class="topbar-title" id="page-title">Dashboard</div>
<div class="topbar-actions" id="topbar-actions">
<button class="btn" onclick="openModal('importPOModal')">⬇ Import POs</button>
<button class="btn primary" onclick="openModal('newInvoiceModal')">+ New Invoice</button>
</div>
</div>
@ -624,7 +629,7 @@
<div class="table-wrap">
<table>
<thead><tr><th>PO Number</th><th>Vendor</th><th>Amount</th><th>Status</th></tr></thead>
<tbody id="dashboard-po-table"><tr><td colspan="4"><div class="empty-state" style="padding:20px"><div class="es-icon">📦</div><p>No POs imported yet</p></div></td></tr></tbody>
<tbody id="dashboard-po-table"><tr><td colspan="4"><div class="empty-state" style="padding:20px"><div class="es-icon">📦</div><p>No POs synced yet</p></div></td></tr></tbody>
</table>
</div>
</div>
@ -660,26 +665,26 @@
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
<div>
<h2 style="font-family:var(--sans);font-weight:700;font-size:18px">Purchase Orders</h2>
<p style="color:var(--text3);font-size:11px;margin-top:4px">Imported from DynamoDB · <span id="po-count">0</span> records</p>
<p style="color:var(--text3);font-size:11px;margin-top:4px">Auto-synced from DynamoDB · <span id="po-count">0</span> records</p>
</div>
<div style="display:flex;gap:8px">
<button class="btn" onclick="openModal('importPOModal')">⬇ Import from DynamoDB</button>
<button class="btn" onclick="openModal('addPOModal')">+ Add Manual PO</button>
</div>
</div>
<div class="card">
<div class="card-header">
<span class="card-title">All Purchase Orders</span>
<input class="form-input" style="width:200px" placeholder="🔍 Search POs..." oninput="filterTable('po-table', this.value)">
<input class="form-input" style="width:200px" placeholder="🔍 Search POs..." id="po-search" oninput="state.poPage=1;refreshPOTable()">
</div>
<div class="table-wrap">
<table id="po-table">
<thead><tr><th>PO Number</th><th>Vendor</th><th>Issue Date</th><th>Due Date</th><th>Amount</th><th>Billed</th><th>Remaining</th><th>Status</th><th>Action</th></tr></thead>
<tbody id="po-table-body">
<tr><td colspan="9"><div class="empty-state"><div class="es-icon">📦</div><p>No purchase orders yet. Import from DynamoDB or add manually.</p></div></td></tr>
<tr><td colspan="9"><div class="empty-state"><div class="es-icon">📦</div><p>No purchase orders yet. POs sync automatically from DynamoDB.</p></div></td></tr>
</tbody>
</table>
</div>
<div class="pagination" id="po-pagination"></div>
</div>
</div>
@ -983,55 +988,6 @@
<!-- ===================== MODALS ===================== -->
<!-- Import PO Modal -->
<div class="modal-overlay" id="importPOModal">
<div class="modal">
<div class="modal-header">
<span class="modal-title">Import Purchase Orders from DynamoDB</span>
<button class="modal-close" onclick="closeModal('importPOModal')">×</button>
</div>
<div class="modal-body">
<div id="dynamo-import-state">
<div style="margin-bottom:16px;padding:12px;background:var(--surface2);border:1px solid var(--border);border-radius:8px;font-size:12px;color:var(--text2)">
<strong style="color:var(--text)">DynamoDB Configuration</strong><br>
Region: <span id="imp-region">—</span> · Table: <span id="imp-table">—</span>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Filter by Status</label>
<select class="form-select" id="imp-status-filter">
<option value="">All Statuses</option>
<option value="OPEN">Open</option>
<option value="PENDING">Pending</option>
<option value="APPROVED">Approved</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Limit Records</label>
<select class="form-select" id="imp-limit">
<option>25</option><option>50</option><option>100</option><option>250</option><option>All</option>
</select>
</div>
</div>
<button class="btn" style="width:100%;margin-bottom:12px" onclick="fetchFromDynamo()">🔄 Fetch from DynamoDB</button>
<div id="dynamo-preview" style="display:none">
<div style="font-size:11px;color:var(--text3);margin-bottom:8px">PREVIEW — <span id="preview-count">0</span> records fetched</div>
<div id="dynamo-preview-list"></div>
</div>
<div style="margin-top:14px;padding-top:14px;border-top:1px solid var(--border)">
<div style="font-size:11px;color:var(--text3);margin-bottom:8px">OR PASTE SAMPLE JSON (for demo / testing)</div>
<textarea class="form-textarea" id="dynamo-json-paste" placeholder='[{"po_number":"PO-001","vendor_name":"Acme Supplies","total_amount":15000,"status":"OPEN","issue_date":"2026-01-15","due_date":"2026-02-15"}]' style="height:90px;font-size:11px"></textarea>
<button class="btn" style="width:100%;margin-top:8px" onclick="importFromJSON()">⬇ Import from JSON</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeModal('importPOModal')">Cancel</button>
<button class="btn primary" id="import-confirm-btn" onclick="confirmImport()">Import Selected</button>
</div>
</div>
</div>
<!-- Add Manual PO Modal -->
<div class="modal-overlay" id="addPOModal">
<div class="modal">
@ -1309,8 +1265,9 @@ let state = {
edi: { region: 'us-east-1', partnershipId: '', key: '', secret: '', senderId: '', receiverId: '', transformerId: '', connected: false },
company: { name: '', taxId: '', terms: 'Net 30', currency: 'USD', invPrefix: 'INV-', taxRate: 0 }
},
pendingImport: [],
invCounter: 1
invCounter: 1,
poPage: 1,
poPerPage: 20
};
// Load settings from backend
@ -1345,8 +1302,8 @@ function navigate(view, el) {
const titles = { dashboard:'Dashboard', pos:'Purchase Orders', invoices:'Invoices', billing:'Billing', edi:'EDI Console', settings:'Connections & Settings' };
const actions = {
dashboard: '<button class="btn" onclick="openModal(\'importPOModal\')">⬇ Import POs</button><button class="btn primary" onclick="openModal(\'newInvoiceModal\')">+ New Invoice</button>',
pos: '<button class="btn" onclick="openModal(\'importPOModal\')">⬇ Import from DynamoDB</button><button class="btn primary" onclick="openModal(\'addPOModal\')">+ Add PO</button>',
dashboard: '<button class="btn primary" onclick="openModal(\'newInvoiceModal\')">+ New Invoice</button>',
pos: '<button class="btn primary" onclick="openModal(\'addPOModal\')">+ Add PO</button>',
invoices: '<button class="btn primary" onclick="openModal(\'newInvoiceModal\')">+ Create Invoice</button>',
billing: '<button class="btn primary" onclick="openModal(\'newInvoiceModal\')">+ New Billing</button>',
edi: '',
@ -1367,10 +1324,6 @@ function navigate(view, el) {
function openModal(id) {
document.getElementById(id).classList.add('open');
if (id === 'newInvoiceModal' && !editingInvoiceId) initNewInvoiceModal();
if (id === 'importPOModal') {
document.getElementById('imp-region').textContent = state.config.dynamo.region || '—';
document.getElementById('imp-table').textContent = state.config.dynamo.table || '—';
}
}
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
document.querySelectorAll('.modal-overlay').forEach(o => {
@ -1516,141 +1469,6 @@ function updateConnectionBadges() {
// ============================================================
// PURCHASE ORDERS
// ============================================================
async function fetchFromDynamo() {
if (!state.config.dynamo.table) {
toast('Configure DynamoDB table name in Settings first', 'warn');
return;
}
toast('Fetching from DynamoDB...', 'info');
const params = new URLSearchParams({ limit: '200' });
const data = await api(`/pos/dynamo-scan?${params}`);
if (!data) return;
// Backend returns already-mapped items using the user's field config
state.pendingImport = (data.items || []).map(r => ({
poNumber: r.poNumber || '—',
vendor: r.vendor || '—',
amount: parseFloat(r.amount || 0),
status: (r.status || 'open').toLowerCase(),
issueDate: r.issueDate || '',
dueDate: r.dueDate || '',
notes: r._raw?.notes || r._raw?.description || ''
}));
document.getElementById('dynamo-preview').style.display = 'block';
document.getElementById('preview-count').textContent = state.pendingImport.length;
const list = document.getElementById('dynamo-preview-list');
if (!state.pendingImport.length) {
list.innerHTML = '<p style="font-size:11px;color:var(--text3)">No records found in table.</p>';
return;
}
list.innerHTML = state.pendingImport.map((po, i) =>
`<div class="po-match-bar selected" onclick="togglePendingPO(this,${i})" data-idx="${i}">
<div class="po-match-check">✓</div>
<div style="flex:1">
<span style="color:var(--text);font-weight:500">${po.poNumber}</span>
<span style="color:var(--text3);margin:0 8px">·</span>
<span>${po.vendor}</span>
</div>
<span style="color:var(--accent)">${fmt(po.amount)}</span>
<span class="badge ${po.status}" style="margin-left:8px">${po.status}</span>
</div>`
).join('');
toast(`${state.pendingImport.length} records fetched from DynamoDB`, 'ok');
}
// Unmarshall DynamoDB JSON ({S:"val"}, {N:"123"}, {NULL:true}, etc.) to plain values
function unmarshallValue(v) {
if (v === null || v === undefined) return null;
if (typeof v !== 'object') return v;
if ('S' in v) return v.S;
if ('N' in v) return parseFloat(v.N);
if ('BOOL' in v) return v.BOOL;
if ('NULL' in v) return null;
if ('L' in v) return v.L.map(unmarshallValue);
if ('M' in v) return unmarshallItem(v.M);
return v;
}
function unmarshallItem(item) {
const out = {};
for (const [k, v] of Object.entries(item)) out[k] = unmarshallValue(v);
return out;
}
function isDynamoJSON(obj) {
const vals = Object.values(obj);
if (vals.length === 0) return false;
return vals.some(v => v && typeof v === 'object' && !Array.isArray(v) && ('S' in v || 'N' in v || 'NULL' in v || 'BOOL' in v || 'M' in v || 'L' in v));
}
function importFromJSON() {
const raw = document.getElementById('dynamo-json-paste').value.trim();
if (!raw) { toast('Paste JSON data first', 'warn'); return; }
try {
let data = JSON.parse(raw);
if (!Array.isArray(data)) data = [data];
// Auto-detect and unmarshall DynamoDB JSON format
data = data.map(item => isDynamoJSON(item) ? unmarshallItem(item) : item);
const fields = state.config.dynamo.fields;
state.pendingImport = data.map(r => ({
poNumber: r[fields.poNumber] || r.po_number || r.poNumber || '—',
vendor: r[fields.vendor] || r.vendor_name || r.vendor || r.supplier?.name || '—',
amount: parseFloat(r[fields.amount] || r.total_amount || r.amount || 0),
status: (r[fields.status] || r.po_status || r.status || 'open').toLowerCase(),
issueDate: r[fields.issueDate] || r.issue_date || r.issueDate || r.order_date || '',
dueDate: r[fields.dueDate] || r.due_date || r.dueDate || '',
notes: r.notes || r.description || ''
}));
document.getElementById('dynamo-preview').style.display = 'block';
document.getElementById('preview-count').textContent = state.pendingImport.length;
const list = document.getElementById('dynamo-preview-list');
list.innerHTML = state.pendingImport.map((po, i) =>
`<div class="po-match-bar selected" onclick="togglePendingPO(this,${i})" data-idx="${i}">
<div class="po-match-check">✓</div>
<div style="flex:1">
<span style="color:var(--text);font-weight:500">${po.poNumber}</span>
<span style="color:var(--text3);margin:0 8px">·</span>
<span>${po.vendor}</span>
</div>
<span style="color:var(--accent)">${fmt(po.amount)}</span>
<span class="badge ${po.status}" style="margin-left:8px">${po.status}</span>
</div>`
).join('');
toast(`${state.pendingImport.length} records parsed`, 'ok');
} catch(e) {
toast('Invalid JSON: ' + e.message, 'err');
}
}
function togglePendingPO(el, idx) {
el.classList.toggle('selected');
}
async function confirmImport() {
const selected = document.querySelectorAll('#dynamo-preview-list .po-match-bar.selected');
if (selected.length === 0) { toast('Select at least one PO to import', 'warn'); return; }
const items = [];
selected.forEach(el => {
const idx = parseInt(el.dataset.idx);
items.push(state.pendingImport[idx]);
});
toast('Importing POs...', 'info');
const data = await api('/pos/import', {
method: 'POST',
body: { items, source: 'dynamodb' },
});
if (!data) return;
closeModal('importPOModal');
await loadPOs();
refreshPOTable();
refreshDashboard();
addActivity(`Imported ${data.imported} PO(s) from DynamoDB (${data.skipped} skipped)`, 'green');
toast(`Imported ${data.imported} PO(s) successfully`, 'ok');
state.pendingImport = [];
}
async function addManualPO() {
const poNumber = document.getElementById('add-po-number').value.trim();
@ -1719,14 +1537,26 @@ async function saveEditPO() {
function refreshPOTable() {
const tbody = document.getElementById('po-table-body');
const paginationEl = document.getElementById('po-pagination');
const searchQuery = (document.getElementById('po-search')?.value || '').toLowerCase();
const filtered = searchQuery
? state.pos.filter(po => `${po.poNumber} ${po.vendor} ${po.status}`.toLowerCase().includes(searchQuery))
: state.pos;
document.getElementById('po-count').textContent = state.pos.length;
if (!state.pos.length) {
tbody.innerHTML = '<tr><td colspan="9"><div class="empty-state"><div class="es-icon">📦</div><p>No POs yet</p></div></td></tr>';
if (!filtered.length) {
tbody.innerHTML = `<tr><td colspan="9"><div class="empty-state"><div class="es-icon">📦</div><p>${searchQuery ? 'No matching POs' : 'No POs yet'}</p></div></td></tr>`;
paginationEl.innerHTML = '';
return;
}
tbody.innerHTML = state.pos.map(po => {
const totalPages = Math.ceil(filtered.length / state.poPerPage);
if (state.poPage > totalPages) state.poPage = totalPages;
if (state.poPage < 1) state.poPage = 1;
const start = (state.poPage - 1) * state.poPerPage;
const pageItems = filtered.slice(start, start + state.poPerPage);
tbody.innerHTML = pageItems.map(po => {
const remaining = po.amount - (po.billed || 0);
const pct = po.amount > 0 ? Math.round((po.billed||0)/po.amount*100) : 0;
return `<tr>
<td class="mono">${po.poNumber}</td>
<td class="bold">${po.vendor}</td>
@ -1739,6 +1569,38 @@ function refreshPOTable() {
<td><button class="btn" style="padding:3px 9px;font-size:11px" onclick="billAgainstPO('${po.id}')">Bill</button> <button class="btn" style="padding:3px 9px;font-size:11px" onclick="openEditPO('${po.id}')">Edit</button></td>
</tr>`;
}).join('');
// Render pagination controls
if (totalPages <= 1) { paginationEl.innerHTML = ''; return; }
let btns = `<button onclick="goToPOPage(${state.poPage - 1})" ${state.poPage === 1 ? 'disabled' : ''}>&laquo;</button>`;
const maxVisible = 7;
let pages = [];
if (totalPages <= maxVisible) {
for (let i = 1; i <= totalPages; i++) pages.push(i);
} else {
pages.push(1);
let lo = Math.max(2, state.poPage - 1);
let hi = Math.min(totalPages - 1, state.poPage + 1);
if (state.poPage <= 3) hi = Math.min(totalPages - 1, 4);
if (state.poPage >= totalPages - 2) lo = Math.max(2, totalPages - 3);
if (lo > 2) pages.push('…');
for (let i = lo; i <= hi; i++) pages.push(i);
if (hi < totalPages - 1) pages.push('…');
pages.push(totalPages);
}
pages.forEach(p => {
if (p === '…') { btns += `<span class="page-info">…</span>`; }
else { btns += `<button class="${p === state.poPage ? 'active' : ''}" onclick="goToPOPage(${p})">${p}</button>`; }
});
btns += `<button onclick="goToPOPage(${state.poPage + 1})" ${state.poPage === totalPages ? 'disabled' : ''}>&raquo;</button>`;
btns += `<span class="page-info">${start+1}–${Math.min(start+state.poPerPage, filtered.length)} of ${filtered.length}</span>`;
paginationEl.innerHTML = btns;
}
function goToPOPage(page) {
state.poPage = page;
refreshPOTable();
document.getElementById('po-table').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function billAgainstPO(poId) {
@ -2453,8 +2315,16 @@ function filterTable(tableId, query) {
// DATA LOADERS
// ============================================================
async function loadPOs() {
const data = await api('/pos?limit=200');
if (data && data.items) state.pos = data.items;
let all = [];
let cursor = null;
do {
const url = '/pos?limit=200' + (cursor ? '&cursor=' + encodeURIComponent(cursor) : '');
const data = await api(url);
if (!data || !data.items) break;
all = all.concat(data.items);
cursor = data.cursor || null;
} while (cursor);
state.pos = all;
}
async function loadInvoices() {

View file

@ -6,8 +6,19 @@
<title>LedgerFlow — Sign In</title>
<!-- Config (API URL + Client ID) -->
<script src="config.js"></script>
<!-- Google Identity Services -->
<script src="https://accounts.google.com/gsi/client" async defer onload="onGisLoaded()"></script>
<!-- Google Identity Services — loaded dynamically after config.js sets GOOGLE_CLIENT_ID -->
<script>
// Load GSI only after client ID is available, preventing "Missing required parameter" errors
(function() {
var s = document.createElement('script');
s.src = 'https://accounts.google.com/gsi/client';
s.async = true;
s.defer = true;
if (window.GOOGLE_CLIENT_ID) s.setAttribute('data-client_id', window.GOOGLE_CLIENT_ID);
s.onload = function() { if (typeof onGisLoaded === 'function') onGisLoaded(); };
document.head.appendChild(s);
})();
</script>
<style>
:root {
--bg: #0a0c0f;
@ -205,17 +216,17 @@ let _pendingClientId = null;
window.addEventListener("load", async () => {
// Fetch Google Client ID from backend config endpoint
let clientId = GOOGLE_CLIENT_ID;
try {
if (API_URL) {
const res = await fetch(`${API_URL}/auth/config`);
const cfg = await res.json();
if (cfg.googleClientId) initGoogleSignIn(cfg.googleClientId);
} else if (GOOGLE_CLIENT_ID) {
initGoogleSignIn(GOOGLE_CLIENT_ID);
if (cfg.googleClientId) clientId = cfg.googleClientId;
}
} catch (e) {
console.warn("Could not load auth config:", e);
}
if (clientId) initGoogleSignIn(clientId);
});
function initGoogleSignIn(clientId) {
@ -245,7 +256,8 @@ function initGoogleSignIn(clientId) {
);
// Hide the fallback button
document.getElementById("fallback-btn").style.display = "none";
const fb = document.getElementById("fallback-btn");
if (fb) fb.style.display = "none";
// Show One Tap prompt
google.accounts.id.prompt();
@ -351,8 +363,6 @@ window.LedgerFlowAPI = {
delete: (id) => window.LedgerFlowAPI._fetch(`/pos/${id}`, { method: "DELETE" }),
import: (items, fieldMap, source) =>
window.LedgerFlowAPI._fetch("/pos/import", { method: "POST", body: JSON.stringify({ items, fieldMap, source }) }),
dynamoScan: (table, status, limit) =>
window.LedgerFlowAPI._fetch(`/pos/dynamo-scan?table=${table}&status=${status || ""}&limit=${limit || 50}`),
},
// Invoices

View file

@ -1,5 +1,6 @@
// Simple local dev server — serves frontend on http://localhost:3000
// Usage: node serve.js
// Usage: node serve.js (uses config.js — points to deployed API)
// node serve.js --local (uses config.local.js — points to SAM local on :3001)
const http = require("http");
const fs = require("fs");
@ -7,6 +8,7 @@ const path = require("path");
const PORT = 3000;
const DIR = __dirname;
const LOCAL_MODE = process.argv.includes("--local");
const MIME = {
".html": "text/html",
@ -22,6 +24,11 @@ http.createServer((req, res) => {
let url = req.url.split("?")[0];
if (url === "/") url = "/login.html";
// In local mode, serve config.local.js when config.js is requested
if (LOCAL_MODE && url === "/config.js") {
url = "/config.local.js";
}
const filePath = path.join(DIR, url);
const ext = path.extname(filePath);
@ -35,5 +42,6 @@ http.createServer((req, res) => {
res.end(data);
});
}).listen(PORT, () => {
console.log(`\n LedgerFlow frontend: http://localhost:${PORT}\n`);
const mode = LOCAL_MODE ? "LOCAL (API → localhost:3001)" : "DEPLOYED (API → production)";
console.log(`\n LedgerFlow frontend: http://localhost:${PORT} [${mode}]\n`);
});