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:
commit
4fbd664d64
6 changed files with 2800 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
.DS_Store
|
||||
96
README.md
Normal file
96
README.md
Normal 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
2250
accounting-app.html
Normal file
File diff suppressed because it is too large
Load diff
4
config.js
Normal file
4
config.js
Normal 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
409
login.html
Normal 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
39
serve.js
Normal 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`);
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue