Purchase orders page now shows 20 items per page with page navigation controls. loadPOs() fetches all pages via cursor pagination instead of capping at 200. Search filters the full dataset and resets to page 1. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
407 lines
13 KiB
HTML
407 lines
13 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 — 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 }) }),
|
|
},
|
|
|
|
// 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>
|