PO pagination + fix Google Sign-In client_id error #2
5 changed files with 106 additions and 215 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
config.local.js
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,9 @@ ledgerflow_frontend/
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
### Purchase Orders
|
### 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
|
- Add POs manually
|
||||||
- Edit POs after import (vendor, amount, status, dates, notes)
|
- Edit POs after import (vendor, amount, status, dates, notes)
|
||||||
- Bill against POs to create invoices
|
- Bill against POs to create invoices
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,12 @@
|
||||||
|
|
||||||
/* TABLE */
|
/* TABLE */
|
||||||
.table-wrap { overflow-x: auto; }
|
.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; }
|
table { width: 100%; border-collapse: collapse; }
|
||||||
thead th {
|
thead th {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
|
|
@ -579,7 +585,6 @@
|
||||||
<div class="topbar">
|
<div class="topbar">
|
||||||
<div class="topbar-title" id="page-title">Dashboard</div>
|
<div class="topbar-title" id="page-title">Dashboard</div>
|
||||||
<div class="topbar-actions" id="topbar-actions">
|
<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>
|
<button class="btn primary" onclick="openModal('newInvoiceModal')">+ New Invoice</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -624,7 +629,7 @@
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>PO Number</th><th>Vendor</th><th>Amount</th><th>Status</th></tr></thead>
|
<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>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -660,26 +665,26 @@
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
|
||||||
<div>
|
<div>
|
||||||
<h2 style="font-family:var(--sans);font-weight:700;font-size:18px">Purchase Orders</h2>
|
<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>
|
||||||
<div style="display:flex;gap:8px">
|
<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>
|
<button class="btn" onclick="openModal('addPOModal')">+ Add Manual PO</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title">All Purchase Orders</span>
|
<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>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table id="po-table">
|
<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>
|
<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">
|
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pagination" id="po-pagination"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -983,55 +988,6 @@
|
||||||
|
|
||||||
<!-- ===================== MODALS ===================== -->
|
<!-- ===================== 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 -->
|
<!-- Add Manual PO Modal -->
|
||||||
<div class="modal-overlay" id="addPOModal">
|
<div class="modal-overlay" id="addPOModal">
|
||||||
<div class="modal">
|
<div class="modal">
|
||||||
|
|
@ -1309,8 +1265,9 @@ let state = {
|
||||||
edi: { region: 'us-east-1', partnershipId: '', key: '', secret: '', senderId: '', receiverId: '', transformerId: '', connected: false },
|
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 }
|
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
|
// 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 titles = { dashboard:'Dashboard', pos:'Purchase Orders', invoices:'Invoices', billing:'Billing', edi:'EDI Console', settings:'Connections & Settings' };
|
||||||
const actions = {
|
const actions = {
|
||||||
dashboard: '<button class="btn" onclick="openModal(\'importPOModal\')">⬇ Import POs</button><button class="btn primary" onclick="openModal(\'newInvoiceModal\')">+ New Invoice</button>',
|
dashboard: '<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>',
|
pos: '<button class="btn primary" onclick="openModal(\'addPOModal\')">+ Add PO</button>',
|
||||||
invoices: '<button class="btn primary" onclick="openModal(\'newInvoiceModal\')">+ Create Invoice</button>',
|
invoices: '<button class="btn primary" onclick="openModal(\'newInvoiceModal\')">+ Create Invoice</button>',
|
||||||
billing: '<button class="btn primary" onclick="openModal(\'newInvoiceModal\')">+ New Billing</button>',
|
billing: '<button class="btn primary" onclick="openModal(\'newInvoiceModal\')">+ New Billing</button>',
|
||||||
edi: '',
|
edi: '',
|
||||||
|
|
@ -1367,10 +1324,6 @@ function navigate(view, el) {
|
||||||
function openModal(id) {
|
function openModal(id) {
|
||||||
document.getElementById(id).classList.add('open');
|
document.getElementById(id).classList.add('open');
|
||||||
if (id === 'newInvoiceModal' && !editingInvoiceId) initNewInvoiceModal();
|
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'); }
|
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
|
||||||
document.querySelectorAll('.modal-overlay').forEach(o => {
|
document.querySelectorAll('.modal-overlay').forEach(o => {
|
||||||
|
|
@ -1516,141 +1469,6 @@ function updateConnectionBadges() {
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// PURCHASE ORDERS
|
// 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() {
|
async function addManualPO() {
|
||||||
const poNumber = document.getElementById('add-po-number').value.trim();
|
const poNumber = document.getElementById('add-po-number').value.trim();
|
||||||
|
|
@ -1719,14 +1537,26 @@ async function saveEditPO() {
|
||||||
|
|
||||||
function refreshPOTable() {
|
function refreshPOTable() {
|
||||||
const tbody = document.getElementById('po-table-body');
|
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;
|
document.getElementById('po-count').textContent = state.pos.length;
|
||||||
if (!state.pos.length) {
|
if (!filtered.length) {
|
||||||
tbody.innerHTML = '<tr><td colspan="9"><div class="empty-state"><div class="es-icon">📦</div><p>No POs yet</p></div></td></tr>';
|
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;
|
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 remaining = po.amount - (po.billed || 0);
|
||||||
const pct = po.amount > 0 ? Math.round((po.billed||0)/po.amount*100) : 0;
|
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td class="mono">${po.poNumber}</td>
|
<td class="mono">${po.poNumber}</td>
|
||||||
<td class="bold">${po.vendor}</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>
|
<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>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
// Render pagination controls
|
||||||
|
if (totalPages <= 1) { paginationEl.innerHTML = ''; return; }
|
||||||
|
let btns = `<button onclick="goToPOPage(${state.poPage - 1})" ${state.poPage === 1 ? 'disabled' : ''}>«</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' : ''}>»</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) {
|
function billAgainstPO(poId) {
|
||||||
|
|
@ -2453,8 +2315,16 @@ function filterTable(tableId, query) {
|
||||||
// DATA LOADERS
|
// DATA LOADERS
|
||||||
// ============================================================
|
// ============================================================
|
||||||
async function loadPOs() {
|
async function loadPOs() {
|
||||||
const data = await api('/pos?limit=200');
|
let all = [];
|
||||||
if (data && data.items) state.pos = data.items;
|
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() {
|
async function loadInvoices() {
|
||||||
|
|
|
||||||
26
login.html
26
login.html
|
|
@ -6,8 +6,19 @@
|
||||||
<title>LedgerFlow — Sign In</title>
|
<title>LedgerFlow — Sign In</title>
|
||||||
<!-- Config (API URL + Client ID) -->
|
<!-- Config (API URL + Client ID) -->
|
||||||
<script src="config.js"></script>
|
<script src="config.js"></script>
|
||||||
<!-- Google Identity Services -->
|
<!-- Google Identity Services — loaded dynamically after config.js sets GOOGLE_CLIENT_ID -->
|
||||||
<script src="https://accounts.google.com/gsi/client" async defer onload="onGisLoaded()"></script>
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0a0c0f;
|
--bg: #0a0c0f;
|
||||||
|
|
@ -205,17 +216,17 @@ let _pendingClientId = null;
|
||||||
|
|
||||||
window.addEventListener("load", async () => {
|
window.addEventListener("load", async () => {
|
||||||
// Fetch Google Client ID from backend config endpoint
|
// Fetch Google Client ID from backend config endpoint
|
||||||
|
let clientId = GOOGLE_CLIENT_ID;
|
||||||
try {
|
try {
|
||||||
if (API_URL) {
|
if (API_URL) {
|
||||||
const res = await fetch(`${API_URL}/auth/config`);
|
const res = await fetch(`${API_URL}/auth/config`);
|
||||||
const cfg = await res.json();
|
const cfg = await res.json();
|
||||||
if (cfg.googleClientId) initGoogleSignIn(cfg.googleClientId);
|
if (cfg.googleClientId) clientId = cfg.googleClientId;
|
||||||
} else if (GOOGLE_CLIENT_ID) {
|
|
||||||
initGoogleSignIn(GOOGLE_CLIENT_ID);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn("Could not load auth config:", e);
|
console.warn("Could not load auth config:", e);
|
||||||
}
|
}
|
||||||
|
if (clientId) initGoogleSignIn(clientId);
|
||||||
});
|
});
|
||||||
|
|
||||||
function initGoogleSignIn(clientId) {
|
function initGoogleSignIn(clientId) {
|
||||||
|
|
@ -245,7 +256,8 @@ function initGoogleSignIn(clientId) {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hide the fallback button
|
// 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
|
// Show One Tap prompt
|
||||||
google.accounts.id.prompt();
|
google.accounts.id.prompt();
|
||||||
|
|
@ -351,8 +363,6 @@ window.LedgerFlowAPI = {
|
||||||
delete: (id) => window.LedgerFlowAPI._fetch(`/pos/${id}`, { method: "DELETE" }),
|
delete: (id) => window.LedgerFlowAPI._fetch(`/pos/${id}`, { method: "DELETE" }),
|
||||||
import: (items, fieldMap, source) =>
|
import: (items, fieldMap, source) =>
|
||||||
window.LedgerFlowAPI._fetch("/pos/import", { method: "POST", body: JSON.stringify({ 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
|
// Invoices
|
||||||
|
|
|
||||||
12
serve.js
12
serve.js
|
|
@ -1,5 +1,6 @@
|
||||||
// Simple local dev server — serves frontend on http://localhost:3000
|
// 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 http = require("http");
|
||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
|
|
@ -7,6 +8,7 @@ const path = require("path");
|
||||||
|
|
||||||
const PORT = 3000;
|
const PORT = 3000;
|
||||||
const DIR = __dirname;
|
const DIR = __dirname;
|
||||||
|
const LOCAL_MODE = process.argv.includes("--local");
|
||||||
|
|
||||||
const MIME = {
|
const MIME = {
|
||||||
".html": "text/html",
|
".html": "text/html",
|
||||||
|
|
@ -22,6 +24,11 @@ http.createServer((req, res) => {
|
||||||
let url = req.url.split("?")[0];
|
let url = req.url.split("?")[0];
|
||||||
if (url === "/") url = "/login.html";
|
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 filePath = path.join(DIR, url);
|
||||||
const ext = path.extname(filePath);
|
const ext = path.extname(filePath);
|
||||||
|
|
||||||
|
|
@ -35,5 +42,6 @@ http.createServer((req, res) => {
|
||||||
res.end(data);
|
res.end(data);
|
||||||
});
|
});
|
||||||
}).listen(PORT, () => {
|
}).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`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue