- Edit invoice modal reuses create form, pre-fills all fields including line items, locks invoice number and PO linkage - Delete from detail view with confirmation dialog - Multi-select checkboxes with bulk delete and confirmation modal listing all invoices to be removed - Paid and EDI-submitted invoices cannot be selected for deletion - "Modified After Send" orange badge for post-EDI-send edits - PO remaining balance correctly accounts for current invoice during edit - Edit button added to invoice table row actions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2503 lines
102 KiB
HTML
2503 lines
102 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>LedgerFlow — B2B Accounting</title>
|
||
<!-- Config (API URL + Client ID) -->
|
||
<script src="config.js"></script>
|
||
<link href="https://fonts.googleapis.com/css2?family=DM+Mono:wght@300;400;500&family=Syne:wght@400;600;700;800&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--bg: #0a0c0f;
|
||
--surface: #111318;
|
||
--surface2: #181c24;
|
||
--border: #1f2533;
|
||
--border2: #2a3348;
|
||
--accent: #00e5a0;
|
||
--accent2: #0066ff;
|
||
--accent3: #ff6b35;
|
||
--text: #e8edf5;
|
||
--text2: #8892a4;
|
||
--text3: #4a5568;
|
||
--red: #ff4757;
|
||
--yellow: #ffd32a;
|
||
--green: #00e5a0;
|
||
--mono: 'DM Mono', monospace;
|
||
--sans: 'Syne', sans-serif;
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
body {
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: var(--mono);
|
||
font-size: 13px;
|
||
min-height: 100vh;
|
||
display: flex;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* SIDEBAR */
|
||
.sidebar {
|
||
width: 220px;
|
||
min-width: 220px;
|
||
background: var(--surface);
|
||
border-right: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 0;
|
||
position: relative;
|
||
z-index: 10;
|
||
}
|
||
|
||
.logo {
|
||
padding: 24px 20px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.logo-mark {
|
||
font-family: var(--sans);
|
||
font-weight: 800;
|
||
font-size: 18px;
|
||
color: var(--accent);
|
||
letter-spacing: -0.5px;
|
||
}
|
||
.logo-sub {
|
||
font-size: 10px;
|
||
color: var(--text3);
|
||
letter-spacing: 2px;
|
||
text-transform: uppercase;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.nav {
|
||
flex: 1;
|
||
padding: 16px 0;
|
||
}
|
||
.nav-section {
|
||
padding: 8px 16px 4px;
|
||
font-size: 9px;
|
||
letter-spacing: 2px;
|
||
text-transform: uppercase;
|
||
color: var(--text3);
|
||
}
|
||
.nav-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 9px 16px;
|
||
cursor: pointer;
|
||
color: var(--text2);
|
||
font-size: 12px;
|
||
border-left: 2px solid transparent;
|
||
transition: all 0.15s;
|
||
user-select: none;
|
||
}
|
||
.nav-item:hover { background: var(--surface2); color: var(--text); }
|
||
.nav-item.active {
|
||
background: rgba(0,229,160,0.06);
|
||
color: var(--accent);
|
||
border-left-color: var(--accent);
|
||
}
|
||
.nav-icon { width: 16px; text-align: center; font-size: 14px; }
|
||
|
||
.sidebar-footer {
|
||
padding: 16px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
.connection-badge {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 8px 10px;
|
||
border-radius: 6px;
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border);
|
||
font-size: 11px;
|
||
color: var(--text2);
|
||
margin-bottom: 6px;
|
||
cursor: pointer;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.connection-badge:hover { border-color: var(--border2); }
|
||
.connection-badge .dot {
|
||
width: 6px; height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--text3);
|
||
flex-shrink: 0;
|
||
}
|
||
.connection-badge.connected .dot { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
|
||
.logout-btn {
|
||
width: 100%;
|
||
margin-top: 8px;
|
||
padding: 8px 10px;
|
||
border-radius: 6px;
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border);
|
||
color: var(--text2);
|
||
font-size: 11px;
|
||
cursor: pointer;
|
||
transition: background 0.15s, border-color 0.15s;
|
||
}
|
||
.logout-btn:hover { background: var(--danger, #e74c3c); color: #fff; border-color: var(--danger, #e74c3c); }
|
||
|
||
/* MAIN */
|
||
.main {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.topbar {
|
||
height: 56px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 24px;
|
||
gap: 16px;
|
||
background: var(--surface);
|
||
}
|
||
.topbar-title {
|
||
font-family: var(--sans);
|
||
font-weight: 700;
|
||
font-size: 16px;
|
||
color: var(--text);
|
||
flex: 1;
|
||
}
|
||
.topbar-actions { display: flex; gap: 8px; }
|
||
|
||
.btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 7px 14px;
|
||
border-radius: 6px;
|
||
border: 1px solid var(--border2);
|
||
background: var(--surface2);
|
||
color: var(--text);
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
.btn:hover { border-color: var(--accent); color: var(--accent); }
|
||
.btn.primary {
|
||
background: var(--accent);
|
||
border-color: var(--accent);
|
||
color: #000;
|
||
font-weight: 500;
|
||
}
|
||
.btn.primary:hover { background: #00ffb2; border-color: #00ffb2; color: #000; }
|
||
.btn.danger { border-color: var(--red); color: var(--red); }
|
||
.btn.danger:hover { background: rgba(255,71,87,0.1); }
|
||
|
||
.content {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 24px;
|
||
}
|
||
.content::-webkit-scrollbar { width: 4px; }
|
||
.content::-webkit-scrollbar-track { background: transparent; }
|
||
.content::-webkit-scrollbar-thumb { background: var(--border2); border-radius: 2px; }
|
||
|
||
/* VIEWS */
|
||
.view { display: none; }
|
||
.view.active { display: block; }
|
||
|
||
/* DASHBOARD */
|
||
.stats-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 16px;
|
||
margin-bottom: 24px;
|
||
}
|
||
.stat-card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
padding: 18px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.stat-card::before {
|
||
content: '';
|
||
position: absolute;
|
||
top: 0; left: 0; right: 0;
|
||
height: 2px;
|
||
background: linear-gradient(90deg, var(--accent), transparent);
|
||
}
|
||
.stat-label { font-size: 10px; letter-spacing: 2px; text-transform: uppercase; color: var(--text3); margin-bottom: 8px; }
|
||
.stat-value { font-family: var(--sans); font-size: 26px; font-weight: 700; color: var(--text); }
|
||
.stat-sub { font-size: 11px; color: var(--text2); margin-top: 4px; }
|
||
.stat-delta { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; margin-top: 6px; }
|
||
.stat-delta.up { color: var(--green); }
|
||
.stat-delta.down { color: var(--red); }
|
||
|
||
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }
|
||
|
||
.card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
}
|
||
.card-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 14px 18px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.card-title { font-family: var(--sans); font-weight: 600; font-size: 13px; color: var(--text); }
|
||
.card-body { padding: 18px; }
|
||
|
||
/* TABLE */
|
||
.table-wrap { overflow-x: auto; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
thead th {
|
||
font-size: 10px;
|
||
letter-spacing: 1.5px;
|
||
text-transform: uppercase;
|
||
color: var(--text3);
|
||
text-align: left;
|
||
padding: 10px 14px;
|
||
border-bottom: 1px solid var(--border);
|
||
white-space: nowrap;
|
||
}
|
||
tbody tr {
|
||
border-bottom: 1px solid var(--border);
|
||
transition: background 0.1s;
|
||
cursor: pointer;
|
||
}
|
||
tbody tr:last-child { border-bottom: none; }
|
||
tbody tr:hover { background: var(--surface2); }
|
||
tbody td {
|
||
padding: 11px 14px;
|
||
font-size: 12px;
|
||
color: var(--text2);
|
||
white-space: nowrap;
|
||
}
|
||
tbody td.mono { font-family: var(--mono); color: var(--text); }
|
||
tbody td.bold { color: var(--text); font-weight: 500; }
|
||
|
||
/* BADGES */
|
||
.badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 3px 8px;
|
||
border-radius: 4px;
|
||
font-size: 10px;
|
||
letter-spacing: 0.5px;
|
||
font-weight: 500;
|
||
text-transform: uppercase;
|
||
}
|
||
.badge.open { background: rgba(0,102,255,0.15); color: #5b9bff; border: 1px solid rgba(0,102,255,0.2); }
|
||
.badge.pending { background: rgba(255,211,42,0.12); color: var(--yellow); border: 1px solid rgba(255,211,42,0.2); }
|
||
.badge.submitted { background: rgba(0,229,160,0.1); color: var(--accent); border: 1px solid rgba(0,229,160,0.2); }
|
||
.badge.modified { background: rgba(255,140,50,0.12); color: #ff8c32; border: 1px solid rgba(255,140,50,0.25); }
|
||
.badge.paid { background: rgba(0,229,160,0.05); color: #4a8; border: 1px solid rgba(0,229,160,0.1); }
|
||
.badge.overdue { background: rgba(255,71,87,0.12); color: var(--red); border: 1px solid rgba(255,71,87,0.2); }
|
||
.badge.partial { background: rgba(255,107,53,0.12); color: var(--accent3); border: 1px solid rgba(255,107,53,0.2); }
|
||
.badge-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; }
|
||
|
||
/* MODAL */
|
||
.modal-overlay {
|
||
display: none;
|
||
position: fixed; inset: 0;
|
||
background: rgba(0,0,0,0.7);
|
||
z-index: 1000;
|
||
align-items: center;
|
||
justify-content: center;
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
.modal-overlay.open { display: flex; }
|
||
.modal {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border2);
|
||
border-radius: 12px;
|
||
width: 580px;
|
||
max-width: 95vw;
|
||
max-height: 88vh;
|
||
overflow-y: auto;
|
||
box-shadow: 0 24px 60px rgba(0,0,0,0.6);
|
||
}
|
||
.modal-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 20px 24px;
|
||
border-bottom: 1px solid var(--border);
|
||
position: sticky; top: 0;
|
||
background: var(--surface);
|
||
z-index: 1;
|
||
}
|
||
.modal-title { font-family: var(--sans); font-weight: 700; font-size: 15px; }
|
||
.modal-close { background: none; border: none; color: var(--text2); cursor: pointer; font-size: 20px; padding: 2px; }
|
||
.modal-close:hover { color: var(--text); }
|
||
.modal-body { padding: 24px; }
|
||
.modal-footer {
|
||
padding: 16px 24px;
|
||
border-top: 1px solid var(--border);
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 10px;
|
||
background: var(--surface);
|
||
position: sticky; bottom: 0;
|
||
}
|
||
|
||
/* FORMS */
|
||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 14px; }
|
||
.form-row.full { grid-template-columns: 1fr; }
|
||
.form-row.three { grid-template-columns: 1fr 1fr 1fr; }
|
||
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
||
.form-label { font-size: 10px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--text2); }
|
||
.form-input, .form-select, .form-textarea {
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border2);
|
||
border-radius: 6px;
|
||
padding: 9px 12px;
|
||
color: var(--text);
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
outline: none;
|
||
transition: border-color 0.15s;
|
||
width: 100%;
|
||
}
|
||
.form-input:focus, .form-select:focus, .form-textarea:focus { border-color: var(--accent); }
|
||
.form-select { appearance: none; cursor: pointer; }
|
||
.form-textarea { resize: vertical; min-height: 80px; }
|
||
.form-hint { font-size: 10px; color: var(--text3); }
|
||
|
||
/* LINE ITEMS */
|
||
.line-items-table { width: 100%; border-collapse: collapse; margin-top: 6px; }
|
||
.line-items-table th {
|
||
font-size: 9px; letter-spacing: 1.5px; text-transform: uppercase;
|
||
color: var(--text3); text-align: left; padding: 6px 8px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.line-items-table td { padding: 6px 8px; }
|
||
.line-items-table td input {
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 4px;
|
||
padding: 5px 8px;
|
||
color: var(--text);
|
||
font-family: var(--mono);
|
||
font-size: 11px;
|
||
width: 100%;
|
||
outline: none;
|
||
}
|
||
.line-items-table td input:focus { border-color: var(--accent); }
|
||
.li-del { background: none; border: none; color: var(--text3); cursor: pointer; font-size: 16px; padding: 2px 6px; }
|
||
.li-del:hover { color: var(--red); }
|
||
|
||
.totals-box {
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 14px 16px;
|
||
margin-top: 14px;
|
||
}
|
||
.totals-row { display: flex; justify-content: space-between; align-items: center; padding: 4px 0; font-size: 12px; color: var(--text2); }
|
||
.totals-row.total { font-family: var(--sans); font-weight: 700; font-size: 16px; color: var(--text); padding-top: 10px; border-top: 1px solid var(--border); margin-top: 6px; }
|
||
|
||
/* SETTINGS / CONNECTIONS */
|
||
.settings-section {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
margin-bottom: 20px;
|
||
overflow: hidden;
|
||
}
|
||
.settings-section-header {
|
||
padding: 14px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.settings-section-icon { font-size: 18px; }
|
||
.settings-section-title { font-family: var(--sans); font-weight: 700; font-size: 14px; }
|
||
.settings-section-body { padding: 20px; }
|
||
.settings-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 14px; }
|
||
.test-btn-wrap { display: flex; gap: 10px; align-items: flex-end; }
|
||
|
||
/* NOTIFICATION TOAST */
|
||
#toast {
|
||
position: fixed;
|
||
bottom: 24px; right: 24px;
|
||
background: var(--surface2);
|
||
border: 1px solid var(--border2);
|
||
border-radius: 8px;
|
||
padding: 12px 18px;
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
z-index: 9999;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
|
||
transform: translateY(80px);
|
||
opacity: 0;
|
||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||
}
|
||
#toast.show { transform: translateY(0); opacity: 1; }
|
||
#toast .t-icon { font-size: 16px; }
|
||
|
||
/* PROGRESS BAR */
|
||
.progress-bar {
|
||
height: 4px;
|
||
background: var(--border);
|
||
border-radius: 2px;
|
||
overflow: hidden;
|
||
margin-top: 8px;
|
||
}
|
||
.progress-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
||
border-radius: 2px;
|
||
transition: width 0.3s;
|
||
}
|
||
|
||
/* EMPTY STATE */
|
||
.empty-state {
|
||
text-align: center;
|
||
padding: 48px 24px;
|
||
color: var(--text3);
|
||
}
|
||
.empty-state .es-icon { font-size: 36px; margin-bottom: 12px; }
|
||
.empty-state p { font-size: 13px; }
|
||
|
||
/* ACTIVITY FEED */
|
||
.activity-item {
|
||
display: flex;
|
||
gap: 12px;
|
||
padding: 10px 0;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.activity-item:last-child { border-bottom: none; }
|
||
.activity-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex-shrink: 0; margin-top: 4px; }
|
||
.activity-dot.blue { background: var(--accent2); }
|
||
.activity-dot.orange { background: var(--accent3); }
|
||
.activity-text { font-size: 12px; color: var(--text2); line-height: 1.5; }
|
||
.activity-time { font-size: 10px; color: var(--text3); margin-top: 2px; }
|
||
|
||
/* EDI STATUS */
|
||
.edi-log {
|
||
background: #070a0e;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 14px;
|
||
font-size: 11px;
|
||
line-height: 1.8;
|
||
color: var(--text2);
|
||
max-height: 200px;
|
||
overflow-y: auto;
|
||
font-family: var(--mono);
|
||
}
|
||
.edi-log span.ok { color: var(--accent); }
|
||
.edi-log span.err { color: var(--red); }
|
||
.edi-log span.info { color: var(--accent2); }
|
||
.edi-log span.warn { color: var(--yellow); }
|
||
|
||
.po-match-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
background: var(--surface2);
|
||
border-radius: 6px;
|
||
border: 1px solid var(--border);
|
||
margin-bottom: 8px;
|
||
cursor: pointer;
|
||
transition: border-color 0.15s;
|
||
}
|
||
.po-match-bar:hover { border-color: var(--accent); }
|
||
.po-match-bar.selected { border-color: var(--accent); background: rgba(0,229,160,0.06); }
|
||
.po-match-check { width: 16px; height: 16px; border-radius: 4px; border: 1.5px solid var(--border2); display: flex; align-items: center; justify-content: center; color: var(--accent); font-size: 12px; }
|
||
.po-match-bar.selected .po-match-check { background: var(--accent); border-color: var(--accent); color: #000; }
|
||
|
||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||
.pulsing { animation: pulse 2s infinite; }
|
||
|
||
@keyframes fadeIn { from { opacity:0; transform: translateY(6px); } to { opacity:1; transform: none; } }
|
||
.view.active { animation: fadeIn 0.2s ease; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- SIDEBAR -->
|
||
<aside class="sidebar">
|
||
<div class="logo">
|
||
<div class="logo-mark">LedgerFlow</div>
|
||
<div class="logo-sub">B2B Accounting</div>
|
||
</div>
|
||
<nav class="nav">
|
||
<div class="nav-section">Overview</div>
|
||
<div class="nav-item active" onclick="navigate('dashboard', this)">
|
||
<span class="nav-icon">◈</span> Dashboard
|
||
</div>
|
||
|
||
<div class="nav-section">Procurement</div>
|
||
<div class="nav-item" onclick="navigate('pos', this)">
|
||
<span class="nav-icon">📦</span> Purchase Orders
|
||
</div>
|
||
<div class="nav-item" onclick="navigate('invoices', this)">
|
||
<span class="nav-icon">🧾</span> Invoices
|
||
</div>
|
||
<div class="nav-item" onclick="navigate('billing', this)">
|
||
<span class="nav-icon">💳</span> Billing
|
||
</div>
|
||
|
||
<div class="nav-section">Integration</div>
|
||
<div class="nav-item" onclick="navigate('edi', this)">
|
||
<span class="nav-icon">⚡</span> EDI Console
|
||
</div>
|
||
<div class="nav-item" onclick="navigate('settings', this)">
|
||
<span class="nav-icon">⚙</span> Connections
|
||
</div>
|
||
</nav>
|
||
<div class="sidebar-footer">
|
||
<div class="connection-badge" id="dynamo-badge" onclick="navigate('settings', document.querySelector('[onclick*=settings]'))">
|
||
<div class="dot" id="dynamo-dot"></div>
|
||
<span id="dynamo-label">DynamoDB</span>
|
||
</div>
|
||
<div class="connection-badge" id="edi-badge" onclick="navigate('settings', document.querySelector('[onclick*=settings]'))">
|
||
<div class="dot" id="edi-dot"></div>
|
||
<span id="edi-label">AWS B2B EDI</span>
|
||
</div>
|
||
<button class="logout-btn" onclick="logout()">Sign Out</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- MAIN -->
|
||
<div class="main">
|
||
<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>
|
||
|
||
<div class="content">
|
||
|
||
<!-- DASHBOARD -->
|
||
<div class="view active" id="view-dashboard">
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-label">Open POs</div>
|
||
<div class="stat-value" id="stat-pos">0</div>
|
||
<div class="stat-sub">Total value: <span id="stat-pos-val">$0</span></div>
|
||
<div class="stat-delta up" id="stat-pos-delta">↑ 0 this month</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Pending Invoices</div>
|
||
<div class="stat-value" id="stat-inv">0</div>
|
||
<div class="stat-sub">Awaiting payment</div>
|
||
<div class="stat-delta" id="stat-inv-delta" style="color:var(--yellow)">● 0 overdue</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Billed (MTD)</div>
|
||
<div class="stat-value" id="stat-billed">$0</div>
|
||
<div class="stat-sub">Month to date</div>
|
||
<div class="stat-delta up">↑ 0%</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">EDI Transactions</div>
|
||
<div class="stat-value" id="stat-edi">0</div>
|
||
<div class="stat-sub">Submitted today</div>
|
||
<div class="stat-delta" id="stat-edi-delta" style="color:var(--accent2)">● 0 processing</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid-2">
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">Recent Purchase Orders</span>
|
||
<button class="btn" style="padding:4px 10px;font-size:11px" onclick="navigate('pos', document.querySelectorAll('.nav-item')[2])">View All</button>
|
||
</div>
|
||
<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>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">Activity Feed</span>
|
||
</div>
|
||
<div class="card-body" id="activity-feed">
|
||
<div class="empty-state" style="padding:12px">
|
||
<div class="es-icon">📋</div>
|
||
<p>Activity will appear here</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">Recent Invoices</span>
|
||
<button class="btn" style="padding:4px 10px;font-size:11px" onclick="navigate('invoices', document.querySelectorAll('.nav-item')[3])">View All</button>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>Invoice #</th><th>PO Reference</th><th>Vendor</th><th>Amount</th><th>EDI Status</th><th>Status</th></tr></thead>
|
||
<tbody id="dashboard-inv-table"><tr><td colspan="6"><div class="empty-state" style="padding:20px"><div class="es-icon">🧾</div><p>No invoices created yet</p></div></td></tr></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- PURCHASE ORDERS -->
|
||
<div class="view" id="view-pos">
|
||
<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>
|
||
</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)">
|
||
</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>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- INVOICES -->
|
||
<div class="view" id="view-invoices">
|
||
<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">Invoices</h2>
|
||
<p style="color:var(--text3);font-size:11px;margin-top:4px"><span id="inv-count">0</span> invoices · Submit via AWS B2B EDI</p>
|
||
</div>
|
||
<button class="btn primary" onclick="openModal('newInvoiceModal')">+ Create Invoice</button>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">All Invoices</span>
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<button class="btn" id="inv-bulk-delete-btn" style="display:none;padding:4px 12px;font-size:11px;color:var(--red);border-color:var(--red)" onclick="confirmBulkDelete()">Delete Selected (<span id="inv-selected-count">0</span>)</button>
|
||
<input class="form-input" style="width:200px" placeholder="🔍 Search..." oninput="filterTable('inv-table', this.value)">
|
||
</div>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table id="inv-table">
|
||
<thead><tr><th style="width:32px"><input type="checkbox" id="inv-select-all" onchange="toggleAllInvoices(this.checked)"></th><th>Invoice #</th><th>PO Reference</th><th>Vendor</th><th>Issued</th><th>Due</th><th>Amount</th><th>EDI Status</th><th>Status</th><th>Actions</th></tr></thead>
|
||
<tbody id="inv-table-body">
|
||
<tr><td colspan="10"><div class="empty-state"><div class="es-icon">🧾</div><p>No invoices yet. Create one against a PO.</p></div></td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- BILLING -->
|
||
<div class="view" id="view-billing">
|
||
<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">Billing</h2>
|
||
<p style="color:var(--text3);font-size:11px;margin-top:4px">Bill against open Purchase Orders</p>
|
||
</div>
|
||
<button class="btn primary" onclick="openModal('newInvoiceModal')">+ New Billing</button>
|
||
</div>
|
||
|
||
<div class="stats-grid" style="grid-template-columns:repeat(3,1fr)">
|
||
<div class="stat-card">
|
||
<div class="stat-label">Total Invoiced</div>
|
||
<div class="stat-value" id="bill-total">$0.00</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Collected</div>
|
||
<div class="stat-value" id="bill-paid" style="color:var(--accent)">$0.00</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-label">Outstanding</div>
|
||
<div class="stat-value" id="bill-outstanding" style="color:var(--yellow)">$0.00</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">PO Utilization</span></div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>PO Number</th><th>Vendor</th><th>PO Total</th><th>Billed</th><th>Remaining</th><th>Utilization</th></tr></thead>
|
||
<tbody id="billing-table-body">
|
||
<tr><td colspan="6"><div class="empty-state"><div class="es-icon">💳</div><p>Import POs to view billing utilization</p></div></td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- EDI CONSOLE -->
|
||
<div class="view" id="view-edi">
|
||
<div style="margin-bottom:20px">
|
||
<h2 style="font-family:var(--sans);font-weight:700;font-size:18px">EDI Console</h2>
|
||
<p style="color:var(--text3);font-size:11px;margin-top:4px">AWS B2B EDI · X12 004010 · Transaction Monitor</p>
|
||
</div>
|
||
<div class="grid-2">
|
||
<div class="card">
|
||
<div class="card-header"><span class="card-title">Submit EDI Transaction</span></div>
|
||
<div class="card-body">
|
||
<div class="form-group" style="margin-bottom:12px">
|
||
<label class="form-label">Transaction Type</label>
|
||
<select class="form-select" id="edi-tx-type">
|
||
<option value="810">810 — Invoice</option>
|
||
<option value="850">850 — Purchase Order</option>
|
||
<option value="856">856 — Advance Ship Notice</option>
|
||
<option value="855">855 — PO Acknowledgment</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:12px">
|
||
<label class="form-label">Select Invoice</label>
|
||
<select class="form-select" id="edi-invoice-select">
|
||
<option value="">— Select invoice —</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:16px">
|
||
<label class="form-label">Trading Partner ID</label>
|
||
<input class="form-input" id="edi-partner-id" placeholder="e.g. VENDOR-001">
|
||
</div>
|
||
<div style="display:flex;gap:8px">
|
||
<button class="btn" style="flex:1" onclick="downloadEDI()">⬇ Download EDI File</button>
|
||
<button class="btn primary" style="flex:1" onclick="submitEDI()">⚡ Submit to AWS B2B EDI</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-header">
|
||
<span class="card-title">Transaction Log</span>
|
||
<button class="btn" style="padding:4px 10px;font-size:11px" onclick="clearEDILog()">Clear</button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="edi-log" id="edi-log">
|
||
<span class="info">[SYSTEM]</span> EDI console ready. Configure AWS connection in Settings.<br>
|
||
<span class="info">[INFO]</span> X12 004010 · Supported: 810, 850, 855, 856 transaction sets.<br>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="card" style="margin-top:16px">
|
||
<div class="card-header"><span class="card-title">EDI Transaction History</span></div>
|
||
<div class="table-wrap">
|
||
<table>
|
||
<thead><tr><th>Timestamp</th><th>TX Type</th><th>Invoice #</th><th>Partner</th><th>ISA Control #</th><th>Status</th></tr></thead>
|
||
<tbody id="edi-history-body">
|
||
<tr><td colspan="6"><div class="empty-state" style="padding:20px"><div class="es-icon">⚡</div><p>No EDI transactions yet</p></div></td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SETTINGS / CONNECTIONS -->
|
||
<div class="view" id="view-settings">
|
||
<div style="margin-bottom:20px">
|
||
<h2 style="font-family:var(--sans);font-weight:700;font-size:18px">Connections & Settings</h2>
|
||
<p style="color:var(--text3);font-size:11px;margin-top:4px">Configure AWS DynamoDB and B2B EDI integrations</p>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-header">
|
||
<span class="settings-section-icon">🗄</span>
|
||
<div>
|
||
<div class="settings-section-title">AWS DynamoDB</div>
|
||
<div style="font-size:11px;color:var(--text3)">Import Purchase Orders from your DynamoDB table</div>
|
||
</div>
|
||
<div style="margin-left:auto" id="dynamo-status-badge"><span class="badge pending"><span class="badge-dot"></span>Not Connected</span></div>
|
||
</div>
|
||
<div class="settings-section-body">
|
||
<div class="settings-row">
|
||
<div class="form-group">
|
||
<label class="form-label">AWS Region</label>
|
||
<select class="form-select" id="cfg-dynamo-region">
|
||
<option>us-east-1</option><option>us-east-2</option><option>us-west-1</option><option>us-west-2</option>
|
||
<option>eu-west-1</option><option>eu-west-2</option><option>eu-central-1</option>
|
||
<option>ap-southeast-1</option><option>ap-northeast-1</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Table Name</label>
|
||
<input class="form-input" id="cfg-dynamo-table" placeholder="e.g. PurchaseOrders">
|
||
</div>
|
||
</div>
|
||
<div class="settings-row">
|
||
<div class="form-group">
|
||
<label class="form-label">Access Key ID</label>
|
||
<input class="form-input" id="cfg-dynamo-key" placeholder="AKIA...">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Secret Access Key</label>
|
||
<input class="form-input" type="password" id="cfg-dynamo-secret" placeholder="••••••••••••••••">
|
||
</div>
|
||
</div>
|
||
<div class="settings-row">
|
||
<div class="form-group">
|
||
<label class="form-label">PO Number Field</label>
|
||
<input class="form-input" id="cfg-dynamo-po-field" placeholder="po_number" value="po_number">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Vendor Field</label>
|
||
<input class="form-input" id="cfg-dynamo-vendor-field" placeholder="vendor_name" value="vendor_name">
|
||
</div>
|
||
</div>
|
||
<div class="settings-row">
|
||
<div class="form-group">
|
||
<label class="form-label">Amount Field</label>
|
||
<input class="form-input" id="cfg-dynamo-amount-field" placeholder="total_amount" value="total_amount">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Status Field</label>
|
||
<input class="form-input" id="cfg-dynamo-status-field" placeholder="status" value="status">
|
||
</div>
|
||
</div>
|
||
<div class="test-btn-wrap">
|
||
<button class="btn" onclick="testDynamoConnection()">🔌 Test Connection</button>
|
||
<button class="btn primary" onclick="saveDynamoConfig()">Save Configuration</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-header">
|
||
<span class="settings-section-icon">⚡</span>
|
||
<div>
|
||
<div class="settings-section-title">AWS B2B EDI</div>
|
||
<div style="font-size:11px;color:var(--text3)">Submit EDI transactions (810, 850, 855, 856)</div>
|
||
</div>
|
||
<div style="margin-left:auto" id="edi-status-badge"><span class="badge pending"><span class="badge-dot"></span>Not Connected</span></div>
|
||
</div>
|
||
<div class="settings-section-body">
|
||
<div class="settings-row">
|
||
<div class="form-group">
|
||
<label class="form-label">AWS Region</label>
|
||
<select class="form-select" id="cfg-edi-region">
|
||
<option>us-east-1</option><option>us-east-2</option><option>us-west-2</option>
|
||
<option>eu-west-1</option><option>ap-southeast-1</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Partnership ID</label>
|
||
<input class="form-input" id="cfg-edi-partnership" placeholder="p-XXXXXXXXXX">
|
||
</div>
|
||
</div>
|
||
<div class="settings-row">
|
||
<div class="form-group">
|
||
<label class="form-label">Access Key ID</label>
|
||
<input class="form-input" id="cfg-edi-key" placeholder="AKIA...">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Secret Access Key</label>
|
||
<input class="form-input" type="password" id="cfg-edi-secret" placeholder="••••••••••••••••">
|
||
</div>
|
||
</div>
|
||
<div class="settings-row">
|
||
<div class="form-group">
|
||
<label class="form-label">ISA Sender ID</label>
|
||
<input class="form-input" id="cfg-edi-sender" placeholder="Your EDI Sender ID">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">ISA Receiver ID</label>
|
||
<input class="form-input" id="cfg-edi-receiver" placeholder="Partner EDI Receiver ID">
|
||
</div>
|
||
</div>
|
||
<div class="form-group" style="margin-bottom:14px">
|
||
<label class="form-label">Transformer ID (X12 810 · 004010)</label>
|
||
<input class="form-input" id="cfg-edi-transformer" placeholder="tr-XXXXXXXXXX">
|
||
<span class="form-hint">Found in AWS B2B Data Interchange console</span>
|
||
</div>
|
||
<div class="test-btn-wrap">
|
||
<button class="btn" onclick="testEDIConnection()">🔌 Test Connection</button>
|
||
<button class="btn primary" onclick="saveEDIConfig()">Save Configuration</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="settings-section">
|
||
<div class="settings-section-header">
|
||
<span class="settings-section-icon">🏢</span>
|
||
<div><div class="settings-section-title">Company Settings</div></div>
|
||
</div>
|
||
<div class="settings-section-body">
|
||
<div class="settings-row">
|
||
<div class="form-group">
|
||
<label class="form-label">Company Name</label>
|
||
<input class="form-input" id="cfg-company-name" placeholder="Acme Corp">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Tax ID / EIN</label>
|
||
<input class="form-input" id="cfg-tax-id" placeholder="XX-XXXXXXX">
|
||
</div>
|
||
</div>
|
||
<div class="settings-row">
|
||
<div class="form-group">
|
||
<label class="form-label">Default Payment Terms</label>
|
||
<select class="form-select" id="cfg-payment-terms">
|
||
<option>Net 30</option><option>Net 15</option><option>Net 45</option><option>Net 60</option><option>90 NET</option><option>Due on Receipt</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Default Currency</label>
|
||
<select class="form-select" id="cfg-currency">
|
||
<option>USD</option><option>EUR</option><option>GBP</option><option>CAD</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="settings-row">
|
||
<div class="form-group">
|
||
<label class="form-label">Invoice Number Prefix</label>
|
||
<input class="form-input" id="cfg-inv-prefix" placeholder="INV-" value="INV-">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Default Tax Rate (%)</label>
|
||
<input class="form-input" id="cfg-tax-rate" placeholder="0" value="0" type="number">
|
||
</div>
|
||
</div>
|
||
<button class="btn primary" onclick="saveCompanyConfig()">Save Settings</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div><!-- /content -->
|
||
</div><!-- /main -->
|
||
|
||
<!-- ===================== 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">
|
||
<div class="modal-header">
|
||
<span class="modal-title">Add Purchase Order</span>
|
||
<button class="modal-close" onclick="closeModal('addPOModal')">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">PO Number *</label>
|
||
<input class="form-input" id="add-po-number" placeholder="PO-001">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Vendor Name *</label>
|
||
<input class="form-input" id="add-po-vendor" placeholder="Supplier Inc.">
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">Total Amount *</label>
|
||
<input class="form-input" id="add-po-amount" type="number" placeholder="0.00">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Status</label>
|
||
<select class="form-select" id="add-po-status">
|
||
<option value="open">Open</option>
|
||
<option value="pending">Pending</option>
|
||
<option value="closed">Closed</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">Issue Date</label>
|
||
<input class="form-input" id="add-po-issue" type="date">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Due Date</label>
|
||
<input class="form-input" id="add-po-due" type="date">
|
||
</div>
|
||
</div>
|
||
<div class="form-row full">
|
||
<div class="form-group">
|
||
<label class="form-label">Description / Notes</label>
|
||
<textarea class="form-textarea" id="add-po-notes" placeholder="Optional notes..."></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn" onclick="closeModal('addPOModal')">Cancel</button>
|
||
<button class="btn primary" onclick="addManualPO()">Add PO</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Edit PO Modal -->
|
||
<div class="modal-overlay" id="editPOModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<span class="modal-title">Edit Purchase Order</span>
|
||
<button class="modal-close" onclick="closeModal('editPOModal')">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<input type="hidden" id="edit-po-id">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">PO Number *</label>
|
||
<input class="form-input" id="edit-po-number" placeholder="PO-001">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Vendor Name *</label>
|
||
<input class="form-input" id="edit-po-vendor" placeholder="Supplier Inc.">
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">Total Amount *</label>
|
||
<input class="form-input" id="edit-po-amount" type="number" placeholder="0.00">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Status</label>
|
||
<select class="form-select" id="edit-po-status">
|
||
<option value="open">Open</option>
|
||
<option value="pending">Pending</option>
|
||
<option value="closed">Closed</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">Issue Date</label>
|
||
<input class="form-input" id="edit-po-issue" type="date">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Due Date</label>
|
||
<input class="form-input" id="edit-po-due" type="date">
|
||
</div>
|
||
</div>
|
||
<div class="form-row full">
|
||
<div class="form-group">
|
||
<label class="form-label">Description / Notes</label>
|
||
<textarea class="form-textarea" id="edit-po-notes" placeholder="Optional notes..."></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn" onclick="closeModal('editPOModal')">Cancel</button>
|
||
<button class="btn primary" onclick="saveEditPO()">Save Changes</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- New / Edit Invoice Modal -->
|
||
<div class="modal-overlay" id="newInvoiceModal">
|
||
<div class="modal" style="width:660px">
|
||
<div class="modal-header">
|
||
<span class="modal-title" id="inv-modal-title">Create Invoice</span>
|
||
<button class="modal-close" onclick="closeModal('newInvoiceModal')">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">Invoice Number</label>
|
||
<input class="form-input" id="inv-number" placeholder="INV-0001">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Bill Against PO</label>
|
||
<select class="form-select" id="inv-po-ref" onchange="fillPOData(this.value)">
|
||
<option value="">— Select PO (optional) —</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">Vendor / Bill To *</label>
|
||
<input class="form-input" id="inv-vendor" placeholder="Vendor name">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Issue Date</label>
|
||
<input class="form-input" id="inv-issue" type="date">
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label class="form-label">Due Date</label>
|
||
<input class="form-input" id="inv-due" type="date">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label">Payment Terms</label>
|
||
<select class="form-select" id="inv-terms" onchange="updateDueDate()">
|
||
<option>Net 30</option><option>Net 15</option><option>Net 45</option><option>Net 60</option><option>90 NET</option><option>Due on Receipt</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Line Items -->
|
||
<div style="margin-top:6px;margin-bottom:4px;font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:var(--text3)">Line Items</div>
|
||
<table class="line-items-table" id="line-items-table">
|
||
<thead><tr><th style="width:40%">Description</th><th>Qty</th><th>Unit Price</th><th>Total</th><th></th></tr></thead>
|
||
<tbody id="line-items-body"></tbody>
|
||
</table>
|
||
<button class="btn" style="margin-top:8px;width:100%;font-size:11px" onclick="addLineItem()">+ Add Line Item</button>
|
||
|
||
<div class="totals-box" id="invoice-totals">
|
||
<div class="totals-row"><span>Subtotal</span><span id="inv-subtotal">$0.00</span></div>
|
||
<div class="totals-row"><span>Tax (<span id="inv-tax-rate-label">0</span>%)</span><span id="inv-tax">$0.00</span></div>
|
||
<div class="totals-row total"><span>Total</span><span id="inv-total">$0.00</span></div>
|
||
<div class="totals-row" id="po-remaining-row" style="display:none;border-top:1px solid var(--border);padding-top:6px;margin-top:4px">
|
||
<span>PO Remaining</span><span id="inv-po-remaining" style="font-weight:500">—</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-row full" style="margin-top:14px">
|
||
<div class="form-group">
|
||
<label class="form-label">Notes</label>
|
||
<textarea class="form-textarea" id="inv-notes" placeholder="Payment instructions, remittance info..."></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer" id="inv-modal-footer">
|
||
<button class="btn" onclick="closeModal('newInvoiceModal')">Cancel</button>
|
||
<button class="btn" onclick="saveInvoice('draft')">Save Draft</button>
|
||
<button class="btn primary" onclick="saveInvoice('pending')">Create & Queue for EDI</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- View Invoice Modal -->
|
||
<div class="modal-overlay" id="viewInvoiceModal">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<span class="modal-title" id="view-inv-title">Invoice Details</span>
|
||
<button class="modal-close" onclick="closeModal('viewInvoiceModal')">×</button>
|
||
</div>
|
||
<div class="modal-body" id="view-inv-body"></div>
|
||
<div class="modal-footer">
|
||
<button class="btn" onclick="closeModal('viewInvoiceModal')">Close</button>
|
||
<button class="btn" id="view-inv-delete-btn" style="color:var(--red)">Delete</button>
|
||
<button class="btn" id="view-inv-edit-btn">Edit</button>
|
||
<button class="btn" id="view-inv-edi-btn">⚡ Submit via EDI</button>
|
||
<button class="btn primary" id="view-inv-paid-btn">✓ Mark as Paid</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Bulk Delete Confirmation Modal -->
|
||
<div class="modal-overlay" id="bulkDeleteModal">
|
||
<div class="modal" style="width:440px">
|
||
<div class="modal-header">
|
||
<span class="modal-title">Confirm Deletion</span>
|
||
<button class="modal-close" onclick="closeModal('bulkDeleteModal')">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<p style="color:var(--text);margin-bottom:12px">Are you sure you want to delete the following <strong id="bulk-del-count">0</strong> invoice(s)? This cannot be undone.</p>
|
||
<div id="bulk-del-list" style="max-height:200px;overflow-y:auto;background:var(--surface2);border-radius:6px;padding:8px 12px;font-size:12px;color:var(--text2)"></div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn" onclick="closeModal('bulkDeleteModal')">Cancel</button>
|
||
<button class="btn" style="background:var(--red);color:#fff;border-color:var(--red)" onclick="executeBulkDelete()">Delete</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Toast -->
|
||
<div id="toast"><span class="t-icon" id="toast-icon">✓</span><span id="toast-msg"></span></div>
|
||
|
||
<script>
|
||
// ============================================================
|
||
// API LAYER
|
||
// ============================================================
|
||
const API_URL = window.LEDGERFLOW_API_URL || "";
|
||
|
||
function getToken() {
|
||
return sessionStorage.getItem("ledgerflow_token");
|
||
}
|
||
|
||
async function logout() {
|
||
const token = getToken();
|
||
if (token) {
|
||
await fetch(`${API_URL}/auth/logout`, { method: "POST", headers: { Authorization: `Bearer ${token}` } }).catch(() => {});
|
||
}
|
||
sessionStorage.clear();
|
||
window.location.href = "login.html";
|
||
}
|
||
|
||
async function api(path, opts = {}) {
|
||
const token = getToken();
|
||
if (!token) { toast('Not authenticated — please log in', 'err'); throw new Error("Not authenticated"); }
|
||
const res = await fetch(`${API_URL}${path}`, {
|
||
...opts,
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${token}`,
|
||
...(opts.headers || {}),
|
||
},
|
||
body: opts.body ? (typeof opts.body === "string" ? opts.body : JSON.stringify(opts.body)) : undefined,
|
||
});
|
||
if (res.status === 401) { toast('Session expired — please log in again', 'err'); return null; }
|
||
if (res.status === 204) return null;
|
||
const data = await res.json();
|
||
if (!res.ok) { toast(data.message || data.error || 'API error', 'err'); return null; }
|
||
return data;
|
||
}
|
||
|
||
// ============================================================
|
||
// STATE
|
||
// ============================================================
|
||
let state = {
|
||
pos: [],
|
||
invoices: [],
|
||
ediTransactions: [],
|
||
config: {
|
||
dynamo: { region: 'us-east-1', table: '', key: '', secret: '', fields: { poNumber:'po_number', vendor:'vendor_name', amount:'total_amount', status:'status', issueDate:'issue_date', dueDate:'due_date' }, 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 }
|
||
},
|
||
pendingImport: [],
|
||
invCounter: 1
|
||
};
|
||
|
||
// Load settings from backend
|
||
async function loadSettings() {
|
||
const data = await api('/settings');
|
||
if (data) {
|
||
Object.assign(state.config, data.config || {});
|
||
state.invCounter = data.invCounter || 1;
|
||
}
|
||
}
|
||
|
||
// Persist settings to backend (debounced to avoid rapid-fire writes)
|
||
let _saveTimer = null;
|
||
function save() {
|
||
if (!getToken()) return;
|
||
clearTimeout(_saveTimer);
|
||
_saveTimer = setTimeout(async () => {
|
||
try {
|
||
await api('/settings', { method: 'PUT', body: { config: state.config, invCounter: state.invCounter } });
|
||
} catch (e) { console.error('Failed to save settings', e); }
|
||
}, 500);
|
||
}
|
||
|
||
// ============================================================
|
||
// NAVIGATION
|
||
// ============================================================
|
||
function navigate(view, el) {
|
||
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
||
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
|
||
document.getElementById('view-' + view).classList.add('active');
|
||
if (el) el.classList.add('active');
|
||
|
||
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>',
|
||
invoices: '<button class="btn primary" onclick="openModal(\'newInvoiceModal\')">+ Create Invoice</button>',
|
||
billing: '<button class="btn primary" onclick="openModal(\'newInvoiceModal\')">+ New Billing</button>',
|
||
edi: '',
|
||
settings: ''
|
||
};
|
||
document.getElementById('page-title').textContent = titles[view] || view;
|
||
document.getElementById('topbar-actions').innerHTML = actions[view] || '';
|
||
|
||
if (view === 'settings') loadSettingsForm();
|
||
if (view === 'edi') refreshEDIInvoiceSelect();
|
||
if (view === 'billing') refreshBillingTable();
|
||
refreshDashboard();
|
||
}
|
||
|
||
// ============================================================
|
||
// MODALS
|
||
// ============================================================
|
||
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 => {
|
||
o.addEventListener('click', e => { if (e.target === o) o.classList.remove('open'); });
|
||
});
|
||
|
||
// ============================================================
|
||
// TOAST
|
||
// ============================================================
|
||
let toastTimer;
|
||
function toast(msg, type = 'ok') {
|
||
const el = document.getElementById('toast');
|
||
const icons = { ok:'✓', err:'✗', info:'ℹ', warn:'⚠' };
|
||
const colors = { ok:'var(--accent)', err:'var(--red)', info:'var(--accent2)', warn:'var(--yellow)' };
|
||
document.getElementById('toast-icon').textContent = icons[type];
|
||
document.getElementById('toast-icon').style.color = colors[type];
|
||
document.getElementById('toast-msg').textContent = msg;
|
||
el.classList.add('show');
|
||
clearTimeout(toastTimer);
|
||
toastTimer = setTimeout(() => el.classList.remove('show'), 3200);
|
||
}
|
||
|
||
// ============================================================
|
||
// SETTINGS
|
||
// ============================================================
|
||
function loadSettingsForm() {
|
||
const c = state.config;
|
||
// DynamoDB
|
||
if (c.dynamo.region) document.getElementById('cfg-dynamo-region').value = c.dynamo.region;
|
||
if (c.dynamo.table) document.getElementById('cfg-dynamo-table').value = c.dynamo.table;
|
||
if (c.dynamo.key) document.getElementById('cfg-dynamo-key').value = c.dynamo.key;
|
||
if (c.dynamo.secret) document.getElementById('cfg-dynamo-secret').value = c.dynamo.secret;
|
||
if (c.dynamo.fields) {
|
||
if (c.dynamo.fields.poNumber) document.getElementById('cfg-dynamo-po-field').value = c.dynamo.fields.poNumber;
|
||
if (c.dynamo.fields.vendor) document.getElementById('cfg-dynamo-vendor-field').value = c.dynamo.fields.vendor;
|
||
if (c.dynamo.fields.amount) document.getElementById('cfg-dynamo-amount-field').value = c.dynamo.fields.amount;
|
||
if (c.dynamo.fields.status) document.getElementById('cfg-dynamo-status-field').value = c.dynamo.fields.status;
|
||
}
|
||
// EDI
|
||
if (c.edi.region) document.getElementById('cfg-edi-region').value = c.edi.region;
|
||
if (c.edi.partnershipId) document.getElementById('cfg-edi-partnership').value = c.edi.partnershipId;
|
||
if (c.edi.key) document.getElementById('cfg-edi-key').value = c.edi.key;
|
||
if (c.edi.secret) document.getElementById('cfg-edi-secret').value = c.edi.secret;
|
||
if (c.edi.senderId) document.getElementById('cfg-edi-sender').value = c.edi.senderId;
|
||
if (c.edi.receiverId) document.getElementById('cfg-edi-receiver').value = c.edi.receiverId;
|
||
if (c.edi.transformerId) document.getElementById('cfg-edi-transformer').value = c.edi.transformerId;
|
||
// Company
|
||
if (c.company.name) document.getElementById('cfg-company-name').value = c.company.name;
|
||
if (c.company.taxId) document.getElementById('cfg-tax-id').value = c.company.taxId;
|
||
if (c.company.terms) document.getElementById('cfg-payment-terms').value = c.company.terms;
|
||
if (c.company.currency) document.getElementById('cfg-currency').value = c.company.currency;
|
||
if (c.company.invPrefix) document.getElementById('cfg-inv-prefix').value = c.company.invPrefix;
|
||
if (c.company.taxRate !== undefined) document.getElementById('cfg-tax-rate').value = c.company.taxRate;
|
||
updateConnectionBadges();
|
||
}
|
||
|
||
function saveDynamoConfig() {
|
||
state.config.dynamo.region = document.getElementById('cfg-dynamo-region').value;
|
||
state.config.dynamo.table = document.getElementById('cfg-dynamo-table').value;
|
||
state.config.dynamo.key = document.getElementById('cfg-dynamo-key').value;
|
||
state.config.dynamo.secret = document.getElementById('cfg-dynamo-secret').value;
|
||
state.config.dynamo.fields.poNumber = document.getElementById('cfg-dynamo-po-field').value;
|
||
state.config.dynamo.fields.vendor = document.getElementById('cfg-dynamo-vendor-field').value;
|
||
state.config.dynamo.fields.amount = document.getElementById('cfg-dynamo-amount-field').value;
|
||
state.config.dynamo.fields.status = document.getElementById('cfg-dynamo-status-field').value;
|
||
save(); toast('DynamoDB configuration saved', 'ok');
|
||
}
|
||
|
||
function saveEDIConfig() {
|
||
state.config.edi.region = document.getElementById('cfg-edi-region').value;
|
||
state.config.edi.partnershipId = document.getElementById('cfg-edi-partnership').value;
|
||
state.config.edi.key = document.getElementById('cfg-edi-key').value;
|
||
state.config.edi.secret = document.getElementById('cfg-edi-secret').value;
|
||
state.config.edi.senderId = document.getElementById('cfg-edi-sender').value;
|
||
state.config.edi.receiverId = document.getElementById('cfg-edi-receiver').value;
|
||
state.config.edi.transformerId = document.getElementById('cfg-edi-transformer').value;
|
||
save(); toast('EDI configuration saved', 'ok');
|
||
}
|
||
|
||
function saveCompanyConfig() {
|
||
state.config.company.name = document.getElementById('cfg-company-name').value;
|
||
state.config.company.taxId = document.getElementById('cfg-tax-id').value;
|
||
state.config.company.terms = document.getElementById('cfg-payment-terms').value;
|
||
state.config.company.currency = document.getElementById('cfg-currency').value;
|
||
state.config.company.invPrefix = document.getElementById('cfg-inv-prefix').value;
|
||
state.config.company.taxRate = parseFloat(document.getElementById('cfg-tax-rate').value) || 0;
|
||
save(); toast('Company settings saved', 'ok');
|
||
}
|
||
|
||
function testDynamoConnection() {
|
||
const region = document.getElementById('cfg-dynamo-region').value;
|
||
const table = document.getElementById('cfg-dynamo-table').value;
|
||
const key = document.getElementById('cfg-dynamo-key').value;
|
||
if (!table || !key) { toast('Please fill in Region, Table Name, and Access Key', 'warn'); return; }
|
||
toast('Testing DynamoDB connection...', 'info');
|
||
setTimeout(() => {
|
||
// In production: call AWS SDK / API Gateway endpoint
|
||
// Marking as connected for demo purposes if credentials are provided
|
||
state.config.dynamo.connected = true;
|
||
document.getElementById('dynamo-status-badge').innerHTML = '<span class="badge submitted"><span class="badge-dot"></span>Connected</span>';
|
||
updateConnectionBadges();
|
||
save();
|
||
toast(`Connected to DynamoDB (${region}/${table})`, 'ok');
|
||
}, 1200);
|
||
}
|
||
|
||
function testEDIConnection() {
|
||
const partnershipId = document.getElementById('cfg-edi-partnership').value;
|
||
const key = document.getElementById('cfg-edi-key').value;
|
||
if (!partnershipId || !key) { toast('Please fill in Partnership ID and Access Key', 'warn'); return; }
|
||
toast('Testing EDI connection...', 'info');
|
||
setTimeout(() => {
|
||
state.config.edi.connected = true;
|
||
document.getElementById('edi-status-badge').innerHTML = '<span class="badge submitted"><span class="badge-dot"></span>Connected</span>';
|
||
updateConnectionBadges();
|
||
save();
|
||
toast('Connected to AWS B2B EDI', 'ok');
|
||
}, 1400);
|
||
}
|
||
|
||
function updateConnectionBadges() {
|
||
const dd = document.getElementById('dynamo-dot');
|
||
const dl = document.getElementById('dynamo-label');
|
||
const ed = document.getElementById('edi-dot');
|
||
const el = document.getElementById('edi-label');
|
||
const db = document.getElementById('dynamo-badge');
|
||
const eb = document.getElementById('edi-badge');
|
||
|
||
if (state.config.dynamo.connected) {
|
||
dd.style.background = 'var(--accent)';
|
||
dd.style.boxShadow = '0 0 6px var(--accent)';
|
||
dl.textContent = state.config.dynamo.table || 'DynamoDB';
|
||
db.classList.add('connected');
|
||
}
|
||
if (state.config.edi.connected) {
|
||
ed.style.background = 'var(--accent)';
|
||
ed.style.boxShadow = '0 0 6px var(--accent)';
|
||
el.textContent = 'EDI Connected';
|
||
eb.classList.add('connected');
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// 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();
|
||
const vendor = document.getElementById('add-po-vendor').value.trim();
|
||
const amount = parseFloat(document.getElementById('add-po-amount').value) || 0;
|
||
if (!poNumber || !vendor) { toast('PO Number and Vendor are required', 'warn'); return; }
|
||
|
||
const data = await api('/pos', {
|
||
method: 'POST',
|
||
body: {
|
||
poNumber, vendor, amount,
|
||
status: document.getElementById('add-po-status').value,
|
||
issueDate: document.getElementById('add-po-issue').value,
|
||
dueDate: document.getElementById('add-po-due').value,
|
||
notes: document.getElementById('add-po-notes').value,
|
||
},
|
||
});
|
||
if (!data) return;
|
||
|
||
state.pos.push(data);
|
||
closeModal('addPOModal');
|
||
refreshPOTable(); refreshDashboard();
|
||
addActivity(`Added manual PO ${poNumber}`, 'blue');
|
||
toast(`PO ${poNumber} added`, 'ok');
|
||
['add-po-number','add-po-vendor','add-po-amount','add-po-notes'].forEach(id => document.getElementById(id).value = '');
|
||
}
|
||
|
||
function openEditPO(poId) {
|
||
const po = state.pos.find(p => p.id === poId);
|
||
if (!po) return;
|
||
document.getElementById('edit-po-id').value = po.id;
|
||
document.getElementById('edit-po-number').value = po.poNumber || '';
|
||
document.getElementById('edit-po-vendor').value = po.vendor || '';
|
||
document.getElementById('edit-po-amount').value = po.amount || '';
|
||
document.getElementById('edit-po-status').value = po.status || 'open';
|
||
document.getElementById('edit-po-issue').value = po.issueDate || '';
|
||
document.getElementById('edit-po-due').value = po.dueDate || '';
|
||
document.getElementById('edit-po-notes').value = po.notes || '';
|
||
openModal('editPOModal');
|
||
}
|
||
|
||
async function saveEditPO() {
|
||
const id = document.getElementById('edit-po-id').value;
|
||
const poNumber = document.getElementById('edit-po-number').value.trim();
|
||
const vendor = document.getElementById('edit-po-vendor').value.trim();
|
||
if (!poNumber || !vendor) { toast('PO Number and Vendor are required', 'warn'); return; }
|
||
|
||
const body = {
|
||
poNumber, vendor,
|
||
amount: parseFloat(document.getElementById('edit-po-amount').value) || 0,
|
||
status: document.getElementById('edit-po-status').value,
|
||
issueDate: document.getElementById('edit-po-issue').value,
|
||
dueDate: document.getElementById('edit-po-due').value,
|
||
notes: document.getElementById('edit-po-notes').value,
|
||
};
|
||
|
||
const data = await api(`/pos/${id}`, { method: 'PUT', body });
|
||
if (!data) return;
|
||
|
||
const idx = state.pos.findIndex(p => p.id === id);
|
||
if (idx !== -1) state.pos[idx] = data;
|
||
closeModal('editPOModal');
|
||
refreshPOTable(); refreshDashboard();
|
||
toast(`PO ${poNumber} updated`, 'ok');
|
||
}
|
||
|
||
function refreshPOTable() {
|
||
const tbody = document.getElementById('po-table-body');
|
||
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>';
|
||
return;
|
||
}
|
||
tbody.innerHTML = state.pos.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>
|
||
<td>${po.issueDate || '—'}</td>
|
||
<td>${po.dueDate || '—'}</td>
|
||
<td class="mono">${fmt(po.amount)}</td>
|
||
<td class="mono" style="color:var(--accent3)">${fmt(po.billed||0)}</td>
|
||
<td class="mono" style="color:var(--accent)">${fmt(remaining)}</td>
|
||
<td><span class="badge ${po.status}"><span class="badge-dot"></span>${po.status}</span></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>`;
|
||
}).join('');
|
||
}
|
||
|
||
function billAgainstPO(poId) {
|
||
const po = state.pos.find(p => p.id === poId);
|
||
if (!po) return;
|
||
openModal('newInvoiceModal');
|
||
setTimeout(() => {
|
||
document.getElementById('inv-po-ref').value = po.id;
|
||
fillPOData(po.id);
|
||
}, 100);
|
||
}
|
||
|
||
// ============================================================
|
||
// INVOICES
|
||
// ============================================================
|
||
function termsToDays(terms) {
|
||
if (!terms) return 30;
|
||
const m = terms.match(/(\d+)/);
|
||
if (m) return parseInt(m[1], 10);
|
||
if (terms.toLowerCase().includes('receipt')) return 0;
|
||
return 30;
|
||
}
|
||
|
||
function updateDueDate() {
|
||
const terms = document.getElementById('inv-terms').value;
|
||
const issueDate = document.getElementById('inv-issue').value;
|
||
const base = issueDate ? new Date(issueDate + 'T00:00:00') : new Date();
|
||
base.setDate(base.getDate() + termsToDays(terms));
|
||
document.getElementById('inv-due').value = base.toISOString().split('T')[0];
|
||
}
|
||
|
||
function initNewInvoiceModal() {
|
||
editingInvoiceId = null;
|
||
|
||
// Reset modal to create mode
|
||
document.getElementById('inv-modal-title').textContent = 'Create Invoice';
|
||
document.getElementById('inv-modal-footer').innerHTML = `
|
||
<button class="btn" onclick="closeModal('newInvoiceModal')">Cancel</button>
|
||
<button class="btn" onclick="saveInvoice('draft')">Save Draft</button>
|
||
<button class="btn primary" onclick="saveInvoice('pending')">Create & Queue for EDI</button>
|
||
`;
|
||
|
||
document.getElementById('inv-number').disabled = false;
|
||
document.getElementById('inv-po-ref').disabled = false;
|
||
|
||
const prefix = state.config.company.invPrefix || 'INV-';
|
||
document.getElementById('inv-number').value = prefix + String(state.invCounter).padStart(4, '0');
|
||
document.getElementById('inv-issue').value = today();
|
||
document.getElementById('inv-notes').value = '';
|
||
updateDueDate();
|
||
document.getElementById('inv-tax-rate-label').textContent = state.config.company.taxRate || 0;
|
||
|
||
const sel = document.getElementById('inv-po-ref');
|
||
sel.innerHTML = '<option value="">— Select PO (optional) —</option>';
|
||
state.pos.filter(p => p.status !== 'closed' && p.status !== 'cancelled').forEach(po => {
|
||
const opt = document.createElement('option');
|
||
opt.value = po.id;
|
||
opt.textContent = `${po.poNumber} — ${po.vendor} (${fmt(po.amount - (po.billed||0))} remaining)`;
|
||
sel.appendChild(opt);
|
||
});
|
||
|
||
// Reset line items
|
||
document.getElementById('line-items-body').innerHTML = '';
|
||
addLineItem();
|
||
calcTotals();
|
||
}
|
||
|
||
function fillPOData(poId) {
|
||
const row = document.getElementById('po-remaining-row');
|
||
if (!poId) { row.style.display = 'none'; return; }
|
||
const po = state.pos.find(p => p.id === poId);
|
||
if (!po) { row.style.display = 'none'; return; }
|
||
document.getElementById('inv-vendor').value = po.vendor;
|
||
row.style.display = '';
|
||
calcTotals();
|
||
}
|
||
|
||
let lineItemCounter = 0;
|
||
function addLineItem() {
|
||
lineItemCounter++;
|
||
const id = 'li-' + lineItemCounter;
|
||
const row = document.createElement('tr');
|
||
row.id = id;
|
||
row.innerHTML = `
|
||
<td><input placeholder="Description" oninput="calcTotals()"></td>
|
||
<td style="width:70px"><input type="number" value="1" min="0" oninput="calcTotals()" style="width:60px"></td>
|
||
<td style="width:100px"><input type="number" value="0" min="0" step="0.01" oninput="calcTotals()" style="width:90px"></td>
|
||
<td style="width:90px;color:var(--text);padding:6px 8px" class="li-total">$0.00</td>
|
||
<td><button class="li-del" onclick="removeLineItem('${id}')">×</button></td>`;
|
||
document.getElementById('line-items-body').appendChild(row);
|
||
}
|
||
|
||
function removeLineItem(id) {
|
||
document.getElementById(id)?.remove();
|
||
calcTotals();
|
||
}
|
||
|
||
function calcTotals() {
|
||
let subtotal = 0;
|
||
document.querySelectorAll('#line-items-body tr').forEach(row => {
|
||
const inputs = row.querySelectorAll('input');
|
||
const qty = parseFloat(inputs[1]?.value) || 0;
|
||
const price = parseFloat(inputs[2]?.value) || 0;
|
||
const total = qty * price;
|
||
row.querySelector('.li-total').textContent = fmt(total);
|
||
subtotal += total;
|
||
});
|
||
const taxRate = state.config.company.taxRate || 0;
|
||
const tax = subtotal * (taxRate / 100);
|
||
const total = subtotal + tax;
|
||
document.getElementById('inv-subtotal').textContent = fmt(subtotal);
|
||
document.getElementById('inv-tax').textContent = fmt(tax);
|
||
document.getElementById('inv-total').textContent = fmt(total);
|
||
|
||
// Update PO remaining if a PO is selected
|
||
const poId = document.getElementById('inv-po-ref')?.value;
|
||
const remainEl = document.getElementById('inv-po-remaining');
|
||
if (poId) {
|
||
const po = state.pos.find(p => p.id === poId);
|
||
if (po) {
|
||
// When editing, the PO's billed amount already includes this invoice — add it back
|
||
const editingInv = editingInvoiceId ? state.invoices.find(i => i.id === editingInvoiceId) : null;
|
||
const editingInvTotal = editingInv ? parseFloat(editingInv.total || 0) : 0;
|
||
const poRemaining = parseFloat(po.amount || 0) - parseFloat(po.billed || 0) + editingInvTotal - total;
|
||
remainEl.textContent = fmt(poRemaining);
|
||
remainEl.style.color = poRemaining < 0 ? 'var(--red, #e74c3c)' : 'var(--accent)';
|
||
}
|
||
}
|
||
return { subtotal, tax, total };
|
||
}
|
||
|
||
function getLineItems() {
|
||
const items = [];
|
||
document.querySelectorAll('#line-items-body tr').forEach(row => {
|
||
const inputs = row.querySelectorAll('input');
|
||
const desc = inputs[0]?.value;
|
||
const qty = parseFloat(inputs[1]?.value) || 0;
|
||
const price = parseFloat(inputs[2]?.value) || 0;
|
||
if (desc || qty) items.push({ description: desc, qty, unitPrice: price, total: qty * price });
|
||
});
|
||
return items;
|
||
}
|
||
|
||
async function saveInvoice(status) {
|
||
const number = document.getElementById('inv-number').value.trim();
|
||
const vendor = document.getElementById('inv-vendor').value.trim();
|
||
if (!number || !vendor) { toast('Invoice number and vendor are required', 'warn'); return; }
|
||
|
||
const totals = calcTotals();
|
||
const poRef = document.getElementById('inv-po-ref').value;
|
||
const poObj = state.pos.find(p => p.id === poRef);
|
||
|
||
const body = {
|
||
number,
|
||
vendor,
|
||
poId: poRef || undefined,
|
||
poNumber: poObj?.poNumber || undefined,
|
||
issueDate: document.getElementById('inv-issue').value,
|
||
dueDate: document.getElementById('inv-due').value,
|
||
terms: document.getElementById('inv-terms').value,
|
||
lineItems: getLineItems(),
|
||
taxRate: state.config.company.taxRate || 0,
|
||
notes: document.getElementById('inv-notes').value,
|
||
status,
|
||
};
|
||
|
||
const data = await api('/invoices', { method: 'POST', body });
|
||
if (!data) return;
|
||
|
||
state.invoices.push(data);
|
||
state.invCounter++;
|
||
save();
|
||
|
||
// Refresh POs from backend (billed amount updated server-side)
|
||
if (poRef) await loadPOs();
|
||
|
||
closeModal('newInvoiceModal');
|
||
refreshInvoiceTable();
|
||
refreshPOTable();
|
||
refreshDashboard();
|
||
addActivity(`Invoice ${number} created (${fmt(data.total)})`, 'blue');
|
||
toast(`Invoice ${number} created`, 'ok');
|
||
}
|
||
|
||
function refreshInvoiceTable() {
|
||
const tbody = document.getElementById('inv-table-body');
|
||
document.getElementById('inv-count').textContent = state.invoices.length;
|
||
document.getElementById('inv-select-all').checked = false;
|
||
document.getElementById('inv-bulk-delete-btn').style.display = 'none';
|
||
if (!state.invoices.length) {
|
||
tbody.innerHTML = '<tr><td colspan="10"><div class="empty-state"><div class="es-icon">🧾</div><p>No invoices yet</p></div></td></tr>';
|
||
return;
|
||
}
|
||
const canSelect = inv => inv.status !== 'paid' && inv.ediStatus !== 'submitted';
|
||
tbody.innerHTML = state.invoices.map(inv => `
|
||
<tr onclick="viewInvoice('${inv.id}')">
|
||
<td onclick="event.stopPropagation()">
|
||
${canSelect(inv) ? `<input type="checkbox" class="inv-select-cb" data-id="${inv.id}" onchange="updateBulkDeleteBtn()">` : ''}
|
||
</td>
|
||
<td class="mono">${inv.number}</td>
|
||
<td class="mono">${inv.poNumber || '—'}</td>
|
||
<td class="bold">${inv.vendor}</td>
|
||
<td>${inv.issueDate || '—'}</td>
|
||
<td>${inv.dueDate || '—'}</td>
|
||
<td class="mono" style="color:var(--text)">${fmt(inv.total)}</td>
|
||
<td><span class="badge ${ediStatusClass(inv.ediStatus)}">${ediStatusLabel(inv.ediStatus)}</span></td>
|
||
<td><span class="badge ${inv.status}"><span class="badge-dot"></span>${inv.status}</span></td>
|
||
<td onclick="event.stopPropagation()">
|
||
${inv.status !== 'paid' ? `<button class="btn" style="padding:3px 9px;font-size:11px;margin-right:4px" onclick="editInvoice('${inv.id}')">Edit</button>` : ''}
|
||
<button class="btn" style="padding:3px 9px;font-size:11px;margin-right:4px" onclick="submitInvoiceEDI('${inv.id}')">⚡ EDI</button>
|
||
${inv.status !== 'paid' ? `<button class="btn" style="padding:3px 9px;font-size:11px" onclick="markPaid('${inv.id}')">✓ Paid</button>` : ''}
|
||
</td>
|
||
</tr>`).join('');
|
||
}
|
||
|
||
function viewInvoice(id) {
|
||
const inv = state.invoices.find(i => i.id === id);
|
||
if (!inv) return;
|
||
document.getElementById('view-inv-title').textContent = `Invoice ${inv.number}`;
|
||
document.getElementById('view-inv-body').innerHTML = `
|
||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:16px">
|
||
<div><div class="form-label">Vendor</div><div style="color:var(--text);margin-top:4px;font-weight:500">${inv.vendor}</div></div>
|
||
<div><div class="form-label">PO Reference</div><div style="color:var(--text);margin-top:4px">${inv.poNumber || '—'}</div></div>
|
||
<div><div class="form-label">Issue Date</div><div style="color:var(--text);margin-top:4px">${inv.issueDate}</div></div>
|
||
<div><div class="form-label">Due Date</div><div style="color:var(--text);margin-top:4px">${inv.dueDate}</div></div>
|
||
<div><div class="form-label">Status</div><div style="margin-top:4px"><span class="badge ${inv.status}">${inv.status}</span></div></div>
|
||
<div><div class="form-label">EDI Status</div><div style="margin-top:4px"><span class="badge ${ediStatusClass(inv.ediStatus)}">${ediStatusLabel(inv.ediStatus)}</span></div></div>
|
||
</div>
|
||
<table class="line-items-table" style="margin-bottom:12px">
|
||
<thead><tr><th>Description</th><th>Qty</th><th>Unit Price</th><th>Total</th></tr></thead>
|
||
<tbody>${inv.lineItems.map(li => `<tr><td style="padding:7px 8px;color:var(--text2)">${li.description}</td><td style="padding:7px 8px;color:var(--text2)">${li.qty}</td><td style="padding:7px 8px;color:var(--text2)">${fmt(li.unitPrice)}</td><td style="padding:7px 8px;color:var(--text)">${fmt(li.total)}</td></tr>`).join('')}</tbody>
|
||
</table>
|
||
<div class="totals-box">
|
||
<div class="totals-row"><span>Subtotal</span><span>${fmt(inv.subtotal)}</span></div>
|
||
<div class="totals-row"><span>Tax</span><span>${fmt(inv.tax)}</span></div>
|
||
<div class="totals-row total"><span>Total</span><span>${fmt(inv.total)}</span></div>
|
||
</div>
|
||
${inv.notes ? `<div style="margin-top:12px;padding:10px 12px;background:var(--surface2);border-radius:6px;font-size:12px;color:var(--text2)">${inv.notes}</div>` : ''}
|
||
`;
|
||
document.getElementById('view-inv-edi-btn').onclick = () => { closeModal('viewInvoiceModal'); submitInvoiceEDI(id); };
|
||
document.getElementById('view-inv-paid-btn').onclick = () => { markPaid(id); closeModal('viewInvoiceModal'); };
|
||
|
||
// Edit button — hidden for paid invoices
|
||
const editBtn = document.getElementById('view-inv-edit-btn');
|
||
editBtn.style.display = inv.status === 'paid' ? 'none' : '';
|
||
editBtn.onclick = () => { closeModal('viewInvoiceModal'); editInvoice(id); };
|
||
|
||
// Delete button — hidden for paid invoices
|
||
const deleteBtn = document.getElementById('view-inv-delete-btn');
|
||
deleteBtn.style.display = inv.status === 'paid' ? 'none' : '';
|
||
deleteBtn.onclick = () => { deleteInvoice(id); };
|
||
|
||
openModal('viewInvoiceModal');
|
||
}
|
||
|
||
let editingInvoiceId = null;
|
||
|
||
function editInvoice(id) {
|
||
const inv = state.invoices.find(i => i.id === id);
|
||
if (!inv) return;
|
||
|
||
editingInvoiceId = id;
|
||
|
||
// Set modal to edit mode
|
||
document.getElementById('inv-modal-title').textContent = `Edit Invoice ${inv.number}`;
|
||
document.getElementById('inv-modal-footer').innerHTML = `
|
||
<button class="btn" onclick="closeModal('newInvoiceModal'); editingInvoiceId = null;">Cancel</button>
|
||
<button class="btn primary" onclick="saveInvoiceEdit()">Save Changes</button>
|
||
`;
|
||
|
||
// Populate fields
|
||
document.getElementById('inv-number').value = inv.number;
|
||
document.getElementById('inv-number').disabled = true;
|
||
document.getElementById('inv-vendor').value = inv.vendor;
|
||
document.getElementById('inv-issue').value = inv.issueDate || '';
|
||
document.getElementById('inv-due').value = inv.dueDate || '';
|
||
document.getElementById('inv-terms').value = inv.terms || 'Net 30';
|
||
document.getElementById('inv-notes').value = inv.notes || '';
|
||
document.getElementById('inv-tax-rate-label').textContent = state.config.company.taxRate || 0;
|
||
|
||
// Populate PO ref
|
||
const sel = document.getElementById('inv-po-ref');
|
||
sel.innerHTML = '<option value="">— Select PO (optional) —</option>';
|
||
state.pos.filter(p => p.status !== 'closed' && p.status !== 'cancelled').forEach(po => {
|
||
const opt = document.createElement('option');
|
||
opt.value = po.id;
|
||
opt.textContent = `${po.poNumber} — ${po.vendor} (${fmt(po.amount - (po.billed||0))} remaining)`;
|
||
sel.appendChild(opt);
|
||
});
|
||
sel.value = inv.poId || '';
|
||
sel.disabled = true; // PO linkage shouldn't change on edit
|
||
|
||
// Populate line items
|
||
document.getElementById('line-items-body').innerHTML = '';
|
||
inv.lineItems.forEach(li => {
|
||
lineItemCounter++;
|
||
const rowId = 'li-' + lineItemCounter;
|
||
const row = document.createElement('tr');
|
||
row.id = rowId;
|
||
row.innerHTML = `
|
||
<td><input placeholder="Description" oninput="calcTotals()" value="${li.description}"></td>
|
||
<td style="width:70px"><input type="number" value="${li.qty}" min="0" oninput="calcTotals()" style="width:60px"></td>
|
||
<td style="width:100px"><input type="number" value="${li.unitPrice}" min="0" step="0.01" oninput="calcTotals()" style="width:90px"></td>
|
||
<td style="width:90px;color:var(--text);padding:6px 8px" class="li-total">${fmt(li.total)}</td>
|
||
<td><button class="li-del" onclick="removeLineItem('${rowId}')">×</button></td>`;
|
||
document.getElementById('line-items-body').appendChild(row);
|
||
});
|
||
|
||
if (inv.poId) {
|
||
document.getElementById('po-remaining-row').style.display = '';
|
||
} else {
|
||
document.getElementById('po-remaining-row').style.display = 'none';
|
||
}
|
||
|
||
calcTotals();
|
||
openModal('newInvoiceModal');
|
||
}
|
||
|
||
async function saveInvoiceEdit() {
|
||
if (!editingInvoiceId) return;
|
||
|
||
const vendor = document.getElementById('inv-vendor').value.trim();
|
||
if (!vendor) { toast('Vendor is required', 'warn'); return; }
|
||
|
||
const body = {
|
||
vendor,
|
||
issueDate: document.getElementById('inv-issue').value,
|
||
dueDate: document.getElementById('inv-due').value,
|
||
terms: document.getElementById('inv-terms').value,
|
||
lineItems: getLineItems(),
|
||
taxRate: state.config.company.taxRate || 0,
|
||
notes: document.getElementById('inv-notes').value,
|
||
};
|
||
|
||
const data = await api(`/invoices/${editingInvoiceId}`, { method: 'PUT', body });
|
||
if (!data) return;
|
||
|
||
// Update local state
|
||
const idx = state.invoices.findIndex(i => i.id === editingInvoiceId);
|
||
if (idx !== -1) state.invoices[idx] = data;
|
||
save();
|
||
|
||
// Refresh POs if linked
|
||
if (data.poId) await loadPOs();
|
||
|
||
closeModal('newInvoiceModal');
|
||
editingInvoiceId = null;
|
||
document.getElementById('inv-number').disabled = false;
|
||
document.getElementById('inv-po-ref').disabled = false;
|
||
|
||
refreshInvoiceTable();
|
||
refreshPOTable();
|
||
refreshDashboard();
|
||
addActivity(`Invoice ${data.number} updated`, 'blue');
|
||
toast(`Invoice ${data.number} updated`, 'ok');
|
||
}
|
||
|
||
async function deleteInvoice(id) {
|
||
const inv = state.invoices.find(i => i.id === id);
|
||
if (!inv) return;
|
||
|
||
if (!confirm(`Delete invoice ${inv.number}? This cannot be undone.`)) return;
|
||
|
||
const res = await fetch(`${API_URL}/invoices/${id}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${getToken()}` },
|
||
});
|
||
if (!res.ok && res.status !== 204) {
|
||
const err = await res.json().catch(() => ({}));
|
||
toast(err.message || err.error || 'Failed to delete invoice', 'err');
|
||
return;
|
||
}
|
||
|
||
state.invoices = state.invoices.filter(i => i.id !== id);
|
||
save();
|
||
|
||
if (inv.poId) await loadPOs();
|
||
|
||
closeModal('viewInvoiceModal');
|
||
refreshInvoiceTable();
|
||
refreshPOTable();
|
||
refreshDashboard();
|
||
addActivity(`Invoice ${inv.number} deleted`, 'red');
|
||
toast(`Invoice ${inv.number} deleted`, 'ok');
|
||
}
|
||
|
||
function toggleAllInvoices(checked) {
|
||
document.querySelectorAll('.inv-select-cb').forEach(cb => { cb.checked = checked; });
|
||
updateBulkDeleteBtn();
|
||
}
|
||
|
||
function updateBulkDeleteBtn() {
|
||
const selected = document.querySelectorAll('.inv-select-cb:checked');
|
||
const btn = document.getElementById('inv-bulk-delete-btn');
|
||
document.getElementById('inv-selected-count').textContent = selected.length;
|
||
btn.style.display = selected.length > 0 ? '' : 'none';
|
||
|
||
// Update select-all checkbox state
|
||
const all = document.querySelectorAll('.inv-select-cb');
|
||
document.getElementById('inv-select-all').checked = all.length > 0 && selected.length === all.length;
|
||
}
|
||
|
||
function confirmBulkDelete() {
|
||
const selectedIds = Array.from(document.querySelectorAll('.inv-select-cb:checked')).map(cb => cb.dataset.id);
|
||
if (!selectedIds.length) return;
|
||
|
||
const invoices = selectedIds.map(id => state.invoices.find(i => i.id === id)).filter(Boolean);
|
||
document.getElementById('bulk-del-count').textContent = invoices.length;
|
||
document.getElementById('bulk-del-list').innerHTML = invoices.map(inv =>
|
||
`<div style="padding:4px 0;border-bottom:1px solid var(--border)"><strong>${inv.number}</strong> — ${inv.vendor} (${fmt(inv.total)})</div>`
|
||
).join('');
|
||
|
||
openModal('bulkDeleteModal');
|
||
}
|
||
|
||
async function executeBulkDelete() {
|
||
const selectedIds = Array.from(document.querySelectorAll('.inv-select-cb:checked')).map(cb => cb.dataset.id);
|
||
if (!selectedIds.length) return;
|
||
|
||
closeModal('bulkDeleteModal');
|
||
let deleted = 0;
|
||
let hadPO = false;
|
||
|
||
for (const id of selectedIds) {
|
||
const inv = state.invoices.find(i => i.id === id);
|
||
if (!inv) continue;
|
||
|
||
const res = await fetch(`${API_URL}/invoices/${id}`, {
|
||
method: 'DELETE',
|
||
headers: { Authorization: `Bearer ${getToken()}` },
|
||
});
|
||
if (res.ok || res.status === 204) {
|
||
if (inv.poId) hadPO = true;
|
||
state.invoices = state.invoices.filter(i => i.id !== id);
|
||
deleted++;
|
||
}
|
||
}
|
||
|
||
if (deleted) {
|
||
save();
|
||
if (hadPO) await loadPOs();
|
||
refreshInvoiceTable();
|
||
refreshPOTable();
|
||
refreshDashboard();
|
||
addActivity(`Deleted ${deleted} invoice(s)`, 'red');
|
||
toast(`Deleted ${deleted} invoice(s)`, 'ok');
|
||
}
|
||
}
|
||
|
||
async function markPaid(id) {
|
||
const inv = state.invoices.find(i => i.id === id);
|
||
if (!inv) return;
|
||
|
||
const data = await api(`/invoices/${id}/status`, {
|
||
method: 'PATCH',
|
||
body: { status: 'paid' },
|
||
});
|
||
if (!data) return;
|
||
|
||
// Update local state with server response
|
||
Object.assign(inv, data);
|
||
refreshInvoiceTable(); refreshDashboard(); refreshBillingTable();
|
||
addActivity(`Invoice ${inv.number} marked as paid`, 'green');
|
||
toast(`Invoice ${inv.number} marked as paid`, 'ok');
|
||
}
|
||
|
||
// ============================================================
|
||
// EDI
|
||
// ============================================================
|
||
function refreshEDIInvoiceSelect() {
|
||
const sel = document.getElementById('edi-invoice-select');
|
||
sel.innerHTML = '<option value="">— Select invoice —</option>';
|
||
state.invoices.filter(i => i.status !== 'paid' && i.ediStatus !== 'submitted').forEach(inv => {
|
||
const opt = document.createElement('option');
|
||
opt.value = inv.id;
|
||
opt.textContent = `${inv.number} — ${inv.vendor} (${fmt(inv.total)})`;
|
||
sel.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
function submitInvoiceEDI(invId) {
|
||
const inv = state.invoices.find(i => i.id === invId);
|
||
if (!inv) return;
|
||
navigate('edi', document.querySelectorAll('.nav-item')[4]);
|
||
setTimeout(() => {
|
||
document.getElementById('edi-invoice-select').value = invId;
|
||
document.getElementById('edi-tx-type').value = '810';
|
||
}, 100);
|
||
}
|
||
|
||
async function downloadEDI() {
|
||
const txType = document.getElementById('edi-tx-type').value;
|
||
const invId = document.getElementById('edi-invoice-select').value;
|
||
if (!invId) { toast('Select an invoice first', 'warn'); return; }
|
||
|
||
const data = await api('/edi/preview', {
|
||
method: 'POST',
|
||
body: { invoiceId: invId, txType },
|
||
});
|
||
if (!data) return;
|
||
|
||
const blob = new Blob([data.ediDocument], { type: 'text/plain;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${data.invoiceNumber}_${txType}.txt`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
toast(`Downloaded ${data.invoiceNumber}_${txType}.txt`, 'ok');
|
||
}
|
||
|
||
async function submitEDI() {
|
||
const txType = document.getElementById('edi-tx-type').value;
|
||
const invId = document.getElementById('edi-invoice-select').value;
|
||
const partnerId = document.getElementById('edi-partner-id').value.trim() || undefined;
|
||
|
||
if (!invId) { toast('Select an invoice to submit', 'warn'); return; }
|
||
|
||
const inv = state.invoices.find(i => i.id === invId);
|
||
if (!inv) return;
|
||
|
||
const log = document.getElementById('edi-log');
|
||
const ts = new Date().toLocaleTimeString();
|
||
|
||
log.innerHTML += `<span class="info">[${ts}]</span> Submitting ${txType} transaction for ${inv.number}...<br>`;
|
||
log.scrollTop = log.scrollHeight;
|
||
|
||
const data = await api('/edi/submit', {
|
||
method: 'POST',
|
||
body: { invoiceId: invId, txType, partnerId },
|
||
});
|
||
|
||
if (!data) {
|
||
log.innerHTML += `<span class="err">[${ts}]</span> Submission failed. Check console for details.<br>`;
|
||
log.scrollTop = log.scrollHeight;
|
||
return;
|
||
}
|
||
|
||
log.innerHTML += `<span class="ok">[${ts}]</span> ISA control: ${data.isaControl}<br>`;
|
||
log.innerHTML += `<span class="ok">[${ts}]</span> ✓ Transmitted to AWS B2B EDI (Partnership: ${data.partnerId || partnerId || 'default'})<br>`;
|
||
log.scrollTop = log.scrollHeight;
|
||
|
||
// Update local invoice state
|
||
inv.ediStatus = 'submitted';
|
||
inv.ediISA = data.isaControl;
|
||
inv.ediSubmittedAt = data.submittedAt || new Date().toISOString();
|
||
|
||
// Reload EDI history from backend
|
||
await loadEDITransactions();
|
||
refreshInvoiceTable(); refreshDashboard();
|
||
refreshEDIHistory();
|
||
addActivity(`EDI ${txType} submitted for ${inv.number}`, 'orange');
|
||
toast(`${txType} submitted for ${inv.number}`, 'ok');
|
||
}
|
||
|
||
function refreshEDIHistory() {
|
||
const tbody = document.getElementById('edi-history-body');
|
||
if (!state.ediTransactions.length) {
|
||
tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state" style="padding:20px"><div class="es-icon">⚡</div><p>No EDI transactions yet</p></div></td></tr>';
|
||
return;
|
||
}
|
||
// Backend uses createdAt/partnerId; legacy local data may use timestamp/partner
|
||
tbody.innerHTML = state.ediTransactions.map(tx => {
|
||
const date = tx.createdAt || tx.timestamp;
|
||
const partner = tx.partnerId || tx.partner || '—';
|
||
const statusClass = tx.status === 'failed' ? 'overdue' : tx.status === 'processing' ? 'pending' : 'submitted';
|
||
return `<tr>
|
||
<td class="mono" style="font-size:11px">${new Date(date).toLocaleString()}</td>
|
||
<td><span class="badge open">${tx.txType}</span></td>
|
||
<td class="mono">${tx.invoiceNumber}</td>
|
||
<td>${partner}</td>
|
||
<td class="mono" style="font-size:11px">${tx.isaControl}</td>
|
||
<td><span class="badge ${statusClass}"><span class="badge-dot"></span>${tx.status}</span></td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
function clearEDILog() {
|
||
document.getElementById('edi-log').innerHTML = '<span class="info">[SYSTEM]</span> Log cleared.<br>';
|
||
}
|
||
|
||
// ============================================================
|
||
// BILLING
|
||
// ============================================================
|
||
function refreshBillingTable() {
|
||
const tbody = document.getElementById('billing-table-body');
|
||
if (!state.pos.length) {
|
||
tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><div class="es-icon">💳</div><p>Import POs to view billing utilization</p></div></td></tr>';
|
||
document.getElementById('bill-total').textContent = '$0.00';
|
||
document.getElementById('bill-paid').textContent = '$0.00';
|
||
document.getElementById('bill-outstanding').textContent = '$0.00';
|
||
return;
|
||
}
|
||
|
||
let totalInvoiced = 0, totalPaid = 0;
|
||
state.invoices.forEach(inv => {
|
||
totalInvoiced += inv.total;
|
||
if (inv.status === 'paid') totalPaid += inv.total;
|
||
});
|
||
|
||
document.getElementById('bill-total').textContent = fmt(totalInvoiced);
|
||
document.getElementById('bill-paid').textContent = fmt(totalPaid);
|
||
document.getElementById('bill-outstanding').textContent = fmt(totalInvoiced - totalPaid);
|
||
|
||
tbody.innerHTML = state.pos.map(po => {
|
||
const billed = po.billed || 0;
|
||
const remaining = Math.max(0, po.amount - billed);
|
||
const pct = po.amount > 0 ? Math.min(100, Math.round(billed / po.amount * 100)) : 0;
|
||
return `<tr>
|
||
<td class="mono">${po.poNumber}</td>
|
||
<td class="bold">${po.vendor}</td>
|
||
<td class="mono">${fmt(po.amount)}</td>
|
||
<td class="mono" style="color:var(--accent3)">${fmt(billed)}</td>
|
||
<td class="mono" style="color:var(--accent)">${fmt(remaining)}</td>
|
||
<td style="min-width:140px">
|
||
<div style="display:flex;align-items:center;gap:8px">
|
||
<div class="progress-bar" style="flex:1"><div class="progress-fill" style="width:${pct}%"></div></div>
|
||
<span style="font-size:11px;color:var(--text2);min-width:32px">${pct}%</span>
|
||
</div>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ============================================================
|
||
// DASHBOARD
|
||
// ============================================================
|
||
function refreshDashboard() {
|
||
// Stats
|
||
const openPOs = state.pos.filter(p => p.status === 'open' || p.status === 'approved');
|
||
const totalPOVal = openPOs.reduce((s, p) => s + p.amount, 0);
|
||
const pendingInv = state.invoices.filter(i => i.status === 'pending');
|
||
const overdue = state.invoices.filter(i => i.status !== 'paid' && i.dueDate && i.dueDate < today());
|
||
const billed = state.invoices.reduce((s, i) => s + i.total, 0);
|
||
const ediToday = state.ediTransactions.filter(t => t.timestamp && t.timestamp.startsWith(today())).length;
|
||
|
||
document.getElementById('stat-pos').textContent = openPOs.length;
|
||
document.getElementById('stat-pos-val').textContent = fmt(totalPOVal);
|
||
document.getElementById('stat-pos-delta').textContent = `↑ ${state.pos.length} total`;
|
||
document.getElementById('stat-inv').textContent = pendingInv.length;
|
||
document.getElementById('stat-inv-delta').textContent = `● ${overdue.length} overdue`;
|
||
document.getElementById('stat-billed').textContent = fmt(billed);
|
||
document.getElementById('stat-edi').textContent = state.ediTransactions.length;
|
||
document.getElementById('stat-edi-delta').textContent = `● ${ediToday} today`;
|
||
|
||
// Dashboard PO table
|
||
const dashPO = document.getElementById('dashboard-po-table');
|
||
if (!state.pos.length) {
|
||
dashPO.innerHTML = '<tr><td colspan="4"><div class="empty-state" style="padding:16px"><div class="es-icon">📦</div><p>No POs yet</p></div></td></tr>';
|
||
} else {
|
||
dashPO.innerHTML = state.pos.slice(-5).reverse().map(po => `
|
||
<tr onclick="navigate('pos', document.querySelectorAll('.nav-item')[2])">
|
||
<td class="mono">${po.poNumber}</td>
|
||
<td class="bold">${po.vendor}</td>
|
||
<td class="mono">${fmt(po.amount)}</td>
|
||
<td><span class="badge ${po.status}"><span class="badge-dot"></span>${po.status}</span></td>
|
||
</tr>`).join('');
|
||
}
|
||
|
||
// Dashboard invoice table
|
||
const dashInv = document.getElementById('dashboard-inv-table');
|
||
if (!state.invoices.length) {
|
||
dashInv.innerHTML = '<tr><td colspan="6"><div class="empty-state" style="padding:16px"><div class="es-icon">🧾</div><p>No invoices yet</p></div></td></tr>';
|
||
} else {
|
||
dashInv.innerHTML = state.invoices.slice(-5).reverse().map(inv => `
|
||
<tr onclick="viewInvoice('${inv.id}')">
|
||
<td class="mono">${inv.number}</td>
|
||
<td class="mono">${inv.poNumber || '—'}</td>
|
||
<td class="bold">${inv.vendor}</td>
|
||
<td class="mono">${fmt(inv.total)}</td>
|
||
<td><span class="badge ${ediStatusClass(inv.ediStatus)}">${ediStatusLabel(inv.ediStatus)}</span></td>
|
||
<td><span class="badge ${inv.status}"><span class="badge-dot"></span>${inv.status}</span></td>
|
||
</tr>`).join('');
|
||
}
|
||
}
|
||
|
||
// ============================================================
|
||
// ACTIVITY
|
||
// ============================================================
|
||
let activities = [];
|
||
function addActivity(text, color = 'green') {
|
||
activities.unshift({ text, color, time: new Date().toLocaleTimeString() });
|
||
if (activities.length > 20) activities.pop();
|
||
const feed = document.getElementById('activity-feed');
|
||
feed.innerHTML = activities.slice(0,8).map(a =>
|
||
`<div class="activity-item">
|
||
<div class="activity-dot ${a.color === 'blue' ? 'blue' : a.color === 'orange' ? 'orange' : ''}"></div>
|
||
<div><div class="activity-text">${a.text}</div><div class="activity-time">${a.time}</div></div>
|
||
</div>`
|
||
).join('') || '<div class="empty-state" style="padding:12px"><div class="es-icon">📋</div><p>No activity yet</p></div>';
|
||
}
|
||
|
||
// ============================================================
|
||
// HELPERS
|
||
// ============================================================
|
||
function fmt(n) {
|
||
return '$' + (parseFloat(n)||0).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
}
|
||
function today() { return new Date().toISOString().split('T')[0]; }
|
||
function genId() { return Math.random().toString(36).slice(2, 10); }
|
||
function ediStatusClass(s) { return s === 'submitted' ? 'submitted' : s === 'modified_after_send' ? 'modified' : s === 'processing' ? 'pending' : 'open'; }
|
||
function ediStatusLabel(s) { return s === 'submitted' ? 'Submitted' : s === 'modified_after_send' ? 'Modified After Send' : s === 'processing' ? 'Processing' : 'Not Sent'; }
|
||
|
||
function filterTable(tableId, query) {
|
||
const q = query.toLowerCase();
|
||
document.querySelectorAll(`#${tableId} tbody tr`).forEach(row => {
|
||
row.style.display = row.textContent.toLowerCase().includes(q) ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
// ============================================================
|
||
// DATA LOADERS
|
||
// ============================================================
|
||
async function loadPOs() {
|
||
const data = await api('/pos?limit=200');
|
||
if (data && data.items) state.pos = data.items;
|
||
}
|
||
|
||
async function loadInvoices() {
|
||
const data = await api('/invoices?limit=200');
|
||
if (data && data.items) state.invoices = data.items;
|
||
}
|
||
|
||
async function loadEDITransactions() {
|
||
const data = await api('/edi/transactions?limit=200');
|
||
if (data && data.items) state.ediTransactions = data.items;
|
||
}
|
||
|
||
// ============================================================
|
||
// INIT
|
||
// ============================================================
|
||
updateConnectionBadges();
|
||
|
||
(async function init() {
|
||
if (!API_URL || !getToken()) {
|
||
// No API configured or not logged in — render with empty/local state
|
||
refreshDashboard();
|
||
refreshPOTable();
|
||
refreshInvoiceTable();
|
||
refreshEDIHistory();
|
||
if (!getToken()) toast('Not logged in — data will not load from server', 'warn');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await loadSettings();
|
||
await Promise.all([loadPOs(), loadInvoices(), loadEDITransactions()]);
|
||
addActivity('Data loaded from server', 'green');
|
||
} catch (e) {
|
||
console.error('Init load error:', e);
|
||
toast('Failed to load data from server', 'err');
|
||
}
|
||
|
||
refreshDashboard();
|
||
refreshPOTable();
|
||
refreshInvoiceTable();
|
||
refreshEDIHistory();
|
||
refreshBillingTable();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|