Initial commit — LedgerFlow frontend

Single-page B2B accounting app with PO management (import, edit),
invoice creation with auto due date calculation, EDI preview/submit,
Google SSO, and sign out support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Adam Moussa 2026-04-02 18:19:49 -04:00
commit 4fbd664d64
6 changed files with 2800 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
.DS_Store

96
README.md Normal file
View file

@ -0,0 +1,96 @@
# LedgerFlow Frontend
Single-page web app for LedgerFlow — a B2B accounting tool for managing purchase orders, invoices, and EDI (X12 810) submissions to Amazon.
Deployed to S3 + CloudFront at **https://ledgerflow.seahaven.com**
---
## Stack
- Vanilla HTML/CSS/JS (no framework, no build step)
- Google Identity Services for SSO
- Calls the LedgerFlow backend API (Lambda + API Gateway)
---
## Files
```
ledgerflow_frontend/
├── accounting-app.html Main app (dashboard, POs, invoices, EDI, settings)
├── login.html Google SSO login page
├── config.js API URL + Google Client ID configuration
├── serve.js Local dev server (Node.js)
├── .gitignore
└── README.md
```
---
## Features
### Purchase Orders
- Import POs from external DynamoDB table (Coupa purchase-orders)
- Add POs manually
- Edit POs after import (vendor, amount, status, dates, notes)
- Bill against POs to create invoices
### Invoices
- Create invoices linked to POs
- Line items with quantity, unit price, and descriptions
- Payment terms: Net 15, Net 30, Net 45, Net 60, 90 NET, Due on Receipt
- Due date auto-calculates based on selected payment terms
- Tax rate from company settings
- Save as draft or queue for EDI submission
### EDI (X12 810)
- Preview generated X12 documents before submission
- Submit invoices to AWS B2B Data Interchange
- Transaction history and status tracking
- ISA control number tracking
### Settings / Connections
- Company info (name, tax ID, invoice prefix, currency)
- DynamoDB connection for external PO import
- EDI configuration (sender/receiver IDs)
- Connection status indicators in sidebar
### Other
- Google SSO with domain restriction
- Sign out button in sidebar
- Real-time dashboard with recent POs, invoices, and activity log
---
## Local Development
```bash
node serve.js
# Opens at http://localhost:3000
```
The frontend calls the deployed AWS backend — there is no local backend server.
---
## Deployment
The frontend is deployed automatically as part of `cdk deploy` in the backend repo. CDK's `BucketDeployment` syncs this directory to S3 and invalidates the CloudFront cache.
```bash
# From the backend repo:
cd infra
GOOGLE_CLIENT_ID=<your-client-id> npx cdk deploy --require-approval never
```
---
## Configuration
Edit `config.js` to set:
```js
window.LEDGERFLOW_API_URL = "https://your-api-gateway-url.amazonaws.com";
window.GOOGLE_CLIENT_ID = "your-client-id.apps.googleusercontent.com";
```

2250
accounting-app.html Normal file

File diff suppressed because it is too large Load diff

4
config.js Normal file
View file

@ -0,0 +1,4 @@
// LedgerFlow frontend configuration
// Set these to your deployed API Gateway endpoint and Google OAuth client ID.
window.LEDGERFLOW_API_URL = "https://sjmuqb1p45.execute-api.us-east-1.amazonaws.com";
window.GOOGLE_CLIENT_ID = "510349952236-eqo5crd0eiae63hifdmu58b0j8qml3ta.apps.googleusercontent.com";

409
login.html Normal file
View file

@ -0,0 +1,409 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LedgerFlow — Sign In</title>
<!-- Config (API URL + Client ID) -->
<script src="config.js"></script>
<!-- Google Identity Services -->
<script src="https://accounts.google.com/gsi/client" async defer onload="onGisLoaded()"></script>
<style>
:root {
--bg: #0a0c0f;
--surface: #111318;
--surface2: #181c24;
--border: #1f2533;
--border2: #2a3348;
--accent: #00e5a0;
--text: #e8edf5;
--text2: #8892a4;
--text3: #4a5568;
--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), monospace;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
/* Animated background grid */
body::before {
content: '';
position: fixed; inset: 0;
background-image:
linear-gradient(rgba(0,229,160,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,229,160,0.03) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
}
.login-card {
background: var(--surface);
border: 1px solid var(--border2);
border-radius: 16px;
padding: 48px 44px;
width: 400px;
text-align: center;
position: relative;
box-shadow: 0 0 80px rgba(0,229,160,0.05), 0 24px 60px rgba(0,0,0,0.5);
}
.login-card::before {
content: '';
position: absolute;
top: 0; left: 10%; right: 10%;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
}
.logo-mark {
font-family: var(--sans), sans-serif;
font-weight: 800;
font-size: 28px;
color: var(--accent);
letter-spacing: -1px;
margin-bottom: 4px;
}
.logo-sub {
font-size: 11px;
letter-spacing: 3px;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 36px;
}
.login-title {
font-family: var(--sans), sans-serif;
font-size: 18px;
font-weight: 700;
margin-bottom: 8px;
}
.login-sub {
font-size: 12px;
color: var(--text2);
margin-bottom: 32px;
line-height: 1.6;
}
/* Google button container — Google renders into this */
#google-btn-container {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
/* Fallback manual button */
.google-btn-fallback {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 11px 22px;
background: #fff;
border: none;
border-radius: 8px;
font-family: var(--mono), monospace;
font-size: 13px;
font-weight: 500;
color: #333;
cursor: pointer;
transition: box-shadow 0.15s;
}
.google-btn-fallback:hover { box-shadow: 0 2px 12px rgba(0,0,0,0.3); }
.google-btn-fallback svg { flex-shrink: 0; }
.login-footer {
font-size: 10px;
color: var(--text3);
line-height: 1.7;
}
.login-footer a { color: var(--text2); text-decoration: none; }
.login-footer a:hover { color: var(--accent); }
.loading {
display: none;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 0;
}
.spinner {
width: 28px; height: 28px;
border: 2px solid var(--border2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.error-box {
display: none;
background: rgba(255,71,87,0.1);
border: 1px solid rgba(255,71,87,0.3);
border-radius: 8px;
padding: 10px 14px;
font-size: 12px;
color: #ff8090;
margin-top: 12px;
text-align: left;
}
</style>
</head>
<body>
<div class="login-card">
<div class="logo-mark">LedgerFlow</div>
<div class="logo-sub">B2B Accounting</div>
<div id="login-content">
<div class="login-title">Welcome back</div>
<div class="login-sub">Sign in with your company Google account<br>to access your accounting workspace.</div>
<div id="google-btn-container">
<!-- Google Identity Services renders here automatically -->
<!-- Fallback shown until GIS loads -->
<button class="google-btn-fallback" id="fallback-btn" onclick="triggerGoogleLogin()">
<svg width="18" height="18" viewBox="0 0 18 18">
<path fill="#4285F4" d="M16.51 8H8.98v3h4.3c-.18 1-.74 1.48-1.6 2.04v2.01h2.6a7.8 7.8 0 0 0 2.38-5.88c0-.57-.05-.66-.15-1.18z"/>
<path fill="#34A853" d="M8.98 17c2.16 0 3.97-.72 5.3-1.94l-2.6-2.04c-.72.48-1.63.8-2.7.8-2.08 0-3.84-1.4-4.47-3.29H1.83v2.07A8 8 0 0 0 8.98 17z"/>
<path fill="#FBBC05" d="M4.51 10.53A4.8 4.8 0 0 1 4.26 9c0-.53.09-1.04.25-1.53V5.4H1.83A8 8 0 0 0 .98 9c0 1.29.31 2.51.85 3.6l2.68-2.07z"/>
<path fill="#EA4335" d="M8.98 3.58c1.17 0 2.23.4 3.06 1.2l2.3-2.3A8 8 0 0 0 .98 9h2.83c.63-1.89 2.4-3.29 4.47-3.42h.7z"/>
</svg>
Continue with Google
</button>
</div>
<div class="error-box" id="error-box"></div>
<div class="login-footer">
By signing in you agree to LedgerFlow's terms of service.<br>
Your credentials are validated by Google — we never see your password.
</div>
</div>
<div class="loading" id="loading-state">
<div class="spinner"></div>
<span style="font-size:12px;color:var(--text2)" id="loading-msg">Signing you in...</span>
</div>
</div>
<script>
// ─── Configuration ────────────────────────────────────────────────────────────
// Set LEDGERFLOW_API_URL to your API Gateway endpoint after deploying.
// Set GOOGLE_CLIENT_ID to your Google OAuth client ID.
const API_URL = window.LEDGERFLOW_API_URL || "";
const GOOGLE_CLIENT_ID = window.GOOGLE_CLIENT_ID || "";
// ─── Google Identity Services Init ───────────────────────────────────────────
let _pendingClientId = null;
window.addEventListener("load", async () => {
// Fetch Google Client ID from backend config endpoint
try {
if (API_URL) {
const res = await fetch(`${API_URL}/auth/config`);
const cfg = await res.json();
if (cfg.googleClientId) initGoogleSignIn(cfg.googleClientId);
} else if (GOOGLE_CLIENT_ID) {
initGoogleSignIn(GOOGLE_CLIENT_ID);
}
} catch (e) {
console.warn("Could not load auth config:", e);
}
});
function initGoogleSignIn(clientId) {
if (!window.google?.accounts) {
// GIS script not yet loaded — store client ID and retry when it loads
_pendingClientId = clientId;
return;
}
google.accounts.id.initialize({
client_id: clientId,
callback: handleCredentialResponse,
auto_select: false,
cancel_on_tap_outside: true,
});
// Render the styled Google button
google.accounts.id.renderButton(
document.getElementById("google-btn-container"),
{
theme: "filled_black",
size: "large",
text: "continue_with",
shape: "rectangular",
width: 280,
}
);
// Hide the fallback button
document.getElementById("fallback-btn").style.display = "none";
// Show One Tap prompt
google.accounts.id.prompt();
}
// Called when the GIS script finishes loading — retries init if we already have a client ID
function onGisLoaded() {
if (_pendingClientId) initGoogleSignIn(_pendingClientId);
}
function triggerGoogleLogin() {
if (window.google?.accounts?.id) {
google.accounts.id.prompt();
} else {
showError("Google Sign-In is loading. Please try again in a moment.");
}
}
// ─── Handle Google Credential Response ───────────────────────────────────────
async function handleCredentialResponse(response) {
const idToken = response.credential;
showLoading("Verifying your account...");
try {
// Verify token with our backend and get user profile
const res = await fetch(`${API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${idToken}` },
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.message || "Authentication failed");
}
const user = await res.json();
// Store session client-side (token + user info)
sessionStorage.setItem("ledgerflow_token", idToken);
sessionStorage.setItem("ledgerflow_user", JSON.stringify(user));
showLoading(`Welcome, ${user.name}! Loading your workspace...`);
// Redirect to main app after short delay
setTimeout(() => {
window.location.href = "accounting-app.html";
}, 800);
} catch (err) {
hideLoading();
showError(err.message || "Sign-in failed. Please try again.");
}
}
// ─── API Client (used in main app) ───────────────────────────────────────────
// Export this as a module or include it in your main app HTML.
window.LedgerFlowAPI = {
_token() {
return sessionStorage.getItem("ledgerflow_token");
},
_user() {
try { return JSON.parse(sessionStorage.getItem("ledgerflow_user")); } catch { return null; }
},
async _fetch(path, opts = {}) {
const token = this._token();
if (!token) { window.location.href = "index.html"; throw new Error("Not authenticated"); }
const res = await fetch(`${API_URL}${path}`, {
...opts,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
...(opts.headers || {}),
},
});
if (res.status === 401) {
// Token expired — redirect to login
sessionStorage.clear();
window.location.href = "index.html";
throw new Error("Session expired");
}
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(err.message || `HTTP ${res.status}`);
}
if (res.status === 204) return null;
return res.json();
},
// Purchase Orders
pos: {
list: (qs = {}) => window.LedgerFlowAPI._fetch("/pos?" + new URLSearchParams(qs)),
get: (id) => window.LedgerFlowAPI._fetch(`/pos/${id}`),
create: (body) => window.LedgerFlowAPI._fetch("/pos", { method: "POST", body: JSON.stringify(body) }),
update: (id, body) => window.LedgerFlowAPI._fetch(`/pos/${id}`, { method: "PUT", body: JSON.stringify(body) }),
patch: (id, body) => window.LedgerFlowAPI._fetch(`/pos/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
delete: (id) => window.LedgerFlowAPI._fetch(`/pos/${id}`, { method: "DELETE" }),
import: (items, fieldMap, source) =>
window.LedgerFlowAPI._fetch("/pos/import", { method: "POST", body: JSON.stringify({ items, fieldMap, source }) }),
dynamoScan: (table, status, limit) =>
window.LedgerFlowAPI._fetch(`/pos/dynamo-scan?table=${table}&status=${status || ""}&limit=${limit || 50}`),
},
// Invoices
invoices: {
list: (qs = {}) => window.LedgerFlowAPI._fetch("/invoices?" + new URLSearchParams(qs)),
get: (id) => window.LedgerFlowAPI._fetch(`/invoices/${id}`),
create: (body) => window.LedgerFlowAPI._fetch("/invoices", { method: "POST", body: JSON.stringify(body) }),
update: (id, body) => window.LedgerFlowAPI._fetch(`/invoices/${id}`, { method: "PUT", body: JSON.stringify(body) }),
updateStatus: (id, status) => window.LedgerFlowAPI._fetch(`/invoices/${id}/status`, { method: "PATCH", body: JSON.stringify({ status }) }),
delete: (id) => window.LedgerFlowAPI._fetch(`/invoices/${id}`, { method: "DELETE" }),
},
// EDI
edi: {
submit: (body) => window.LedgerFlowAPI._fetch("/edi/submit", { method: "POST", body: JSON.stringify(body) }),
transactions: (qs={}) => window.LedgerFlowAPI._fetch("/edi/transactions?" + new URLSearchParams(qs)),
getTransaction: (id) => window.LedgerFlowAPI._fetch(`/edi/transactions/${id}`),
pollStatus: (isa) => window.LedgerFlowAPI._fetch(`/edi/status/${isa}`),
},
// Auth
logout: async () => {
const token = sessionStorage.getItem("ledgerflow_token");
if (token) {
await fetch(`${API_URL}/auth/logout`, { method: "POST", headers: { Authorization: `Bearer ${token}` } })
.catch(() => {});
}
if (window.google?.accounts?.id) google.accounts.id.disableAutoSelect();
sessionStorage.clear();
window.location.href = "index.html";
},
};
// ─── UI Helpers ───────────────────────────────────────────────────────────────
function showLoading(msg = "Loading...") {
document.getElementById("login-content").style.display = "none";
document.getElementById("loading-state").style.display = "flex";
document.getElementById("loading-msg").textContent = msg;
}
function hideLoading() {
document.getElementById("login-content").style.display = "block";
document.getElementById("loading-state").style.display = "none";
}
function showError(msg) {
const box = document.getElementById("error-box");
box.style.display = "block";
box.textContent = "⚠ " + msg;
}
</script>
</body>
</html>

39
serve.js Normal file
View file

@ -0,0 +1,39 @@
// Simple local dev server — serves frontend on http://localhost:3000
// Usage: node serve.js
const http = require("http");
const fs = require("fs");
const path = require("path");
const PORT = 3000;
const DIR = __dirname;
const MIME = {
".html": "text/html",
".js": "application/javascript",
".css": "text/css",
".json": "application/json",
".png": "image/png",
".svg": "image/svg+xml",
".ico": "image/x-icon",
};
http.createServer((req, res) => {
let url = req.url.split("?")[0];
if (url === "/") url = "/login.html";
const filePath = path.join(DIR, url);
const ext = path.extname(filePath);
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end("Not found");
return;
}
res.writeHead(200, { "Content-Type": MIME[ext] || "application/octet-stream" });
res.end(data);
});
}).listen(PORT, () => {
console.log(`\n LedgerFlow frontend: http://localhost:${PORT}\n`);
});