Add user database migration, core reusable components, and layout structure

This commit is contained in:
2026-05-16 18:54:23 -04:00
parent c7df396a83
commit e132c7a580
33 changed files with 2348 additions and 398 deletions
+126 -74
View File
@@ -1,84 +1,136 @@
import { setToken } from './lib/api.mjs';
import './components/wo-list.mjs';
import './components/wo-form.mjs';
// ── Register all custom elements ──────────────────────────────────────────────
import './components/shared/ui-badge.mjs';
import './components/shared/ui-button.mjs';
import './components/shared/ui-spinner.mjs';
import './components/shared/ui-toast.mjs';
import './components/shared/ui-empty.mjs';
import './components/shared/ui-dialog.mjs';
import './components/layout/app-root.mjs';
import './components/layout/app-sidebar.mjs';
import './components/layout/app-topbar.mjs';
import './components/layout/app-mobile-nav.mjs';
import './components/work-orders/wo-list.mjs';
import './components/work-orders/wo-kanban.mjs';
import './components/work-orders/wo-form.mjs';
import './components/work-orders/wo-detail.mjs';
// ── Keycloak init ─────────────────────────────────────────────────────────────
const keycloak = new Keycloak({
url: window.KEYCLOAK_URL || 'http://localhost:8180',
realm: window.KEYCLOAK_REALM || 'workorders',
clientId: window.KEYCLOAK_CLIENT_ID || 'workorders-app',
});
import { getUser, setToken, clearToken } from './lib/auth.mjs';
import { api } from './lib/api.mjs';
import { router } from './lib/router.mjs';
import { showToast } from './components/shared/ui-toast.mjs';
keycloak.init({
onLoad: 'login-required',
pkceMethod: 'S256',
checkLoginIframe: false,
})
.then(authenticated => {
if (!authenticated) { keycloak.login(); return; }
setToken(keycloak.token);
const root = document.getElementById('root');
// Refresh token before it expires
setInterval(async () => {
try { await keycloak.updateToken(60); setToken(keycloak.token); }
catch { keycloak.login(); }
}, 30_000);
window.addEventListener('auth:expired', () => { clearToken(); showLoginPage(); });
window.addEventListener('auth:logout', () => { clearToken(); showLoginPage(); });
window.addEventListener('auth:expired', () => keycloak.login());
renderApp(keycloak);
})
.catch(() => document.getElementById('app').innerHTML =
'<p style="color:red;padding:2rem">Failed to connect to authentication server.</p>');
// ── App shell ─────────────────────────────────────────────────────────────────
function renderApp(kc) {
const app = document.getElementById('app');
const userName = kc.tokenParsed?.name || kc.tokenParsed?.preferred_username || 'User';
// Nav
const nav = document.createElement('nav');
nav.innerHTML = `
<span class="brand">Work Orders</span>
<span class="spacer"></span>
<span class="user-info">${userName}</span>
<button id="logout" style="background:transparent;border:1px solid rgba(255,255,255,.4);color:#fff;padding:.3rem .8rem;font-size:.85rem">Logout</button>`;
document.body.insertBefore(nav, app);
nav.querySelector('#logout').addEventListener('click', () => kc.logout());
showList();
app.addEventListener('wo:select', e => showDetail(e.detail.id));
app.addEventListener('wo:cancel', () => showList());
app.addEventListener('wo:saved', () => showList());
document.addEventListener('keydown', e => { if (e.key === 'Escape') showList(); });
const user = getUser();
if (user) {
startApp();
} else {
showLoginPage();
}
function showList() {
const app = document.getElementById('app');
app.innerHTML = `
<div class="page-header">
<h1>Work Orders</h1>
<button id="new-wo">+ New Work Order</button>
</div>
<wo-list id="wo-list"></wo-list>`;
app.querySelector('#new-wo').addEventListener('click', showCreate);
// ── Login page ────────────────────────────────────────────────────────────────
function showLoginPage() {
root.innerHTML = `
<style>
.login-wrap {
min-height: 100vh; display: flex; align-items: center; justify-content: center;
background: var(--bg); padding: 1rem;
}
.login-card {
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 2.5rem 2rem;
width: 100%; max-width: 380px; box-shadow: var(--shadow-md);
}
.login-brand {
display: flex; align-items: center; justify-content: center; gap: .75rem;
margin-bottom: 1.75rem;
}
.login-icon {
width: 40px; height: 40px; background: var(--teal); border-radius: var(--radius);
display: flex; align-items: center; justify-content: center; color: #fff;
}
.login-title { font-size: 1.25rem; font-weight: 700; color: var(--text); }
.login-card form { display: flex; flex-direction: column; gap: 1rem; }
.login-card label { display: flex; flex-direction: column; gap: .3rem; font-size: .875rem; font-weight: 600; color: var(--text); }
.login-card input { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: .6rem .75rem; background: var(--surface); color: var(--text); font-size: .938rem; width: 100%; transition: border-color .15s; }
.login-card input:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(10,126,164,.15); }
.login-btn {
background: var(--teal); color: #fff; border: none; border-radius: var(--radius);
padding: .65rem; font-size: .938rem; font-weight: 600; cursor: pointer;
transition: opacity .15s; display: flex; align-items: center; justify-content: center; gap: .4rem;
}
.login-btn:hover { opacity: .88; }
.login-btn:disabled { opacity: .5; cursor: not-allowed; }
.login-error { color: var(--danger); font-size: .813rem; text-align: center; min-height: 1.25rem; }
</style>
<div class="login-wrap">
<div class="login-card">
<div class="login-brand">
<div class="login-icon"><i data-lucide="clipboard-list" style="width:20px;height:20px"></i></div>
<span class="login-title">Work Orders</span>
</div>
<form id="login-form">
<label>Username or Email<input id="login-user" type="text" autocomplete="username" required placeholder="admin"></label>
<label>Password<input id="login-pass" type="password" autocomplete="current-password" required placeholder="••••••••"></label>
<div class="login-error" id="login-error"></div>
<button type="submit" class="login-btn" id="login-btn">Sign In</button>
</form>
</div>
</div>`;
if (window.lucide) lucide.createIcons({ nodes: [root] });
root.querySelector('#login-form').addEventListener('submit', async e => {
e.preventDefault();
const btn = root.querySelector('#login-btn');
const err = root.querySelector('#login-error');
const username = root.querySelector('#login-user').value.trim();
const password = root.querySelector('#login-pass').value;
btn.disabled = true;
btn.textContent = 'Signing in…';
err.textContent = '';
try {
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const json = await res.json();
if (!res.ok) throw new Error(json.error || 'Login failed');
setToken(json.data.token);
startApp();
} catch (ex) {
err.textContent = ex.message;
btn.disabled = false;
btn.textContent = 'Sign In';
}
});
}
function showCreate() {
const app = document.getElementById('app');
app.innerHTML = `
<div class="page-header"><h1>New Work Order</h1></div>
<div class="card"><wo-form></wo-form></div>`;
}
// ── Main app ──────────────────────────────────────────────────────────────────
function startApp() {
root.innerHTML = '<app-root></app-root>';
const appRoot = root.querySelector('app-root');
function showDetail(id) {
const app = document.getElementById('app');
app.innerHTML = `
<div class="page-header">
<h1>Edit Work Order</h1>
<button id="back" class="secondary">← Back</button>
</div>
<div class="card"><wo-form wo-id="${id}"></wo-form></div>`;
app.querySelector('#back').addEventListener('click', showList);
router
.on('/', () => appRoot.setPage('<wo-list></wo-list>'))
.on('/work-orders', () => appRoot.setPage('<wo-list></wo-list>'))
.on('/work-orders/new', () => appRoot.setPage('<wo-form></wo-form>'))
.on('/work-orders/:id/edit', ({ id }) => appRoot.setPage(`<wo-form wo-id="${id}"></wo-form>`))
.on('/work-orders/:id', ({ id }) => appRoot.setPage(`<wo-detail wo-id="${id}"></wo-detail>`))
.on('/registry/people', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">People registry — Phase 2</p>'))
.on('/registry/vehicles', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">Vehicles registry — Phase 2</p>'))
.on('/registry/equipment', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">Equipment registry — Phase 2</p>'))
.on('/registry/materials', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">Materials registry — Phase 2</p>'))
.on('/reports', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">Reports — Phase 3</p>'))
.on('/users', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">User management — Phase 3</p>'))
.on('/settings', () => appRoot.setPage('<p style="padding:2rem;color:var(--text-muted)">Settings — Phase 4</p>'))
.start();
// Global navigation events from WO components
window.addEventListener('wo:navigate', e => router.navigate(e.detail.path));
window.addEventListener('wo:toast', e => showToast(e.detail.message, e.detail.type));
}