Working version before modification.

This commit is contained in:
Stan
2026-04-20 21:04:54 +02:00
parent 28d167f11f
commit e7127f3215
30 changed files with 7046 additions and 1201 deletions
+32
View File
@@ -0,0 +1,32 @@
/*
* Admin entry point — bootstraps the admin console.
*
* Opens the shared IndexedDB, loads cached image rules, then hands control to
* the admin module which manages navigation, CRUD, and rendering for every
* admin category (Settings, Users, Sites, Check Lists, Reports).
*/
import { state } from './js/state.js';
import { openDatabase, dbGet } from './js/db.js';
import { STORE_CONFIG } from './js/constants.js';
import { initAdmin } from './js/admin.js';
document.addEventListener('DOMContentLoaded', async () => {
/* Open the shared IndexedDB so image-policy read/write keeps working. */
state.db = await openDatabase();
/* Load cached image rules (may be null on first visit). */
const configRow = await dbGet(STORE_CONFIG, 'imageRules');
/* Hand off to the admin module for all further initialization. */
await initAdmin({ imageRules: configRow?.value || null });
});
/* Re-init when navigating back (fixes stale localStorage after bfcache) */
window.addEventListener('pageshow', async (e) => {
if (e.persisted) {
state.db = await openDatabase();
const configRow = await dbGet(STORE_CONFIG, 'imageRules');
await initAdmin({ imageRules: configRow?.value || null });
}
});
+554 -158
View File
@@ -6,191 +6,587 @@
<meta name="theme-color" content="#f3efe6" />
<title>Check List PoC — Admin</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<!--
Administrator workspace: server-backed configuration editing for image
policies and other centrally managed settings.
-->
<div class="app-shell">
<aside class="sidebar panel">
<div class="brand-block">
<p class="eyebrow">Hybrid Inspection Reporting</p>
<h1>Check List</h1>
<p class="lede">
Offline-first proof of concept for template-driven quality reports.
</p>
<div class="d-flex vh-100">
<!-- Sidebar -->
<aside class="sidebar-bs d-flex flex-column border-end bg-light" style="width:260px;min-width:260px;">
<div class="p-3 border-bottom">
<p class="text-uppercase text-muted small fw-semibold mb-0">Hybrid Inspection Reporting</p>
<h5 class="fw-bold mb-0">Check List</h5>
<small class="text-muted">Administration console</small>
</div>
<div class="sidebar-section">
<div class="status-row">
<span id="connectionBadge" class="badge badge-neutral">Checking connection</span>
<span id="saveBadge" class="badge badge-neutral">No changes</span>
<div class="p-3 border-bottom">
<span id="connectionBadge" class="badge bg-secondary">Checking…</span>
</div>
<!-- Navigation -->
<nav id="adminNav" class="flex-grow-1 overflow-auto p-2">
<!-- Settings -->
<div class="admin-nav-cat is-open mb-1">
<button class="admin-nav-cat-btn btn btn-sm btn-light w-100 text-start d-flex justify-content-between align-items-center" type="button">
<span><i class="bi bi-gear me-1"></i>Settings</span><span class="nav-arrow"></span>
</button>
<div class="admin-nav-sub ms-3 mt-1">
<button class="admin-nav-item btn btn-sm btn-link text-start w-100 is-active" type="button" data-panel="settings-policies">Image Policy</button>
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="settings-template">Template</button>
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="settings-task">Task</button>
</div>
</div>
<button id="syncTemplatesButton" class="button button-secondary" type="button">
Sync templates
</button>
</div>
<div class="sidebar-section">
<label class="field-label" for="templateSelect">Template</label>
<select id="templateSelect" class="select-input"></select>
</div>
<div class="sidebar-section">
<div class="section-heading-row sidebar-links-heading">
<h2>Access</h2>
<span class="muted-count">Direct links</span>
<!-- Users -->
<div class="admin-nav-cat mb-1">
<button class="admin-nav-cat-btn btn btn-sm btn-light w-100 text-start d-flex justify-content-between align-items-center" type="button">
<span><i class="bi bi-people me-1"></i>Users</span><span class="nav-arrow"></span>
</button>
<div class="admin-nav-sub ms-3 mt-1">
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="users">Users</button>
</div>
</div>
<a id="userAreaLink" class="button button-secondary sidebar-link" href="/user">User area</a>
<a id="adminAreaLink" class="button button-secondary sidebar-link is-active" href="/admin">Admin area</a>
<a class="button button-secondary sidebar-link" href="/">Back to portal</a>
<!-- Sites -->
<div class="admin-nav-cat mb-1">
<button class="admin-nav-cat-btn btn btn-sm btn-light w-100 text-start d-flex justify-content-between align-items-center" type="button">
<span><i class="bi bi-building me-1"></i>Sites</span><span class="nav-arrow"></span>
</button>
<div class="admin-nav-sub ms-3 mt-1">
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="sites">Sites</button>
</div>
</div>
<!-- Check Lists -->
<div class="admin-nav-cat mb-1">
<button class="admin-nav-cat-btn btn btn-sm btn-light w-100 text-start d-flex justify-content-between align-items-center" type="button">
<span><i class="bi bi-list-check me-1"></i>Check Lists</span><span class="nav-arrow"></span>
</button>
<div class="admin-nav-sub ms-3 mt-1">
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="cl-templates">Templates</button>
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="cl-records">Records</button>
</div>
</div>
<!-- Reports -->
<div class="admin-nav-cat mb-1">
<button class="admin-nav-cat-btn btn btn-sm btn-light w-100 text-start d-flex justify-content-between align-items-center" type="button">
<span><i class="bi bi-file-earmark-text me-1"></i>Reports</span><span class="nav-arrow"></span>
</button>
<div class="admin-nav-sub ms-3 mt-1">
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="reports">Tasks</button>
</div>
</div>
</nav>
<div class="p-3 border-top">
<a class="btn btn-outline-secondary btn-sm w-100 mb-1" href="/user">User area</a>
<a class="btn btn-secondary btn-sm w-100 mb-1" href="/admin">Admin area</a>
<a class="btn btn-outline-secondary btn-sm w-100" href="/">Back to portal</a>
</div>
</aside>
<main class="workspace">
<section id="adminWorkspace" class="workspace-view workspace-view-active">
<section class="hero panel">
<!-- Main content area -->
<main class="flex-grow-1 overflow-auto p-4 bg-white">
<!-- SETTINGS > IMAGE POLICY -->
<section id="panel-settings-policies" class="admin-panel admin-panel-active">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<p class="eyebrow">Administrator workspace</p>
<h2>Configuration control</h2>
<p class="hero-copy">
Update centrally managed image requirements used by the inspection frontend.
</p>
<p class="text-muted small mb-0">Settings Image Policy</p>
<h3 class="fw-bold">Image Policy Editor</h3>
<p class="text-muted">Update centrally managed image requirements.</p>
</div>
<div class="hero-actions">
<span id="adminSyncState" class="badge badge-neutral">Server-backed settings</span>
</div>
</section>
<span id="adminSyncState" class="badge bg-secondary">Server-backed</span>
</div>
<section class="editor-grid">
<section class="panel editor-panel">
<div class="section-heading-row">
<h2>Image policy editor</h2>
<span class="panel-note">Updates the active server rule</span>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-body">
<form id="adminImageRulesForm">
<div class="mb-3">
<label class="form-label fw-semibold">Allowed MIME types</label>
<div id="adminMimeCheckboxes" class="row g-2">
<div class="col-6 col-md-4"><div class="form-check"><input class="form-check-input" type="checkbox" name="mimeType" value="image/jpeg" id="mime-jpeg"><label class="form-check-label" for="mime-jpeg">image/jpeg</label></div></div>
<div class="col-6 col-md-4"><div class="form-check"><input class="form-check-input" type="checkbox" name="mimeType" value="image/png" id="mime-png"><label class="form-check-label" for="mime-png">image/png</label></div></div>
<div class="col-6 col-md-4"><div class="form-check"><input class="form-check-input" type="checkbox" name="mimeType" value="image/webp" id="mime-webp"><label class="form-check-label" for="mime-webp">image/webp</label></div></div>
<div class="col-6 col-md-4"><div class="form-check"><input class="form-check-input" type="checkbox" name="mimeType" value="image/gif" id="mime-gif"><label class="form-check-label" for="mime-gif">image/gif</label></div></div>
<div class="col-6 col-md-4"><div class="form-check"><input class="form-check-input" type="checkbox" name="mimeType" value="image/bmp" id="mime-bmp"><label class="form-check-label" for="mime-bmp">image/bmp</label></div></div>
<div class="col-6 col-md-4"><div class="form-check"><input class="form-check-input" type="checkbox" name="mimeType" value="image/tiff" id="mime-tiff"><label class="form-check-label" for="mime-tiff">image/tiff</label></div></div>
<div class="col-6 col-md-4"><div class="form-check"><input class="form-check-input" type="checkbox" name="mimeType" value="image/svg+xml" id="mime-svg"><label class="form-check-label" for="mime-svg">image/svg+xml</label></div></div>
<div class="col-6 col-md-4"><div class="form-check"><input class="form-check-input" type="checkbox" name="mimeType" value="image/heic" id="mime-heic"><label class="form-check-label" for="mime-heic">image/heic</label></div></div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label for="adminMaxFileSizeKb" class="form-label">Max file size (KB)</label>
<input id="adminMaxFileSizeKb" class="form-control" type="number" min="1" step="1" />
</div>
<div class="col-md-4">
<label for="adminMaxWidthPx" class="form-label">Max width (px)</label>
<input id="adminMaxWidthPx" class="form-control" type="number" min="1" step="1" />
</div>
<div class="col-md-4">
<label for="adminMaxHeightPx" class="form-label">Max height (px)</label>
<input id="adminMaxHeightPx" class="form-control" type="number" min="1" step="1" />
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="adminImageQuality" class="form-label">Image quality (%)</label>
<input id="adminImageQuality" class="form-control" type="number" min="1" max="100" step="1" />
</div>
<div class="col-md-6">
<label for="adminOversizeBehavior" class="form-label">Oversize behavior</label>
<select id="adminOversizeBehavior" class="form-select">
<option value="auto_optimize">Auto optimize</option>
<option value="warn_then_optimize">Warn then optimize</option>
<option value="block">Block oversized files</option>
</select>
</div>
</div>
<div class="d-flex gap-2">
<button id="saveImageRulesButton" class="btn btn-primary" type="submit">Save image policy</button>
<button id="resetImageRulesButton" class="btn btn-outline-secondary" type="button">Reset form</button>
</div>
</form>
</div>
</div>
<form id="adminImageRulesForm" class="report-form admin-form">
<section class="template-section">
<div class="field-grid">
<div class="field field-full">
<div class="field-header">
<label class="field-label" for="adminPolicyName">Policy name</label>
</div>
<input id="adminPolicyName" name="name" class="text-input" type="text" />
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header fw-semibold">Live Preview</div>
<div class="card-body">
<dl class="mb-0">
<dt class="small text-muted">Allowed types</dt>
<dd id="adminPolicyMimeTypes" class="mb-2">-</dd>
<dt class="small text-muted">Max file size</dt>
<dd id="adminPolicyFileSize" class="mb-2">-</dd>
<dt class="small text-muted">Optimization</dt>
<dd id="adminPolicyOptimization" class="mb-2">-</dd>
<dt class="small text-muted">Limits</dt>
<dd id="adminPolicyLimits" class="mb-0">-</dd>
</dl>
</div>
</div>
</div>
</div>
</section>
<div class="field field-full">
<div class="field-header">
<label class="field-label" for="adminAllowedMimeTypes">Allowed MIME types</label>
</div>
<input
id="adminAllowedMimeTypes"
name="allowedMimeTypes"
class="text-input"
type="text"
placeholder="image/jpeg, image/png, image/webp"
/>
<p class="field-help">Comma-separated values used by the attachment field and browser validation.</p>
</div>
<!-- SETTINGS > TEMPLATE -->
<section id="panel-settings-template" class="admin-panel">
<div class="mb-3">
<p class="text-muted small mb-0">Settings Template</p>
<h3 class="fw-bold">Record Dropdown Configuration</h3>
<p class="text-muted">Define dropdown values for checklist record forms.</p>
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxFileSizeMb">Max file size (MB)</label>
</div>
<input id="adminMaxFileSizeMb" name="maxFileSizeMb" class="text-input" type="number" min="1" step="0.1" />
</div>
<div class="card">
<div class="card-body">
<!-- Categories -->
<h6 class="fw-semibold">Categories</h6>
<div class="input-group input-group-sm mb-2">
<input id="tsCatInput" class="form-control" type="text" placeholder="Add category…" />
<button type="button" class="btn btn-outline-primary" data-ts-action="add-cat">Add</button>
</div>
<div id="tsCatList" class="mb-4"></div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxAttachmentsPerField">Max attachments per field</label>
</div>
<input id="adminMaxAttachmentsPerField" name="maxAttachmentsPerField" class="text-input" type="number" min="1" step="1" />
</div>
<!-- Sub Categories -->
<h6 class="fw-semibold">Sub Categories</h6>
<p class="text-muted small">Parent category is mandatory.</p>
<div class="input-group input-group-sm mb-2">
<select id="tsSubCatParent" class="form-select" style="max-width:180px"><option value="">Parent…</option></select>
<input id="tsSubCatInput" class="form-control" type="text" placeholder="Add sub category…" />
<button type="button" class="btn btn-outline-primary" data-ts-action="add-subcat">Add</button>
</div>
<div id="tsSubCatList" class="mb-4"></div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxWidthPx">Max width (px)</label>
</div>
<input id="adminMaxWidthPx" name="maxWidthPx" class="text-input" type="number" min="1" step="1" />
</div>
<!-- Severities -->
<h6 class="fw-semibold">Severities</h6>
<div class="input-group input-group-sm mb-2">
<input id="tsSevInput" class="form-control" type="text" placeholder="Add severity…" />
<button type="button" class="btn btn-outline-primary" data-ts-action="add-sev">Add</button>
</div>
<div id="tsSevList" class="mb-4"></div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxHeightPx">Max height (px)</label>
</div>
<input id="adminMaxHeightPx" name="maxHeightPx" class="text-input" type="number" min="1" step="1" />
</div>
<!-- Statuses -->
<h6 class="fw-semibold">Statuses</h6>
<div class="input-group input-group-sm mb-1">
<input id="tsStatusInput" class="form-control" type="text" placeholder="Add status…" />
<button type="button" class="btn btn-outline-primary" data-ts-action="add-status">Add</button>
</div>
<div class="form-check form-check-inline small mb-2">
<input class="form-check-input" type="checkbox" id="tsStatusReqHandled" />
<label class="form-check-label" for="tsStatusReqHandled">Handled By required</label>
</div>
<div class="form-check form-check-inline small mb-2">
<input class="form-check-input" type="checkbox" id="tsStatusReqComment" />
<label class="form-check-label" for="tsStatusReqComment">Comment required</label>
</div>
<div id="tsStatusList" class="mb-4"></div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminJpegQuality">JPEG quality</label>
</div>
<input id="adminJpegQuality" name="jpegQuality" class="text-input" type="number" min="1" max="100" step="1" />
</div>
<!-- Handled By -->
<h6 class="fw-semibold">Handled By</h6>
<div class="input-group input-group-sm mb-2">
<input id="tsHandledInput" class="form-control" type="text" placeholder="Add handler…" />
<button type="button" class="btn btn-outline-primary" data-ts-action="add-handled">Add</button>
</div>
<div id="tsHandledList"></div>
</div>
</div>
</section>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminOversizeBehavior">Oversize behavior</label>
</div>
<select id="adminOversizeBehavior" name="oversizeBehavior" class="select-input">
<option value="auto_optimize">Auto optimize</option>
<option value="warn_then_optimize">Warn then optimize</option>
<option value="block">Block oversized files</option>
</select>
</div>
<!-- SETTINGS > TASK -->
<section id="panel-settings-task" class="admin-panel">
<div class="mb-3">
<p class="text-muted small mb-0">Settings Task</p>
<h3 class="fw-bold">Task Dropdown Configuration</h3>
<p class="text-muted">Define Project and Process values for task assignment.</p>
</div>
<div class="card">
<div class="card-body">
<!-- Projects -->
<h6 class="fw-semibold">Projects</h6>
<div class="input-group input-group-sm mb-2">
<input id="tkProjInput" class="form-control" type="text" placeholder="Add project…" />
<button type="button" class="btn btn-outline-primary" data-tk-action="add-proj">Add</button>
</div>
<div id="tkProjList" class="mb-4"></div>
<!-- Processes -->
<h6 class="fw-semibold">Processes</h6>
<p class="text-muted small">Parent project is mandatory.</p>
<div class="input-group input-group-sm mb-2">
<select id="tkProcParent" class="form-select" style="max-width:180px"><option value="">Parent project…</option></select>
<input id="tkProcInput" class="form-control" type="text" placeholder="Add process…" />
<button type="button" class="btn btn-outline-primary" data-tk-action="add-proc">Add</button>
</div>
<div id="tkProcList"></div>
</div>
</div>
</section>
<!-- USERS -->
<section id="panel-users" class="admin-panel">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<p class="text-muted small mb-0">Users</p>
<h3 class="fw-bold mb-0">Manage Users</h3>
</div>
<button id="showUserFormBtn" class="btn btn-primary btn-sm" type="button"><i class="bi bi-plus-lg me-1"></i>Add User</button>
</div>
<div class="card mb-3">
<div class="card-body p-0">
<div id="userListContainer"></div>
</div>
</div>
<div id="userFormSection" class="card" style="display:none">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 id="userFormHeading" class="mb-0 fw-semibold">Add User</h6>
</div>
<div class="card-body">
<form id="userForm">
<div class="row g-3">
<div class="col-md-6">
<label for="userEmail" class="form-label">Email</label>
<input id="userEmail" class="form-control" type="email" required />
</div>
</section>
<div class="admin-actions">
<button id="saveImageRulesButton" class="button button-primary" type="submit">
Save image policy
</button>
<button id="resetImageRulesButton" class="button button-secondary" type="button">
Reset form
</button>
<div class="col-md-6">
<label for="userPassword" class="form-label">Password</label>
<input id="userPassword" class="form-control" type="password" required />
</div>
<div class="col-md-4">
<label for="userName" class="form-label">Name</label>
<input id="userName" class="form-control" type="text" required />
</div>
<div class="col-md-4">
<label for="userFamilyName" class="form-label">Family Name</label>
<input id="userFamilyName" class="form-control" type="text" required />
</div>
<div class="col-md-4">
<label for="userCompany" class="form-label">Company</label>
<input id="userCompany" class="form-control" type="text" />
</div>
<div class="col-md-4">
<label for="userRole" class="form-label">Role</label>
<select id="userRole" class="form-select" required>
<option value="">— Select —</option>
<option value="CW">CW</option>
<option value="ANT">ANT</option>
<option value="CW/ANT">CW/ANT</option>
</select>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary btn-sm" type="submit">Save User</button>
<button id="cancelUserBtn" class="btn btn-outline-secondary btn-sm" type="button">Cancel</button>
</div>
</form>
</section>
<aside class="panel inspector-panel">
<div class="section-heading-row">
<h2>Admin summary</h2>
<span class="panel-note">Live configuration preview</span>
</div>
<dl class="meta-list">
<div>
<dt>Active policy code</dt>
<dd id="adminPolicyCode">-</dd>
</div>
<div>
<dt>Allowed types</dt>
<dd id="adminPolicyMimeTypes">-</dd>
</div>
<div>
<dt>Optimization</dt>
<dd id="adminPolicyOptimization">-</dd>
</div>
<div>
<dt>Limits</dt>
<dd id="adminPolicyLimits">-</dd>
</div>
</dl>
<div class="validation-block">
<h3>Admin notes</h3>
<ul class="validation-list" id="adminNotesList">
<li>Changes are stored on the server and reused by report attachments.</li>
<li>Operators will use the updated policy after the next sync.</li>
</ul>
</div>
</aside>
</section>
</div>
</div>
</section>
<!-- SITES -->
<section id="panel-sites" class="admin-panel">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<p class="text-muted small mb-0">Sites</p>
<h3 class="fw-bold mb-0">Manage Sites</h3>
</div>
<button id="showSiteFormBtn" class="btn btn-primary btn-sm" type="button"><i class="bi bi-plus-lg me-1"></i>Add Site</button>
</div>
<div class="card mb-3">
<div class="card-body p-0">
<div id="siteListContainer"></div>
</div>
</div>
<div id="siteFormSection" class="card" style="display:none">
<div class="card-header"><h6 id="siteFormHeading" class="mb-0 fw-semibold">Add Site</h6></div>
<div class="card-body">
<form id="siteForm">
<div class="row g-3">
<div class="col-md-6">
<label for="siteSiteCode" class="form-label">Site Code</label>
<input id="siteSiteCode" class="form-control" type="text" required />
</div>
<div class="col-md-6">
<label for="siteHost" class="form-label">Host</label>
<select id="siteHost" class="form-select">
<option value="">— Select —</option>
<option value="OBE">OBE</option>
<option value="PXS">PXS</option>
</select>
</div>
<div class="col-md-6">
<label for="siteObe" class="form-label">OBE Site Code</label>
<input id="siteObe" class="form-control" type="text" />
</div>
<div class="col-md-6">
<label for="sitePxs" class="form-label">PXS Site Code</label>
<input id="sitePxs" class="form-control" type="text" />
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary btn-sm" type="submit">Save Site</button>
<button id="cancelSiteBtn" class="btn btn-outline-secondary btn-sm" type="button">Cancel</button>
</div>
</form>
</div>
</div>
</section>
<!-- CHECK LISTS > TEMPLATES -->
<section id="panel-cl-templates" class="admin-panel">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<p class="text-muted small mb-0">Check Lists Templates</p>
<h3 class="fw-bold mb-0">Manage Templates</h3>
</div>
<button id="showClTplFormBtn" class="btn btn-primary btn-sm" type="button"><i class="bi bi-plus-lg me-1"></i>Add Template</button>
</div>
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">Template List</span>
<span id="clTplCount" class="badge bg-secondary">0</span>
</div>
<div class="card-body p-0">
<div id="clTemplateListContainer"></div>
</div>
</div>
<div id="clTplFormSection" class="card" style="display:none">
<div class="card-header"><h6 id="clTplFormHeading" class="mb-0 fw-semibold">Add Template</h6></div>
<div class="card-body">
<form id="clTemplateForm">
<div class="row g-3 mb-3">
<div class="col-12">
<label for="clTplName" class="form-label">Template Name</label>
<input id="clTplName" class="form-control" type="text" required />
</div>
<div class="col-md-4">
<label for="clTplScope" class="form-label">Scope</label>
<select id="clTplScope" class="form-select">
<option value="">— Select —</option>
<option value="CW">CW</option>
<option value="ANT">ANT</option>
<option value="ANT_CPsite">ANT_CPsite</option>
</select>
</div>
<div class="col-md-4">
<label for="clTplVersion" class="form-label">Version</label>
<input id="clTplVersion" class="form-control" type="text" />
</div>
<div class="col-md-4"></div>
<div class="col-md-6">
<label for="clTplValidFrom" class="form-label">Valid From</label>
<input id="clTplValidFrom" class="form-control" type="date" />
</div>
<div class="col-md-6">
<label for="clTplValidTill" class="form-label">Valid Till</label>
<input id="clTplValidTill" class="form-control" type="date" />
</div>
</div>
<h6 class="fw-semibold">Include Records</h6>
<p class="text-muted small">Select records to include in this template.</p>
<div id="clTplRecordSelection" class="mb-3"></div>
<div class="d-flex gap-2">
<button class="btn btn-primary btn-sm" type="submit">Save Template</button>
<button id="cancelClTplBtn" class="btn btn-outline-secondary btn-sm" type="button">Cancel</button>
</div>
</form>
</div>
</div>
</section>
<!-- CHECK LISTS > RECORDS -->
<section id="panel-cl-records" class="admin-panel">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<p class="text-muted small mb-0">Check Lists Records</p>
<h3 class="fw-bold mb-0">Manage Records</h3>
</div>
<button id="showClRecFormBtn" class="btn btn-primary btn-sm" type="button"><i class="bi bi-plus-lg me-1"></i>Add Record</button>
</div>
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<span class="fw-semibold">Record List</span>
<span id="clRecCount" class="badge bg-secondary">0</span>
</div>
<div class="card-body p-0">
<div id="clRecordListContainer"></div>
</div>
</div>
<div id="clRecFormSection" class="card" style="display:none">
<div class="card-header"><h6 id="clRecFormHeading" class="mb-0 fw-semibold">Add Record</h6></div>
<div class="card-body">
<form id="clRecordForm">
<div class="row g-3 mb-3">
<div class="col-md-3">
<label for="clRecSort" class="form-label">Sort</label>
<input id="clRecSort" class="form-control" type="number" min="1" step="1" required />
<div class="form-text">Unique number.</div>
</div>
<div class="col-md-3">
<label for="clRecCategory" class="form-label">Category</label>
<select id="clRecCategory" class="form-select"></select>
</div>
<div class="col-md-3">
<label for="clRecSubCategory" class="form-label">Sub Category</label>
<select id="clRecSubCategory" class="form-select"></select>
</div>
<div class="col-md-3">
<label for="clRecSeverity" class="form-label">Severity</label>
<select id="clRecSeverity" class="form-select"></select>
</div>
<div class="col-12">
<div class="form-check">
<input id="clRecImageRequired" class="form-check-input" type="checkbox" />
<label class="form-check-label" for="clRecImageRequired">Image Required — user must attach image</label>
</div>
</div>
<div class="col-12">
<label for="clRecDescEN" class="form-label">Description EN</label>
<input id="clRecDescEN" class="form-control" type="text" />
</div>
<div class="col-md-6">
<label for="clRecDescFR" class="form-label">Description FR</label>
<input id="clRecDescFR" class="form-control" type="text" />
</div>
<div class="col-md-6">
<label for="clRecDescNL" class="form-label">Description NL</label>
<input id="clRecDescNL" class="form-control" type="text" />
</div>
<div class="col-md-4">
<label for="clRecStatus" class="form-label">Status <span class="badge bg-warning text-dark">PLACEHOLDER</span></label>
<select id="clRecStatus" class="form-select" disabled></select>
<div class="form-text">Set by user at runtime.</div>
</div>
<div class="col-md-4">
<label for="clRecHandledBy" class="form-label">Handled By <span class="badge bg-warning text-dark">PLACEHOLDER</span></label>
<select id="clRecHandledBy" class="form-select" disabled></select>
<div class="form-text">Set by user at runtime.</div>
</div>
<div class="col-md-4">
<label for="clRecComment" class="form-label">Comment <span class="badge bg-warning text-dark">PLACEHOLDER</span></label>
<textarea id="clRecComment" class="form-control" rows="2" disabled></textarea>
<div class="form-text">Filled by user at runtime.</div>
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-primary btn-sm" type="submit">Save Record</button>
<button id="cancelClRecBtn" class="btn btn-outline-secondary btn-sm" type="button">Cancel</button>
</div>
</form>
</div>
</div>
</section>
<!-- REPORTS -->
<section id="panel-reports" class="admin-panel">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<p class="text-muted small mb-0">Reports</p>
<h3 class="fw-bold mb-0">Task Management</h3>
</div>
<button id="showTaskFormBtn" class="btn btn-primary btn-sm" type="button"><i class="bi bi-plus-lg me-1"></i>Add Task</button>
</div>
<div class="card mb-3">
<div class="card-body p-0">
<div id="taskListContainer"></div>
</div>
</div>
<div id="taskFormSection" class="card" style="display:none">
<div class="card-header"><h6 id="taskFormHeading" class="mb-0 fw-semibold">Create Task Assignment</h6></div>
<div class="card-body">
<form id="taskForm">
<div class="row g-3">
<div class="col-md-6">
<label for="taskUser" class="form-label">User</label>
<select id="taskUser" class="form-select" required></select>
</div>
<div class="col-md-6">
<label for="taskSite" class="form-label">Site</label>
<select id="taskSite" class="form-select" required></select>
</div>
<div class="col-md-4">
<label for="taskTemplate" class="form-label">Template</label>
<select id="taskTemplate" class="form-select" required></select>
</div>
<div class="col-md-4">
<label for="taskProject" class="form-label">Project</label>
<select id="taskProject" class="form-select"></select>
</div>
<div class="col-md-4">
<label for="taskProcess" class="form-label">Process</label>
<select id="taskProcess" class="form-select"></select>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<button class="btn btn-primary btn-sm" type="submit">Assign Task</button>
<button id="cancelTaskBtn" class="btn btn-outline-secondary btn-sm" type="button">Cancel</button>
</div>
</form>
</div>
</div>
</section>
</main>
</div>
<script type="module" src="/app.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script type="module" src="/admin-app.js"></script>
</body>
</html>
+218 -285
View File
@@ -6,67 +6,55 @@
<meta name="theme-color" content="#f3efe6" />
<title>Check List PoC</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<!--
This document is the shared app shell for both operator and administrator
routes. JavaScript decides which workspace to reveal based on the current
URL so the project can keep one frontend bundle while still presenting two
distinct entry points.
-->
<div class="app-shell">
<aside class="sidebar panel">
<!--
The sidebar keeps app-level actions visible across both workspaces:
sync status, template selection, navigation links, and the local draft
list. That supports quick report switching on small operational screens.
-->
<div class="brand-block">
<p class="eyebrow">Hybrid Inspection Reporting</p>
<h1>Check List</h1>
<p class="lede">
Offline-first proof of concept for template-driven quality reports.
</p>
<div class="d-flex vh-100">
<!-- Sidebar -->
<aside class="sidebar-bs d-flex flex-column border-end bg-light" style="width:280px;min-width:280px;">
<div class="p-3 border-bottom">
<p class="text-uppercase text-muted small fw-semibold mb-0">Hybrid Inspection Reporting</p>
<h5 class="fw-bold mb-0">Check List</h5>
<small class="text-muted">Offline-first proof of concept for template-driven quality reports.</small>
</div>
<div class="sidebar-section">
<div class="status-row">
<span id="connectionBadge" class="badge badge-neutral">Checking connection</span>
<span id="saveBadge" class="badge badge-neutral">No changes</span>
<div class="p-3 border-bottom">
<div class="d-flex gap-2 mb-2">
<span id="connectionBadge" class="badge bg-secondary">Checking connection</span>
<span id="saveBadge" class="badge bg-secondary">No changes</span>
</div>
<button id="syncTemplatesButton" class="button button-secondary" type="button">
Sync templates
<button id="syncTemplatesButton" class="btn btn-outline-secondary btn-sm w-100" type="button">
<i class="bi bi-arrow-repeat me-1"></i>Sync templates
</button>
</div>
<div class="sidebar-section">
<label class="field-label" for="templateSelect">Template</label>
<select id="templateSelect" class="select-input"></select>
<button id="createReportButton" class="button button-primary" type="button">
Create new report
<div class="p-3 border-bottom">
<label class="form-label small fw-semibold" for="templateSelect">Template</label>
<select id="templateSelect" class="form-select form-select-sm mb-2"></select>
<button id="createReportButton" class="btn btn-primary btn-sm w-100" type="button">
<i class="bi bi-plus-lg me-1"></i>Create new report
</button>
</div>
<div class="sidebar-section">
<div class="section-heading-row sidebar-links-heading">
<h2>Access</h2>
<span class="muted-count">Direct links</span>
</div>
<a id="userAreaLink" class="button button-secondary sidebar-link" href="/user">User area</a>
<a id="adminAreaLink" class="button button-secondary sidebar-link" href="/admin">Admin area</a>
<a class="button button-secondary sidebar-link" href="/">Back to portal</a>
<div class="p-3 border-bottom">
<a id="userAreaLink" class="btn btn-outline-secondary btn-sm w-100 mb-1" href="/user">User area</a>
<a id="adminAreaLink" class="btn btn-outline-secondary btn-sm w-100 mb-1" href="/admin">Admin area</a>
<a class="btn btn-outline-secondary btn-sm w-100" href="/">Back to portal</a>
</div>
<div class="sidebar-section grow-section">
<div class="section-heading-row">
<h2>Local reports</h2>
<span id="reportCount" class="muted-count">0</span>
<div class="flex-grow-1 overflow-auto p-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="fw-semibold mb-0">Local reports</h6>
<span id="reportCount" class="badge bg-secondary">0</span>
</div>
<!-- F4 — Search and status filter for the local report list -->
<div class="report-filter-row">
<input id="reportSearchInput" class="text-input text-input-small" type="search" placeholder="Search reports" />
<select id="reportFilterSelect" class="select-input select-input-small">
<div class="mb-2">
<input id="reportSearchInput" class="form-control form-control-sm mb-1" type="search" placeholder="Search reports" />
<select id="reportFilterSelect" class="form-select form-select-sm">
<option value="">All statuses</option>
<option value="draft">Draft</option>
<option value="in_progress">In Progress</option>
@@ -79,259 +67,204 @@
</div>
</aside>
<main class="workspace">
<!-- Operator workspace: draft editing, validation, and local attachments. -->
<!-- Main content -->
<main class="flex-grow-1 overflow-auto p-4 bg-white">
<!-- Operator workspace -->
<section id="reportsWorkspace" class="workspace-view workspace-view-active">
<section class="hero panel">
<div>
<p class="eyebrow">Proof of concept frontend</p>
<h2 id="heroTitle">No report selected</h2>
<p id="heroSubtitle" class="hero-copy">
Start by syncing templates and creating a local draft.
</p>
</div>
<div class="hero-actions">
<label class="status-picker">
<span>Status</span>
<select id="reportStatusSelect" class="select-input">
<option value="draft">Draft</option>
<option value="in_progress">In Progress</option>
<option value="ready_for_export">Ready for Export</option>
<option value="exported">Exported</option>
<option value="archived">Archived</option>
</select>
</label>
<button id="submitReportButton" class="button button-secondary" type="button">
Submit
</button>
<button id="exportReportButton" class="button button-secondary" type="button">
Export CSV
</button>
<button id="deleteReportButton" class="button button-ghost" type="button">
Delete report
</button>
</div>
</section>
<section class="summary-grid">
<article class="summary-card panel accent-card">
<p class="summary-label">Template</p>
<strong id="summaryTemplate">Not loaded</strong>
<span id="summaryVersion" class="summary-note">Version -</span>
</article>
<article class="summary-card panel">
<p class="summary-label">Validation</p>
<strong id="validationHeadline">No report selected</strong>
<span id="validationDetail" class="summary-note">Draft validation will appear here.</span>
</article>
<article class="summary-card panel">
<p class="summary-label">Offline cache</p>
<strong id="syncHeadline">No sync yet</strong>
<span id="syncDetail" class="summary-note">Templates are cached locally after the first successful sync.</span>
</article>
</section>
<section class="editor-grid">
<section class="panel editor-panel">
<div class="section-heading-row">
<h2>Report editor</h2>
<span id="editorHint" class="panel-note">Dynamic form rendering from template JSON</span>
</div>
<form id="reportForm" class="report-form">
<div class="empty-state">
<h3>No report open</h3>
<p>Choose a template and create a report to start editing locally.</p>
</div>
</form>
</section>
<aside class="panel inspector-panel">
<div class="section-heading-row">
<h2>Inspector view</h2>
<span class="panel-note">Local draft summary</span>
</div>
<dl id="reportMeta" class="meta-list">
<div>
<dt>Report ID</dt>
<dd>-</dd>
</div>
<div>
<dt>Template</dt>
<dd>-</dd>
</div>
<div>
<dt>Created</dt>
<dd>-</dd>
</div>
<div>
<dt>Updated</dt>
<dd>-</dd>
</div>
</dl>
<div class="validation-block">
<h3>Validation issues</h3>
<ul id="validationList" class="validation-list">
<li>No report selected.</li>
</ul>
</div>
<div class="attachment-policy">
<h3>Image policy</h3>
<p id="imagePolicyText" class="policy-copy">
Load server configuration to see image limits and optimization rules.
</p>
</div>
</aside>
</section>
</section>
<!-- Administrator workspace: server-backed configuration editing. -->
<section id="adminWorkspace" class="workspace-view" hidden>
<section class="hero panel">
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<p class="eyebrow">Administrator workspace</p>
<h2>Configuration control</h2>
<p class="hero-copy">
Update centrally managed image requirements used by the inspection frontend.
</p>
<p class="text-muted small mb-0">Proof of concept frontend</p>
<h3 id="heroTitle" class="fw-bold">No report selected</h3>
<p id="heroSubtitle" class="text-muted">Start by syncing templates and creating a local draft.</p>
</div>
<div class="hero-actions">
<span id="adminSyncState" class="badge badge-neutral">Server-backed settings</span>
<div class="d-flex gap-2 align-items-center">
<label class="d-flex align-items-center gap-1 small">
<span>Status</span>
<select id="reportStatusSelect" class="form-select form-select-sm" style="width:auto">
<option value="draft">Draft</option>
<option value="in_progress">In Progress</option>
<option value="ready_for_export">Ready for Export</option>
<option value="exported">Exported</option>
<option value="archived">Archived</option>
</select>
</label>
<button id="submitReportButton" class="btn btn-outline-secondary btn-sm" type="button">Submit</button>
<button id="exportReportButton" class="btn btn-outline-secondary btn-sm" type="button">Export CSV</button>
<button id="deleteReportButton" class="btn btn-outline-danger btn-sm" type="button">Delete</button>
</div>
</section>
</div>
<section class="editor-grid">
<section class="panel editor-panel">
<div class="section-heading-row">
<h2>Image policy editor</h2>
<span class="panel-note">Updates the active server rule</span>
<!-- Summary cards -->
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card border-primary">
<div class="card-body py-2 px-3">
<small class="text-muted">Template</small>
<div class="fw-semibold" id="summaryTemplate">Not loaded</div>
<small class="text-muted" id="summaryVersion">Version -</small>
</div>
</div>
<form id="adminImageRulesForm" class="report-form admin-form">
<section class="template-section">
<div class="field-grid">
<div class="field field-full">
<div class="field-header">
<label class="field-label" for="adminPolicyName">Policy name</label>
</div>
<input id="adminPolicyName" name="name" class="text-input" type="text" />
</div>
<div class="field field-full">
<div class="field-header">
<label class="field-label" for="adminAllowedMimeTypes">Allowed MIME types</label>
</div>
<input
id="adminAllowedMimeTypes"
name="allowedMimeTypes"
class="text-input"
type="text"
placeholder="image/jpeg, image/png, image/webp"
/>
<p class="field-help">Comma-separated values used by the attachment field and browser validation.</p>
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxFileSizeMb">Max file size (MB)</label>
</div>
<input id="adminMaxFileSizeMb" name="maxFileSizeMb" class="text-input" type="number" min="1" step="0.1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxAttachmentsPerField">Max attachments per field</label>
</div>
<input id="adminMaxAttachmentsPerField" name="maxAttachmentsPerField" class="text-input" type="number" min="1" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxWidthPx">Max width (px)</label>
</div>
<input id="adminMaxWidthPx" name="maxWidthPx" class="text-input" type="number" min="1" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminMaxHeightPx">Max height (px)</label>
</div>
<input id="adminMaxHeightPx" name="maxHeightPx" class="text-input" type="number" min="1" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminJpegQuality">JPEG quality</label>
</div>
<input id="adminJpegQuality" name="jpegQuality" class="text-input" type="number" min="1" max="100" step="1" />
</div>
<div class="field">
<div class="field-header">
<label class="field-label" for="adminOversizeBehavior">Oversize behavior</label>
</div>
<select id="adminOversizeBehavior" name="oversizeBehavior" class="select-input">
<option value="auto_optimize">Auto optimize</option>
<option value="warn_then_optimize">Warn then optimize</option>
<option value="block">Block oversized files</option>
</select>
</div>
</div>
</section>
<div class="admin-actions">
<button id="saveImageRulesButton" class="button button-primary" type="submit">
Save image policy
</button>
<button id="resetImageRulesButton" class="button button-secondary" type="button">
Reset form
</button>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body py-2 px-3">
<small class="text-muted">Validation</small>
<div class="fw-semibold" id="validationHeadline">No report selected</div>
<small class="text-muted" id="validationDetail">Draft validation will appear here.</small>
</div>
</form>
</section>
<aside class="panel inspector-panel">
<div class="section-heading-row">
<h2>Admin summary</h2>
<span class="panel-note">Live configuration preview</span>
</div>
<dl class="meta-list">
<div>
<dt>Active policy code</dt>
<dd id="adminPolicyCode">-</dd>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-body py-2 px-3">
<small class="text-muted">Offline cache</small>
<div class="fw-semibold" id="syncHeadline">No sync yet</div>
<small class="text-muted" id="syncDetail">Templates are cached locally after the first successful sync.</small>
</div>
<div>
<dt>Allowed types</dt>
<dd id="adminPolicyMimeTypes">-</dd>
</div>
<div>
<dt>Optimization</dt>
<dd id="adminPolicyOptimization">-</dd>
</div>
<div>
<dt>Limits</dt>
<dd id="adminPolicyLimits">-</dd>
</div>
</dl>
<div class="validation-block">
<h3>Admin notes</h3>
<ul class="validation-list" id="adminNotesList">
<li>Changes are stored on the server and reused by report attachments.</li>
<li>Operators will use the updated policy after the next sync.</li>
</ul>
</div>
</aside>
</section>
</div>
</div>
<!-- Editor + Inspector -->
<div class="row g-4">
<div class="col-lg-8">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0 fw-semibold">Report editor</h6>
<small id="editorHint" class="text-muted">Dynamic form rendering from template JSON</small>
</div>
<div class="card-body">
<form id="reportForm" class="report-form">
<div class="text-center text-muted py-4">
<h5>No report open</h5>
<p>Choose a template and create a report to start editing locally.</p>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card mb-3">
<div class="card-header fw-semibold">Inspector view</div>
<div class="card-body">
<dl id="reportMeta" class="mb-0">
<dt class="small text-muted">Report ID</dt><dd class="mb-2">-</dd>
<dt class="small text-muted">Template</dt><dd class="mb-2">-</dd>
<dt class="small text-muted">Created</dt><dd class="mb-2">-</dd>
<dt class="small text-muted">Updated</dt><dd class="mb-0">-</dd>
</dl>
</div>
</div>
<div class="card mb-3">
<div class="card-header fw-semibold">Validation issues</div>
<div class="card-body">
<ul id="validationList" class="mb-0 ps-3">
<li>No report selected.</li>
</ul>
</div>
</div>
<div class="card">
<div class="card-header fw-semibold">Image policy</div>
<div class="card-body">
<p id="imagePolicyText" class="mb-0 text-muted small">
Load server configuration to see image limits and optimization rules.
</p>
</div>
</div>
</div>
</div>
</section>
<!-- Admin workspace (legacy) -->
<section id="adminWorkspace" class="workspace-view" hidden>
<div class="mb-4">
<p class="text-muted small mb-0">Administrator workspace</p>
<h3 class="fw-bold">Configuration control</h3>
<p class="text-muted">Update centrally managed image requirements used by the inspection frontend.</p>
<span id="adminSyncState" class="badge bg-secondary">Server-backed settings</span>
</div>
<div class="row g-4">
<div class="col-lg-8">
<div class="card">
<div class="card-header fw-semibold">Image policy editor</div>
<div class="card-body">
<form id="adminImageRulesForm">
<div class="row g-3 mb-3">
<div class="col-12">
<label for="adminPolicyName" class="form-label">Policy name</label>
<input id="adminPolicyName" name="name" class="form-control" type="text" />
</div>
<div class="col-12">
<label for="adminAllowedMimeTypes" class="form-label">Allowed MIME types</label>
<input id="adminAllowedMimeTypes" name="allowedMimeTypes" class="form-control" type="text" placeholder="image/jpeg, image/png, image/webp" />
<div class="form-text">Comma-separated values used by the attachment field and browser validation.</div>
</div>
<div class="col-md-6">
<label for="adminMaxFileSizeMb" class="form-label">Max file size (MB)</label>
<input id="adminMaxFileSizeMb" name="maxFileSizeMb" class="form-control" type="number" min="1" step="0.1" />
</div>
<div class="col-md-6">
<label for="adminMaxAttachmentsPerField" class="form-label">Max attachments per field</label>
<input id="adminMaxAttachmentsPerField" name="maxAttachmentsPerField" class="form-control" type="number" min="1" step="1" />
</div>
<div class="col-md-4">
<label for="adminMaxWidthPx" class="form-label">Max width (px)</label>
<input id="adminMaxWidthPx" name="maxWidthPx" class="form-control" type="number" min="1" step="1" />
</div>
<div class="col-md-4">
<label for="adminMaxHeightPx" class="form-label">Max height (px)</label>
<input id="adminMaxHeightPx" name="maxHeightPx" class="form-control" type="number" min="1" step="1" />
</div>
<div class="col-md-4">
<label for="adminJpegQuality" class="form-label">JPEG quality</label>
<input id="adminJpegQuality" name="jpegQuality" class="form-control" type="number" min="1" max="100" step="1" />
</div>
<div class="col-md-6">
<label for="adminOversizeBehavior" class="form-label">Oversize behavior</label>
<select id="adminOversizeBehavior" name="oversizeBehavior" class="form-select">
<option value="auto_optimize">Auto optimize</option>
<option value="warn_then_optimize">Warn then optimize</option>
<option value="block">Block oversized files</option>
</select>
</div>
</div>
<div class="d-flex gap-2">
<button id="saveImageRulesButton" class="btn btn-primary" type="submit">Save image policy</button>
<button id="resetImageRulesButton" class="btn btn-outline-secondary" type="button">Reset form</button>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header fw-semibold">Admin summary</div>
<div class="card-body">
<dl class="mb-0">
<dt class="small text-muted">Active policy code</dt><dd id="adminPolicyCode" class="mb-2">-</dd>
<dt class="small text-muted">Allowed types</dt><dd id="adminPolicyMimeTypes" class="mb-2">-</dd>
<dt class="small text-muted">Optimization</dt><dd id="adminPolicyOptimization" class="mb-2">-</dd>
<dt class="small text-muted">Limits</dt><dd id="adminPolicyLimits" class="mb-0">-</dd>
</dl>
</div>
</div>
<div class="card mt-3">
<div class="card-header fw-semibold">Admin notes</div>
<div class="card-body">
<ul id="adminNotesList" class="mb-0 ps-3">
<li>Changes are stored on the server and reused by report attachments.</li>
<li>Operators will use the updated policy after the next sync.</li>
</ul>
</div>
</div>
</div>
</div>
</section>
</main>
</div>
<!--
Report list items are rendered from this template at runtime so the sidebar
can update without rebuilding the entire page markup from strings.
-->
<template id="reportListItemTemplate">
<button class="report-list-item" type="button" data-report-id="">
<span class="report-list-item__header">
+1788
View File
File diff suppressed because it is too large Load Diff
+283
View File
@@ -0,0 +1,283 @@
/*
* exif.js — Lightweight EXIF parser for JPEG images in the browser.
*
* Extracts basic EXIF metadata (camera make/model, date taken, GPS coords,
* orientation, dimensions) from a base64 dataUrl or ArrayBuffer.
*
* This is a minimal parser — it handles the most common IFD0 and GPS tags
* without pulling in a full library.
*/
/* ── EXIF Tag IDs ───────────────────────────────────────────────────────── */
const TAGS = {
0x010F: 'make',
0x0110: 'model',
0x0112: 'orientation',
0x011A: 'xResolution',
0x011B: 'yResolution',
0x0132: 'dateTime',
0x8769: 'exifOffset',
0x8825: 'gpsOffset',
0xA002: 'pixelXDimension',
0xA003: 'pixelYDimension',
0x9003: 'dateTimeOriginal',
0x9004: 'dateTimeDigitized',
0x920A: 'focalLength',
0x829A: 'exposureTime',
0x829D: 'fNumber',
0x8827: 'isoSpeed'
};
const GPS_TAGS = {
0x0001: 'latRef',
0x0002: 'lat',
0x0003: 'lonRef',
0x0004: 'lon',
0x0005: 'altRef',
0x0006: 'alt'
};
/* ── Public API ─────────────────────────────────────────────────────────── */
/**
* Parse EXIF from a base64 dataUrl string.
* Returns an object with parsed tags, or null if no EXIF found.
*/
export function parseExifFromDataUrl(dataUrl) {
if (!dataUrl || !dataUrl.startsWith('data:image/jpeg')) return null;
try {
const base64 = dataUrl.split(',')[1];
const binary = atob(base64);
const buffer = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) buffer[i] = binary.charCodeAt(i);
return parseExif(buffer.buffer);
} catch {
return null;
}
}
/**
* Parse EXIF from an ArrayBuffer.
* Returns an object with parsed tags, or null if no EXIF found.
*/
export function parseExif(arrayBuffer) {
const view = new DataView(arrayBuffer);
/* Check JPEG SOI marker */
if (view.getUint16(0) !== 0xFFD8) return null;
/* Find APP1 marker (EXIF) */
let offset = 2;
while (offset < view.byteLength - 4) {
const marker = view.getUint16(offset);
if (marker === 0xFFE1) {
/* Found APP1 */
const length = view.getUint16(offset + 2);
return parseExifData(view, offset + 4, length - 2);
}
/* Skip to next marker */
if ((marker & 0xFF00) !== 0xFF00) break;
const segLen = view.getUint16(offset + 2);
offset += 2 + segLen;
}
return null;
}
/* ── Internal parsing ───────────────────────────────────────────────────── */
function parseExifData(view, start, length) {
/* Check "Exif\0\0" header */
const exifStr = String.fromCharCode(
view.getUint8(start), view.getUint8(start + 1),
view.getUint8(start + 2), view.getUint8(start + 3)
);
if (exifStr !== 'Exif') return null;
const tiffStart = start + 6;
const byteOrder = view.getUint16(tiffStart);
const littleEndian = byteOrder === 0x4949; /* 'II' = Intel = little-endian */
/* Verify TIFF magic number */
if (view.getUint16(tiffStart + 2, littleEndian) !== 0x002A) return null;
const ifd0Offset = view.getUint32(tiffStart + 4, littleEndian);
const result = {};
/* Parse IFD0 */
const ifd0 = parseIFD(view, tiffStart, tiffStart + ifd0Offset, littleEndian, TAGS);
Object.assign(result, ifd0);
/* Parse EXIF sub-IFD if present */
if (ifd0.exifOffset) {
const exifIfd = parseIFD(view, tiffStart, tiffStart + ifd0.exifOffset, littleEndian, TAGS);
delete result.exifOffset;
Object.assign(result, exifIfd);
}
/* Parse GPS IFD if present */
if (ifd0.gpsOffset) {
const gpsIfd = parseIFD(view, tiffStart, tiffStart + ifd0.gpsOffset, littleEndian, GPS_TAGS);
delete result.gpsOffset;
if (gpsIfd.lat && gpsIfd.latRef) {
result.latitude = convertDMSToDD(gpsIfd.lat, gpsIfd.latRef);
}
if (gpsIfd.lon && gpsIfd.lonRef) {
result.longitude = convertDMSToDD(gpsIfd.lon, gpsIfd.lonRef);
}
if (gpsIfd.alt != null) {
result.altitude = gpsIfd.altRef === 1 ? -gpsIfd.alt : gpsIfd.alt;
}
}
/* Clean up internal offsets */
delete result.exifOffset;
delete result.gpsOffset;
return Object.keys(result).length ? result : null;
}
function parseIFD(view, tiffStart, ifdStart, littleEndian, tagMap) {
const result = {};
try {
const entries = view.getUint16(ifdStart, littleEndian);
for (let i = 0; i < entries; i++) {
const entryOffset = ifdStart + 2 + (i * 12);
const tag = view.getUint16(entryOffset, littleEndian);
const tagName = tagMap[tag];
if (!tagName) continue;
const type = view.getUint16(entryOffset + 2, littleEndian);
const count = view.getUint32(entryOffset + 4, littleEndian);
const valueOffset = entryOffset + 8;
result[tagName] = readTagValue(view, tiffStart, type, count, valueOffset, littleEndian);
}
} catch {
/* Gracefully handle malformed EXIF */
}
return result;
}
function readTagValue(view, tiffStart, type, count, valueOffset, littleEndian) {
/* Type: 1=BYTE, 2=ASCII, 3=SHORT, 4=LONG, 5=RATIONAL, 7=UNDEFINED, 10=SRATIONAL */
const typeSize = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 };
const totalBytes = (typeSize[type] || 1) * count;
const dataOffset = totalBytes > 4
? tiffStart + view.getUint32(valueOffset, littleEndian)
: valueOffset;
switch (type) {
case 2: { /* ASCII string */
let str = '';
for (let i = 0; i < count - 1; i++) str += String.fromCharCode(view.getUint8(dataOffset + i));
return str.trim();
}
case 3: /* SHORT */
return count === 1 ? view.getUint16(dataOffset, littleEndian) : readArray(view, dataOffset, count, 2, littleEndian);
case 4: /* LONG */
return count === 1 ? view.getUint32(dataOffset, littleEndian) : readArray(view, dataOffset, count, 4, littleEndian);
case 5: /* RATIONAL (unsigned) */
if (count === 1) {
const num = view.getUint32(dataOffset, littleEndian);
const den = view.getUint32(dataOffset + 4, littleEndian);
return den ? num / den : 0;
}
return readRationalArray(view, dataOffset, count, littleEndian);
case 10: /* SRATIONAL (signed) */
if (count === 1) {
const num = view.getInt32(dataOffset, littleEndian);
const den = view.getInt32(dataOffset + 4, littleEndian);
return den ? num / den : 0;
}
return readRationalArray(view, dataOffset, count, littleEndian);
default:
return count === 1 ? view.getUint8(dataOffset) : null;
}
}
function readArray(view, offset, count, size, littleEndian) {
const arr = [];
for (let i = 0; i < count; i++) {
arr.push(size === 2 ? view.getUint16(offset + i * size, littleEndian) : view.getUint32(offset + i * size, littleEndian));
}
return arr;
}
function readRationalArray(view, offset, count, littleEndian) {
const arr = [];
for (let i = 0; i < count; i++) {
const num = view.getUint32(offset + i * 8, littleEndian);
const den = view.getUint32(offset + i * 8 + 4, littleEndian);
arr.push(den ? num / den : 0);
}
return arr;
}
function convertDMSToDD(dms, ref) {
if (!Array.isArray(dms) || dms.length < 3) return null;
let dd = dms[0] + dms[1] / 60 + dms[2] / 3600;
if (ref === 'S' || ref === 'W') dd = -dd;
return Math.round(dd * 1000000) / 1000000;
}
/* ═══════════════════════════════════════════════════════════════════════════
* EXIF preservation: extract raw APP1 segment and re-inject into JPEG
* ═══════════════════════════════════════════════════════════════════════════ */
/**
* Extract the raw EXIF APP1 segment (including FF E1 marker + length) from a
* JPEG ArrayBuffer. Returns a Uint8Array of the full segment, or null if no
* EXIF is found. This raw segment can be re-injected into a new JPEG to
* preserve metadata after canvas operations.
*/
export function extractExifSegment(arrayBuffer) {
const view = new DataView(arrayBuffer);
if (view.byteLength < 4) return null;
/* Check JPEG SOI marker */
if (view.getUint16(0) !== 0xFFD8) return null;
let offset = 2;
while (offset < view.byteLength - 4) {
const marker = view.getUint16(offset);
if (marker === 0xFFE1) {
/* APP1 found — extract the entire segment (marker + length + data) */
const segmentLength = view.getUint16(offset + 2);
const totalLength = 2 + segmentLength; /* marker (2) + length field included in segmentLength */
if (offset + totalLength > view.byteLength) return null;
/* Return a standalone copy so it survives GC of the original buffer */
return new Uint8Array(arrayBuffer.slice(offset, offset + totalLength));
}
/* Not APP1 — skip to next marker */
if ((marker & 0xFF00) !== 0xFF00) break;
const segLen = view.getUint16(offset + 2);
offset += 2 + segLen;
}
return null;
}
/**
* Insert a raw APP1 EXIF segment into a JPEG Blob that lacks one.
* Places the APP1 segment immediately after the SOI marker (FF D8).
* Returns a new Blob with EXIF restored, or the original blob if it's not JPEG
* or if exifSegment is null.
*/
export async function insertExifIntoJpeg(jpegBlob, exifSegment) {
if (!exifSegment || !jpegBlob || jpegBlob.type !== 'image/jpeg') return jpegBlob;
const buffer = await jpegBlob.arrayBuffer();
const view = new DataView(buffer);
if (view.getUint16(0) !== 0xFFD8) return jpegBlob;
/* Build new JPEG: SOI (2 bytes) + EXIF segment + rest of original after SOI */
const soi = new Uint8Array(buffer, 0, 2);
const rest = new Uint8Array(buffer, 2);
const merged = new Uint8Array(soi.length + exifSegment.length + rest.length);
merged.set(soi, 0);
merged.set(exifSegment, 2);
merged.set(rest, 2 + exifSegment.length);
return new Blob([merged], { type: 'image/jpeg' });
}
+26 -5
View File
@@ -4,8 +4,14 @@
* during large-image processing. On browsers that lack OffscreenCanvas support
* (or when running inside a Worker is not possible) the module falls back to
* main-thread canvas operations.
*
* EXIF preservation: Canvas operations strip all metadata. After resize/compress
* we re-inject the original EXIF APP1 segment into the output JPEG so that
* camera info, GPS, date-taken etc. survive the optimization.
*/
import { extractExifSegment, insertExifIntoJpeg } from './exif.js';
let worker = null;
let workerSupported = null;
@@ -35,6 +41,8 @@ function getWorker() {
/*
* Public entry point. Validates the file against the image rules, then delegates
* the actual resize/compress work to the worker or the main-thread fallback.
* After optimization, EXIF metadata from the original file is re-injected into
* the output JPEG so that camera/GPS/date info is preserved.
*/
export async function optimizeImage(file, imageRules) {
if (imageRules?.allowedMimeTypes?.length && !imageRules.allowedMimeTypes.includes(file.type)) {
@@ -45,13 +53,26 @@ export async function optimizeImage(file, imageRules) {
throw new Error(`File exceeds limit: ${file.name}`);
}
const w = getWorker();
if (w) {
return optimizeInWorker(w, file, imageRules);
/* Extract raw EXIF segment from original JPEG before canvas strips it */
let exifSegment = null;
if (file.type === 'image/jpeg') {
try {
const originalBuffer = await file.arrayBuffer();
exifSegment = extractExifSegment(originalBuffer);
} catch { /* non-critical — proceed without EXIF */ }
}
return optimizeOnMainThread(file, imageRules);
const w = getWorker();
const result = w
? await optimizeInWorker(w, file, imageRules)
: await optimizeOnMainThread(file, imageRules);
/* Re-inject EXIF into the optimized JPEG */
if (exifSegment && result.blob.type === 'image/jpeg') {
result.blob = await insertExifIntoJpeg(result.blob, exifSegment);
}
return result;
}
/* ── Worker path ────────────────────────────────────────────────────────── */
+173
View File
@@ -0,0 +1,173 @@
/*
* user-db.js — IndexedDB storage for the user portal.
*
* Stores task data (including heavy base64 image dataUrls) in IndexedDB
* instead of localStorage to avoid the ~5 MB browser quota.
*
* IndexedDB provides hundreds of MB of storage (browser-managed, quota-based)
* which makes it suitable for image-heavy task data.
*
* Usage:
* import { openUserDB, loadTaskData, saveTaskData, getStorageEstimate } from './user-db.js';
*
* await openUserDB(); // Call once on init
* const data = await loadTaskData(); // Returns the full taskData object
* await saveTaskData(data); // Persist updated taskData
* const est = await getStorageEstimate(); // Get usage info
*/
/* ── Constants ──────────────────────────────────────────────────────────── */
const DB_NAME = 'user-portal-db';
const DB_VERSION = 1;
const STORE_TASK_DATA = 'taskData';
/* ── Module-level DB reference ──────────────────────────────────────────── */
let db = null;
/* ── Open / create database ─────────────────────────────────────────────── */
/**
* Opens the IndexedDB database. Must be called once before any read/write.
* Creates the object store on first run or version upgrade.
*/
export function openUserDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
db = request.result;
resolve(db);
};
request.onupgradeneeded = () => {
const database = request.result;
/* Single store keyed by taskId — each entry holds one task's data */
if (!database.objectStoreNames.contains(STORE_TASK_DATA)) {
database.createObjectStore(STORE_TASK_DATA, { keyPath: 'taskId' });
}
};
});
}
/* ── Read all task data ─────────────────────────────────────────────────── */
/**
* Loads all task data from IndexedDB and returns it as a plain object
* keyed by taskId (same shape as the old localStorage structure).
*
* @returns {Promise<Object>} e.g. { "task-123": { visitDate: "", records: {...} }, ... }
*/
export function loadTaskData() {
return new Promise((resolve, reject) => {
if (!db) { resolve({}); return; }
const tx = db.transaction(STORE_TASK_DATA, 'readonly');
const store = tx.objectStore(STORE_TASK_DATA);
const request = store.getAll();
request.onsuccess = () => {
const result = {};
for (const entry of request.result) {
const { taskId, ...data } = entry;
result[taskId] = data;
}
resolve(result);
};
request.onerror = () => reject(request.error);
});
}
/* ── Save all task data ─────────────────────────────────────────────────── */
/**
* Persists the full taskData object into IndexedDB.
* Each taskId becomes a separate record in the store for efficient access.
*
* @param {Object} taskData - Object keyed by taskId
* @returns {Promise<void>}
*/
export function saveTaskData(taskData) {
return new Promise((resolve, reject) => {
if (!db) { reject(new Error('Database not open')); return; }
const tx = db.transaction(STORE_TASK_DATA, 'readwrite');
const store = tx.objectStore(STORE_TASK_DATA);
for (const [taskId, data] of Object.entries(taskData)) {
store.put({ taskId, ...data });
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/* ── Save single task entry ─────────────────────────────────────────────── */
/**
* Saves or updates a single task's data. More efficient than saving everything
* when only one task changed.
*
* @param {string} taskId
* @param {Object} data - The task data (visitDate, records, etc.)
* @returns {Promise<void>}
*/
export function saveOneTaskData(taskId, data) {
return new Promise((resolve, reject) => {
if (!db) { reject(new Error('Database not open')); return; }
const tx = db.transaction(STORE_TASK_DATA, 'readwrite');
const store = tx.objectStore(STORE_TASK_DATA);
store.put({ taskId, ...data });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/* ── Delete a task entry ────────────────────────────────────────────────── */
/**
* Removes a single task's data from IndexedDB.
*
* @param {string} taskId
* @returns {Promise<void>}
*/
export function deleteTaskData(taskId) {
return new Promise((resolve, reject) => {
if (!db) { reject(new Error('Database not open')); return; }
const tx = db.transaction(STORE_TASK_DATA, 'readwrite');
const store = tx.objectStore(STORE_TASK_DATA);
store.delete(taskId);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/* ── Storage estimate ───────────────────────────────────────────────────── */
/**
* Returns an estimate of IndexedDB usage (if the StorageManager API is available).
* Falls back to counting serialized task data size.
*
* @param {Object} taskData - Current in-memory taskData for fallback sizing
* @returns {Promise<{usedMB: string, quotaMB: string, pct: number}>}
*/
export async function getStorageEstimate(taskData) {
/* Try the modern Storage API (available in secure contexts) */
if (navigator.storage && navigator.storage.estimate) {
const est = await navigator.storage.estimate();
const usedMB = ((est.usage || 0) / (1024 * 1024)).toFixed(2);
const quotaMB = ((est.quota || 0) / (1024 * 1024)).toFixed(0);
const pct = est.quota ? Math.min(100, ((est.usage / est.quota) * 100)) : 0;
return { usedMB, quotaMB, pct: Math.round(pct) };
}
/* Fallback: estimate from serialized data */
const json = JSON.stringify(taskData || {});
const bytes = json.length * 2; /* UTF-16 */
const usedMB = (bytes / (1024 * 1024)).toFixed(2);
return { usedMB, quotaMB: '∞', pct: 0 };
}
+1588
View File
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f3efe6" />
<title>Check List PoC — Admin Login</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
<style>
body {
font-family: "Inter", "Segoe UI", sans-serif;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-card {
max-width: 400px;
width: 100%;
}
.login-header {
text-align: center;
margin-bottom: 24px;
}
.login-header h1 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 8px;
}
.login-header p {
color: #6c757d;
font-size: 0.9rem;
}
.alert {
display: none;
}
.alert.show {
display: block;
}
</style>
</head>
<body>
<div class="login-card">
<div class="card shadow-sm">
<div class="card-body p-4">
<div class="login-header">
<i class="bi bi-shield-lock fs-1 text-primary mb-2 d-block"></i>
<h1>Admin Login</h1>
<p>Sign in to access the administration console</p>
</div>
<div id="errorAlert" class="alert alert-danger" role="alert"></div>
<form id="loginForm">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required autofocus />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required />
</div>
<button type="submit" class="btn btn-primary w-100" id="loginBtn">
<span id="loginText">Sign In</span>
<span id="loginSpinner" class="spinner-border spinner-border-sm d-none" role="status"></span>
</button>
</form>
<div class="text-center mt-4">
<a href="/" class="text-decoration-none"><i class="bi bi-arrow-left me-1"></i>Back to portal</a>
</div>
</div>
</div>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const errorAlert = document.getElementById('errorAlert');
const loginBtn = document.getElementById('loginBtn');
const loginText = document.getElementById('loginText');
const loginSpinner = document.getElementById('loginSpinner');
/* Hide previous error */
errorAlert.classList.remove('show');
/* Show loading state */
loginBtn.disabled = true;
loginText.textContent = 'Signing in...';
loginSpinner.classList.remove('d-none');
try {
const response = await fetch('/api/v1/auth/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (data.success) {
/* Store token in sessionStorage for API calls */
sessionStorage.setItem('auth_token', data.token);
sessionStorage.setItem('auth_type', 'admin');
/* Redirect to admin page */
window.location.href = '/admin';
} else {
errorAlert.textContent = data.message || 'Login failed. Please check your credentials.';
errorAlert.classList.add('show');
}
} catch (err) {
errorAlert.textContent = 'Network error. Please try again.';
errorAlert.classList.add('show');
} finally {
loginBtn.disabled = false;
loginText.textContent = 'Sign In';
loginSpinner.classList.add('d-none');
}
});
</script>
</body>
</html>
+132
View File
@@ -0,0 +1,132 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f3efe6" />
<title>Check List PoC — User Login</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
<style>
body {
font-family: "Inter", "Segoe UI", sans-serif;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-card {
max-width: 400px;
width: 100%;
}
.login-header {
text-align: center;
margin-bottom: 24px;
}
.login-header h1 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 8px;
}
.login-header p {
color: #6c757d;
font-size: 0.9rem;
}
.alert {
display: none;
}
.alert.show {
display: block;
}
</style>
</head>
<body>
<div class="login-card">
<div class="card shadow-sm">
<div class="card-body p-4">
<div class="login-header">
<i class="bi bi-person-circle fs-1 text-primary mb-2 d-block"></i>
<h1>User Login</h1>
<p>Sign in to access your task workspace</p>
</div>
<div id="errorAlert" class="alert alert-danger" role="alert"></div>
<form id="loginForm">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" required autofocus />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required />
</div>
<button type="submit" class="btn btn-primary w-100" id="loginBtn">
<span id="loginText">Sign In</span>
<span id="loginSpinner" class="spinner-border spinner-border-sm d-none" role="status"></span>
</button>
</form>
<div class="text-center mt-4">
<a href="/" class="text-decoration-none"><i class="bi bi-arrow-left me-1"></i>Back to portal</a>
</div>
</div>
</div>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
const errorAlert = document.getElementById('errorAlert');
const loginBtn = document.getElementById('loginBtn');
const loginText = document.getElementById('loginText');
const loginSpinner = document.getElementById('loginSpinner');
/* Hide previous error */
errorAlert.classList.remove('show');
/* Show loading state */
loginBtn.disabled = true;
loginText.textContent = 'Signing in...';
loginSpinner.classList.remove('d-none');
try {
const response = await fetch('/api/v1/auth/user/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (data.success) {
/* Store token and user info in sessionStorage */
sessionStorage.setItem('auth_token', data.token);
sessionStorage.setItem('auth_type', 'user');
sessionStorage.setItem('auth_user', JSON.stringify(data.user));
/* Redirect to user page with userId */
window.location.href = '/user?userId=' + data.user.id;
} else {
errorAlert.textContent = data.message || 'Login failed. Please check your credentials.';
errorAlert.classList.add('show');
}
} catch (err) {
errorAlert.textContent = 'Network error. Please try again.';
errorAlert.classList.add('show');
} finally {
loginBtn.disabled = false;
loginText.textContent = 'Sign In';
loginSpinner.classList.add('d-none');
}
});
</script>
</body>
</html>
+126 -34
View File
@@ -5,44 +5,136 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f3efe6" />
<title>Check List Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body class="portal-body">
<!--
The portal intentionally acts as a simple role chooser rather than a real
authentication page. It separates user and admin entry points in the PoC
without committing the project to a security model too early.
-->
<main class="portal-shell">
<section class="portal-hero panel">
<p class="eyebrow">Check List Access</p>
<h1>Choose workspace</h1>
<p class="portal-copy">
Use the operator workspace for quality reports and the administrator workspace for configuration.
</p>
</section>
<body class="portal-body d-flex align-items-center justify-content-center min-vh-100">
<main class="container" style="max-width: 720px;">
<div class="text-center mb-4">
<p class="text-uppercase text-muted small fw-semibold mb-1">Check List Access</p>
<h1 class="fw-bold">Choose workspace</h1>
<p class="text-muted">Select a user, then open the operator or administrator workspace.</p>
</div>
<section class="portal-grid">
<!-- Direct operator entry for report creation and local draft work. -->
<a class="portal-card panel" href="/user">
<p class="summary-label">User area</p>
<h2>Operator workspace</h2>
<p class="portal-copy">
Create reports, work offline, attach images, and manage local drafts.
</p>
<span class="button button-primary portal-button">Open user area</span>
</a>
<!-- User selection -->
<div class="card mb-4">
<div class="card-body">
<label for="portalUserSelect" class="form-label fw-semibold">Select User</label>
<select id="portalUserSelect" class="form-select">
<option value="">— Choose a user to see their tasks —</option>
</select>
</div>
</div>
<!-- Direct administrator entry for centrally managed configuration. -->
<a class="portal-card panel" href="/admin">
<p class="summary-label">Admin area</p>
<h2>Administrator workspace</h2>
<p class="portal-copy">
Maintain image requirements and other centrally managed configuration.
</p>
<span class="button button-secondary portal-button">Open admin area</span>
</a>
</section>
<div class="row g-3">
<!-- Operator workspace -->
<div class="col-md-6">
<a id="portalUserLink" class="card text-decoration-none h-100 portal-card" href="#">
<div class="card-body text-center">
<i class="bi bi-clipboard-check fs-1 text-primary mb-2 d-block"></i>
<p class="text-muted small mb-1">User area</p>
<h5 class="fw-bold">Operator workspace</h5>
<p class="text-muted small">Process assigned tasks, attach images, save reports.</p>
<span class="btn btn-primary btn-sm mt-2">Open user area</span>
</div>
</a>
</div>
<!-- Administrator workspace -->
<div class="col-md-6">
<a class="card text-decoration-none h-100 portal-card" href="/admin">
<div class="card-body text-center">
<i class="bi bi-gear fs-1 text-secondary mb-2 d-block"></i>
<p class="text-muted small mb-1">Admin area</p>
<h5 class="fw-bold">Administrator workspace</h5>
<p class="text-muted small">Manage settings, users, sites, templates, and tasks.</p>
<span class="btn btn-outline-secondary btn-sm mt-2">Open admin area</span>
</div>
</a>
</div>
</div>
<!-- Data cleanup -->
<div class="card mt-4">
<div class="card-body d-flex align-items-center justify-content-between">
<div>
<h6 class="fw-semibold mb-1"><i class="bi bi-trash3 me-1"></i>Data Cleanup</h6>
<p class="text-muted small mb-0">Remove all localStorage data and reset the application.</p>
</div>
<button id="cleanupBtn" class="btn btn-outline-danger btn-sm">Clear all data</button>
</div>
</div>
</main>
<script>
// Populate user dropdown from server API
(function() {
const sel = document.getElementById('portalUserSelect');
const userLink = document.getElementById('portalUserLink');
/* Prevent opening user area without selecting a user */
userLink.addEventListener('click', (e) => {
if (!sel.value) {
e.preventDefault();
sel.focus();
sel.classList.add('is-invalid');
setTimeout(() => sel.classList.remove('is-invalid'), 2000);
}
});
async function populateUsers() {
try {
const resp = await fetch('/api/v1/admin/users', { headers: { Accept: 'application/json' } });
if (!resp.ok) throw new Error('Failed to load users');
const data = await resp.json();
const users = data.items || [];
sel.innerHTML = '<option value="">— Choose a user to see their tasks —</option>';
users.forEach(u => {
const opt = document.createElement('option');
opt.value = u.id;
opt.textContent = `${u.name} ${u.familyName} (${u.email}) — ${u.role}`;
sel.appendChild(opt);
});
const saved = localStorage.getItem('portal_selected_user');
if (saved) sel.value = saved;
updateUserLink();
} catch (err) {
console.warn('Could not load users from server:', err.message);
}
}
sel.addEventListener('change', () => {
localStorage.setItem('portal_selected_user', sel.value);
updateUserLink();
});
function updateUserLink() {
const uid = sel.value;
userLink.href = uid ? `/user?userId=${encodeURIComponent(uid)}` : '#';
}
populateUsers();
/* Re-populate when navigating back (fixes stale data after bfcache) */
window.addEventListener('pageshow', (e) => { if (e.persisted) populateUsers(); });
// Cleanup button
document.getElementById('cleanupBtn').addEventListener('click', () => {
if (!confirm('This will remove ALL application data (cached data, settings). Continue?')) return;
localStorage.removeItem('portal_selected_user');
localStorage.removeItem('user_language');
// Clear IndexedDB
indexedDB.databases().then(dbs => {
dbs.forEach(db => indexedDB.deleteDatabase(db.name));
}).catch(() => {});
alert('All data cleared. Page will reload.');
location.reload();
});
})();
</script>
</body>
</html>
+247 -548
View File
@@ -1,638 +1,337 @@
/* ═══════════════════════════════════════════════════════════════════════════
* Check List PoC — Custom styles (Bootstrap 5 handles layout + components)
* Only app-specific overrides and classes that JS depends on live here.
* ═══════════════════════════════════════════════════════════════════════════ */
:root {
/*
* The visual system uses a warm industrial palette instead of generic neutral
* SaaS colors. The goal is to make the PoC feel closer to an operational tool
* used in inspection environments than to a stock admin dashboard.
*/
--bg: #f3efe6;
--bg-strong: #e8dcc7;
--panel: rgba(255, 252, 247, 0.92);
--panel-border: rgba(93, 67, 35, 0.16);
--text: #1c1a18;
--muted: #685f53;
--accent: #9d3d2e;
--accent-strong: #7f2c20;
--accent-soft: #f4d2bf;
--success: #25624c;
--warning: #8a6119;
--danger: #8b2e34;
--shadow: 0 20px 50px rgba(77, 48, 18, 0.12);
--radius-lg: 24px;
--radius-md: 16px;
--radius-sm: 12px;
--font-ui: "Aptos", "Segoe UI Variable", "Segoe UI", sans-serif;
--font-ui: "Inter", "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.7), transparent 35%),
linear-gradient(135deg, #f8f2e8 0%, #ead9bb 44%, #e9d8c5 100%);
color: var(--text);
font-family: var(--font-ui);
}
body {
min-height: 100vh;
/* ── Sidebar (shared across pages) ──────────────────────────────────────── */
.sidebar-bs {
font-family: var(--font-ui);
}
.portal-body {
display: grid;
place-items: center;
padding: 24px;
/* ── Admin panel visibility (JS toggles these) ──────────────────────────── */
.admin-panel {
display: none;
}
button,
input,
select,
textarea {
font: inherit;
.admin-panel-active {
display: block;
}
.app-shell {
/*
* The main layout keeps navigation and report context visible at the same time.
* This is important for a checklist workflow where users often switch reports
* and need immediate awareness of save state, sync state, and current draft.
*/
display: grid;
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
min-height: 100vh;
gap: 20px;
padding: 20px;
/* ── Admin navigation (sidebar category tree) ───────────────────────────── */
.admin-nav-sub {
display: none;
}
.panel {
border: 1px solid var(--panel-border);
border-radius: var(--radius-lg);
background: var(--panel);
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
.admin-nav-cat.is-open > .admin-nav-sub {
display: block;
}
.sidebar {
display: flex;
flex-direction: column;
padding: 24px;
gap: 22px;
}
.brand-block h1,
.hero h2,
.section-heading-row h2,
.empty-state h3,
.validation-block h3,
.attachment-policy h3 {
margin: 0;
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent-strong);
letter-spacing: 0.16em;
text-transform: uppercase;
font-size: 0.74rem;
.admin-nav-item.is-active {
font-weight: 700;
}
.lede,
.hero-copy,
.panel-note,
.summary-note,
.policy-copy,
.empty-state p,
.meta-list dd,
.validation-list,
.report-list-item__meta {
color: var(--muted);
}
.sidebar-section {
display: grid;
gap: 12px;
}
.sidebar-link {
display: inline-flex;
justify-content: center;
color: var(--bs-primary) !important;
text-decoration: none;
}
.sidebar-link.is-active {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
color: #fff8f0;
}
.portal-shell {
/*
* The chooser page is intentionally sparse. Its only job is to separate entry
* points for operators and administrators without forcing a more complex auth
* design into the PoC before roles and identity are finalized.
*/
width: min(1100px, 100%);
display: grid;
gap: 24px;
}
.portal-hero {
padding: 32px;
}
.portal-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.portal-card {
display: grid;
gap: 16px;
padding: 28px;
text-decoration: none;
color: inherit;
}
.portal-copy {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.portal-button {
width: fit-content;
}
.grow-section {
min-height: 0;
flex: 1;
}
.status-row,
.section-heading-row,
.hero-actions,
.field-header,
.attachment-toolbar,
.report-list-item__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-heading-row {
align-items: baseline;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
}
.badge-neutral {
background: rgba(28, 26, 24, 0.08);
color: var(--text);
}
.badge-online,
.status-in_progress,
.status-ready_for_export {
background: rgba(37, 98, 76, 0.12);
color: var(--success);
}
.badge-offline,
.status-draft,
.status-archived {
background: rgba(138, 97, 25, 0.12);
color: var(--warning);
}
.badge-error,
.status-exported {
background: rgba(139, 46, 52, 0.12);
color: var(--danger);
}
.button {
border: 0;
border-radius: 999px;
padding: 12px 16px;
font-weight: 700;
cursor: pointer;
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.button:hover {
transform: translateY(-1px);
}
.button-primary {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
color: #fff8f0;
box-shadow: 0 12px 24px rgba(157, 61, 46, 0.25);
}
.button-secondary {
background: rgba(28, 26, 24, 0.08);
color: var(--text);
}
.button-ghost {
background: transparent;
color: var(--danger);
border: 1px solid rgba(139, 46, 52, 0.18);
}
.button-small {
padding: 8px 12px;
font-size: 0.84rem;
}
.workspace {
display: grid;
gap: 20px;
}
/* ── Workspace views (JS toggling for user page) ────────────────────────── */
.workspace-view {
/*
* User and admin workspaces share one HTML document. Hidden sections let the
* route control which workspace is visible while still reusing common styling
* and keeping asset delivery simple.
*/
display: none;
gap: 20px;
}
.workspace-view-active {
display: grid;
display: block;
}
.hero {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 24px 28px;
overflow: hidden;
position: relative;
}
/* ── Toast notification ─────────────────────────────────────────────────── */
.hero::after {
content: "";
position: absolute;
inset: auto -80px -80px auto;
width: 220px;
height: 220px;
border-radius: 50%;
background: radial-gradient(circle, rgba(157, 61, 46, 0.18), transparent 70%);
}
.hero-actions {
align-items: end;
flex-wrap: wrap;
}
.status-picker {
display: grid;
gap: 6px;
min-width: 220px;
}
.summary-grid,
.editor-grid {
/*
* These grids create a consistent rhythm between overview cards and working
* panels so the operator can scan status quickly before dropping into detail.
*/
display: grid;
gap: 20px;
}
.summary-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.summary-card {
padding: 20px 22px;
}
.accent-card {
background: linear-gradient(145deg, rgba(157, 61, 46, 0.12), rgba(255, 252, 247, 0.96));
}
.summary-label,
.field-label,
.meta-list dt {
margin: 0 0 8px;
color: var(--muted);
font-size: 0.84rem;
.admin-toast {
position: fixed;
top: 20px;
right: 20px;
z-index: 9000;
padding: 12px 20px;
border-radius: 999px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.88rem;
opacity: 0;
transform: translateY(-10px);
pointer-events: none;
transition: opacity 200ms ease, transform 200ms ease;
background: #198754;
color: #fff;
}
.editor-grid {
grid-template-columns: minmax(0, 1.7fr) minmax(300px, 380px);
.admin-toast-visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.editor-panel,
.inspector-panel {
padding: 24px;
}
.admin-form {
margin-top: 16px;
}
.admin-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.report-form {
/*
* The form styles are generic on purpose because fields are generated from
* template JSON. The same primitives must support report editing and admin
* configuration without each field type needing a dedicated page-specific skin.
*/
display: grid;
gap: 20px;
margin-top: 16px;
}
.template-section {
display: grid;
gap: 16px;
padding: 20px;
border-radius: var(--radius-md);
background: rgba(243, 239, 230, 0.68);
border: 1px solid rgba(93, 67, 35, 0.12);
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.field {
display: grid;
gap: 8px;
}
.field-full {
grid-column: 1 / -1;
}
.field-header {
align-items: baseline;
}
.required-pill {
color: var(--accent-strong);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.text-input,
.select-input,
.text-area,
.file-input {
width: 100%;
border: 1px solid rgba(93, 67, 35, 0.18);
background: rgba(255, 255, 255, 0.82);
color: var(--text);
border-radius: 14px;
padding: 12px 14px;
}
.text-input:focus,
.select-input:focus,
.text-area:focus,
.file-input:focus {
outline: 2px solid rgba(157, 61, 46, 0.22);
outline-offset: 2px;
}
.text-area {
min-height: 120px;
resize: vertical;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(93, 67, 35, 0.18);
}
.field-help {
margin: 0;
font-size: 0.86rem;
color: var(--muted);
}
/* ── Report list (index.html sidebar) ───────────────────────────────────── */
.report-list {
display: grid;
gap: 10px;
gap: 8px;
max-height: 420px;
overflow: auto;
padding-right: 4px;
}
/* F4 — Search and filter controls above the report list */
.report-filter-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.text-input-small,
.select-input-small {
padding: 8px 12px;
font-size: 0.88rem;
border-radius: 12px;
}
.select-input-small {
min-width: 120px;
}
.report-list-item {
width: 100%;
text-align: left;
border: 1px solid rgba(93, 67, 35, 0.12);
background: rgba(255, 255, 255, 0.74);
border-radius: 16px;
padding: 14px;
border: 1px solid rgba(0, 0, 0, 0.1);
background: #fff;
border-radius: 8px;
padding: 10px 12px;
display: grid;
gap: 4px;
cursor: pointer;
}
.report-list-item:hover {
background: #f8f9fa;
}
.report-list-item.is-active,
.report-list-item--active {
border-color: var(--bs-primary);
background: rgba(13, 110, 253, 0.05);
}
.report-list-item__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.report-list-item.is-active {
border-color: rgba(157, 61, 46, 0.36);
box-shadow: inset 0 0 0 1px rgba(157, 61, 46, 0.16);
background: rgba(244, 210, 191, 0.36);
}
.report-list-item__title {
font-size: 0.96rem;
font-size: 0.9rem;
}
.muted-count {
color: var(--muted);
font-size: 0.88rem;
.report-list-item__meta {
color: #6c757d;
font-size: 0.8rem;
}
.empty-state {
display: grid;
place-items: center;
min-height: 240px;
text-align: center;
padding: 24px;
border-radius: var(--radius-md);
background: rgba(243, 239, 230, 0.68);
border: 1px dashed rgba(93, 67, 35, 0.24);
/* ── Task record cards (user page) ──────────────────────────────────────── */
.task-record-card {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
}
.meta-list {
display: grid;
gap: 14px;
margin: 0;
.task-record-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.meta-list div {
display: grid;
gap: 4px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(93, 67, 35, 0.1);
.task-record-sort {
font-weight: 700;
font-size: 0.9rem;
color: var(--bs-primary);
}
.meta-list dd {
margin: 0;
.task-record-desc {
flex: 1;
font-size: 0.92rem;
}
.validation-block,
.attachment-policy {
margin-top: 26px;
.task-record-meta {
font-size: 0.8rem;
color: #6c757d;
}
.validation-list {
padding-left: 18px;
}
.validation-list li + li {
.task-record-fields {
margin-top: 8px;
}
.attachment-list {
display: grid;
gap: 12px;
.task-record-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
}
.attachment-card {
/*
* Attachments need enough visual weight to confirm that a photo is really tied
* to a report item. The card layout reserves space for preview, metadata, and
* removal action without requiring a modal or separate gallery screen.
*/
display: grid;
grid-template-columns: 88px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(93, 67, 35, 0.12);
.task-img-thumb {
position: relative;
display: inline-block;
}
.attachment-preview {
width: 88px;
height: 88px;
border-radius: 12px;
.task-img-thumb img {
width: 64px;
height: 64px;
object-fit: cover;
background: rgba(93, 67, 35, 0.08);
border-radius: 6px;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.attachment-card__copy {
.task-img-thumb .btn-remove {
position: absolute;
top: -4px;
right: -4px;
background: #fff;
border-radius: 50%;
width: 20px;
height: 20px;
padding: 0;
line-height: 20px;
text-align: center;
font-size: 0.8rem;
border: 1px solid rgba(0, 0, 0, 0.15);
}
/* ── Drop zone (drag & drop images) ────────────────────────────────────── */
.drop-zone {
cursor: pointer;
transition: border-color 150ms ease, background-color 150ms ease;
}
.drop-zone:hover {
border-color: var(--bs-primary) !important;
background: rgba(13, 110, 253, 0.03);
}
/* ── Bulk image drop zone (smaller for inline use) ────────────────────── */
.bulk-drop-zone {
cursor: pointer;
transition: border-color 150ms ease, background-color 150ms ease;
font-size: 0.8rem;
}
.bulk-drop-zone:hover {
border-color: var(--bs-primary) !important;
background: rgba(13, 110, 253, 0.05);
}
.bulk-img-thumb img {
border: 1px solid #dee2e6;
}
/* ── Portal page ────────────────────────────────────────────────────────── */
.portal-body {
display: grid;
gap: 4px;
min-width: 0;
place-items: center;
min-height: 100vh;
padding: 24px;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
}
.attachment-card__copy strong,
.attachment-card__copy span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* ── Connection badges (JS sets these classes) ──────────────────────────── */
.badge-online {
background-color: #198754 !important;
color: #fff !important;
}
@media (max-width: 1180px) {
/*
* Tablet and narrow laptop layouts collapse the two-column structure into a
* single column so the editing surface remains usable without horizontal scroll.
*/
.app-shell,
.editor-grid,
.summary-grid,
.portal-grid {
grid-template-columns: 1fr;
}
.badge-offline {
background-color: #ffc107 !important;
color: #000 !important;
}
.sidebar {
min-height: auto;
}
.badge-error {
background-color: #dc3545 !important;
color: #fff !important;
}
.report-list {
max-height: 260px;
/* ── Sub-category group styling ─────────────────────────────────────────── */
.sub-cat-header {
cursor: pointer;
user-select: none;
}
.sub-cat-chevron {
transition: transform 200ms ease;
}
.sub-cat-chevron.rotated {
transform: rotate(90deg);
}
/* ── Mobile hamburger button (hidden on desktop) ───────────────────────── */
.mobile-menu-btn {
display: none;
position: fixed;
bottom: 16px;
left: 16px;
z-index: 1060;
width: 48px;
height: 48px;
border-radius: 50%;
align-items: center;
justify-content: center;
font-size: 1.3rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
/* ── Responsive ─────────────────────────────────────────────────────────── */
/* Toggle button chevron rotation */
#toggleTaskInfoBtn .bi-chevron-up {
transition: transform 200ms ease;
}
#taskInfoCollapse:not(.show) ~ .d-md-none #toggleTaskInfoBtn .bi-chevron-up,
.collapsed .bi-chevron-up {
transform: rotate(180deg);
}
@media (max-width: 992px) {
.sidebar-bs {
width: 220px !important;
min-width: 220px !important;
}
}
@media (max-width: 760px) {
/*
* Mobile layout prioritizes single-column readability and larger preview areas.
* This matters because one of the project requirements is viable use on phones
* where camera capture and image attachment happen directly in the browser.
*/
.app-shell {
padding: 14px;
gap: 14px;
@media (max-width: 768px) {
.sidebar-bs {
display: flex !important;
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 260px !important;
min-width: 260px !important;
z-index: 1050;
transform: translateX(-100%);
transition: transform 250ms ease;
box-shadow: none;
}
.hero,
.hero-actions,
.field-grid,
.attachment-card {
grid-template-columns: 1fr;
display: grid;
.sidebar-bs.sidebar-open {
transform: translateX(0);
box-shadow: 4px 0 16px rgba(0,0,0,0.15);
}
.field-grid {
gap: 14px;
.sidebar-backdrop {
display: none;
position: fixed;
inset: 0;
z-index: 1040;
background: rgba(0,0,0,0.3);
}
.attachment-card {
grid-template-columns: 1fr;
.sidebar-backdrop.show {
display: block;
}
.attachment-preview {
width: 100%;
height: 180px;
.mobile-menu-btn {
display: flex !important;
}
}
+25
View File
@@ -0,0 +1,25 @@
/*
* User entry point — bootstraps the task-processing workspace.
*
* Opens the shared IndexedDB for admin entity cache, then initializes the
* user module which manages task processing: open task, fill in records,
* attach images, save as draft or final.
*/
import { state } from './js/state.js';
import { openDatabase } from './js/db.js';
import { initUser } from './js/user.js';
document.addEventListener('DOMContentLoaded', async () => {
/* Open the shared IndexedDB so admin entity cache is accessible. */
state.db = await openDatabase();
await initUser();
});
/* Re-init when navigating back (fixes stale localStorage after bfcache) */
window.addEventListener('pageshow', async (e) => {
if (e.persisted) {
state.db = await openDatabase();
await initUser();
}
});
+154 -155
View File
@@ -6,189 +6,188 @@
<meta name="theme-color" content="#f3efe6" />
<title>Check List PoC — User</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<!--
Operator workspace: report creation, local draft editing, validation,
image attachments, submission, and CSV export.
-->
<div class="app-shell">
<aside class="sidebar panel">
<div class="brand-block">
<p class="eyebrow">Hybrid Inspection Reporting</p>
<h1>Check List</h1>
<p class="lede">
Offline-first proof of concept for template-driven quality reports.
</p>
<div class="d-flex vh-100">
<!-- Mobile sidebar backdrop -->
<div id="sidebarBackdrop" class="sidebar-backdrop"></div>
<!-- Mobile menu button -->
<button id="mobileMenuBtn" class="mobile-menu-btn btn btn-primary" type="button" aria-label="Open menu"><i class="bi bi-list"></i></button>
<!-- Sidebar -->
<aside class="sidebar-bs d-flex flex-column border-end bg-light" style="width:260px;min-width:260px;">
<div class="p-3 border-bottom">
<p class="text-uppercase text-muted small fw-semibold mb-0">Hybrid Inspection Reporting</p>
<h5 class="fw-bold mb-0">Check List</h5>
<small class="text-muted">Task processing workspace</small>
</div>
<div class="sidebar-section">
<div class="status-row">
<span id="connectionBadge" class="badge badge-neutral">Checking connection</span>
<span id="saveBadge" class="badge badge-neutral">No changes</span>
</div>
<button id="syncTemplatesButton" class="button button-secondary" type="button">
Sync templates
</button>
<div class="p-3 border-bottom">
<span id="connectionBadge" class="badge bg-secondary">Checking…</span>
</div>
<div class="sidebar-section">
<label class="field-label" for="templateSelect">Template</label>
<select id="templateSelect" class="select-input"></select>
<button id="createReportButton" class="button button-primary" type="button">
Create new report
</button>
<div class="flex-grow-1 overflow-auto p-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="fw-semibold mb-0">My Tasks</h6>
<span id="taskCount" class="badge bg-secondary">0</span>
</div>
<div id="taskListSidebar"></div>
</div>
<div class="sidebar-section">
<div class="section-heading-row sidebar-links-heading">
<h2>Access</h2>
<span class="muted-count">Direct links</span>
</div>
<a id="userAreaLink" class="button button-secondary sidebar-link is-active" href="/user">User area</a>
<a id="adminAreaLink" class="button button-secondary sidebar-link" href="/admin">Admin area</a>
<a class="button button-secondary sidebar-link" href="/">Back to portal</a>
</div>
<div class="sidebar-section grow-section">
<div class="section-heading-row">
<h2>Local reports</h2>
<span id="reportCount" class="muted-count">0</span>
</div>
<div class="report-filter-row">
<input id="reportSearchInput" class="text-input text-input-small" type="search" placeholder="Search reports" />
<select id="reportFilterSelect" class="select-input select-input-small">
<option value="">All statuses</option>
<option value="draft">Draft</option>
<option value="in_progress">In Progress</option>
<option value="ready_for_export">Ready for Export</option>
<option value="exported">Exported</option>
<option value="archived">Archived</option>
</select>
</div>
<div id="reportList" class="report-list"></div>
<div class="p-3 border-top">
<button id="showSettingsBtn" class="btn btn-outline-primary btn-sm w-100 mb-1" type="button"><i class="bi bi-gear me-1"></i>Settings</button>
<a class="btn btn-secondary btn-sm w-100 mb-1" href="/user">User area</a>
<a class="btn btn-outline-secondary btn-sm w-100 mb-1" href="/admin">Admin area</a>
<a class="btn btn-outline-secondary btn-sm w-100" href="/">Back to portal</a>
</div>
</aside>
<main class="workspace">
<section id="reportsWorkspace" class="workspace-view workspace-view-active">
<section class="hero panel">
<div>
<p class="eyebrow">Proof of concept frontend</p>
<h2 id="heroTitle">No report selected</h2>
<p id="heroSubtitle" class="hero-copy">
Start by syncing templates and creating a local draft.
</p>
<!-- Main content -->
<main class="flex-grow-1 overflow-auto p-4 bg-white">
<!-- SETTINGS VIEW -->
<section id="settingsView" class="workspace-view">
<div class="mb-4">
<p class="text-muted small mb-0">User workspace</p>
<h3 class="fw-bold">Settings</h3>
<p class="text-muted">Configure your workspace preferences.</p>
</div>
<div class="hero-actions">
<label class="status-picker">
<span>Status</span>
<select id="reportStatusSelect" class="select-input">
<option value="draft">Draft</option>
<option value="in_progress">In Progress</option>
<option value="ready_for_export">Ready for Export</option>
<option value="exported">Exported</option>
<option value="archived">Archived</option>
<div class="card" style="max-width:480px">
<div class="card-body">
<h6 class="fw-semibold mb-3">Language</h6>
<p class="text-muted small">Choose the language for record descriptions.</p>
<select id="userLanguageSelect" class="form-select">
<option value="EN">English</option>
<option value="FR">Français</option>
<option value="NL">Nederlands</option>
</select>
</label>
<button id="submitReportButton" class="button button-secondary" type="button">
Submit
</button>
<button id="exportReportButton" class="button button-secondary" type="button">
Export CSV
</button>
<button id="deleteReportButton" class="button button-ghost" type="button">
Delete report
</button>
<button id="closeSettingsBtn" class="btn btn-primary btn-sm mt-3" type="button">Done</button>
</div>
</div>
</section>
<section class="summary-grid">
<article class="summary-card panel accent-card">
<p class="summary-label">Template</p>
<strong id="summaryTemplate">Not loaded</strong>
<span id="summaryVersion" class="summary-note">Version -</span>
</article>
<article class="summary-card panel">
<p class="summary-label">Validation</p>
<strong id="validationHeadline">No report selected</strong>
<span id="validationDetail" class="summary-note">Draft validation will appear here.</span>
</article>
<article class="summary-card panel">
<p class="summary-label">Offline cache</p>
<strong id="syncHeadline">No sync yet</strong>
<span id="syncDetail" class="summary-note">Templates are cached locally after the first successful sync.</span>
</article>
<!-- TASK LIST VIEW (shown by default) -->
<section id="taskListView" class="workspace-view workspace-view-active">
<div class="mb-4">
<p class="text-muted small mb-0">User workspace</p>
<h3 class="fw-bold">Assigned Tasks</h3>
<p class="text-muted">Select a task to begin processing.</p>
</div>
<div class="card">
<div class="card-body p-0">
<div id="taskListContainer"></div>
</div>
</div>
</section>
<section class="editor-grid">
<section class="panel editor-panel">
<div class="section-heading-row">
<h2>Report editor</h2>
<span id="editorHint" class="panel-note">Dynamic form rendering from template JSON</span>
<!-- TASK DETAIL VIEW (shown when a task is opened) -->
<section id="taskDetailView" class="workspace-view">
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<p class="text-muted small mb-0" id="taskDetailEyebrow">Task</p>
<h3 class="fw-bold" id="taskDetailTitle">-</h3>
<p class="text-muted" id="taskDetailSubtitle">-</p>
</div>
<form id="reportForm" class="report-form">
<div class="empty-state">
<h3>No report open</h3>
<p>Choose a template and create a report to start editing locally.</p>
</div>
</form>
</section>
<button id="backToListBtn" class="btn btn-outline-secondary btn-sm" type="button"><i class="bi bi-arrow-left me-1"></i>Back to tasks</button>
</div>
<aside class="panel inspector-panel">
<div class="section-heading-row">
<h2>Inspector view</h2>
<span class="panel-note">Local draft summary</span>
<!-- Task info summary cards (collapsible on small screens) -->
<div class="d-flex justify-content-between align-items-center mb-2 d-md-none">
<small class="text-muted fw-semibold">Task Info</small>
<button id="toggleTaskInfoBtn" class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#taskInfoCollapse" aria-expanded="true" aria-controls="taskInfoCollapse"><i class="bi bi-chevron-up"></i></button>
</div>
<div class="collapse show" id="taskInfoCollapse">
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card border-primary">
<div class="card-body py-2 px-3">
<small class="text-muted">Site Code</small>
<div class="fw-semibold" id="taskInfoSite">-</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body py-2 px-3">
<small class="text-muted">Project</small>
<div class="fw-semibold" id="taskInfoProject">-</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body py-2 px-3">
<small class="text-muted">Process</small>
<div class="fw-semibold" id="taskInfoProcess">-</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card">
<div class="card-body py-2 px-3">
<small class="text-muted">Status</small>
<div><span id="taskInfoStatus" class="badge bg-secondary">-</span></div>
</div>
</div>
</div>
</div>
<dl id="reportMeta" class="meta-list">
<div>
<dt>Report ID</dt>
<dd>-</dd>
</div>
<div>
<dt>Template</dt>
<dd>-</dd>
</div>
<div>
<dt>Created</dt>
<dd>-</dd>
</div>
<div>
<dt>Updated</dt>
<dd>-</dd>
</div>
</dl>
</div>
<div class="validation-block">
<h3>Validation issues</h3>
<ul id="validationList" class="validation-list">
<li>No report selected.</li>
</ul>
</div>
<!-- Visit date + records form -->
<div class="card mb-4">
<div class="card-body">
<form id="taskProcessingForm">
<div class="row g-3 mb-4">
<div class="col-md-4">
<label for="visitDate" class="form-label">Visit Date</label>
<input id="visitDate" class="form-control" type="date" />
<div class="form-text">Pick the inspection visit date.</div>
</div>
</div>
<div class="attachment-policy">
<h3>Image policy</h3>
<p id="imagePolicyText" class="policy-copy">
Load server configuration to see image limits and optimization rules.
</p>
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="fw-semibold mb-0">Records</h5>
<span id="taskRecordCount" class="badge bg-secondary">0 record(s)</span>
</div>
<!-- Search bar for records -->
<div class="mb-3">
<input id="recordSearchInput" class="form-control form-control-sm" type="search" placeholder="Search records (full text)…" />
</div>
<!-- Category tabs -->
<ul id="recordCategoryTabs" class="nav nav-tabs mb-3"></ul>
<!-- Records container (filtered by active tab + search) -->
<div id="taskRecordsContainer"></div>
<div class="d-flex gap-2 mt-4 pt-3 border-top">
<button id="saveDraftBtn" class="btn btn-outline-secondary" type="button">Save as Draft</button>
<button id="saveFinalBtn" class="btn btn-primary" type="button">Save as Final</button>
</div>
</form>
</div>
</aside>
</section>
</div>
<!-- Validation panel -->
<div id="taskValidationPanel" class="card border-warning" style="display:none">
<div class="card-header bg-warning-subtle fw-semibold">Validation Issues</div>
<div class="card-body">
<ul id="taskValidationList" class="mb-0"></ul>
</div>
</div>
</section>
</main>
</div>
<template id="reportListItemTemplate">
<button class="report-list-item" type="button" data-report-id="">
<span class="report-list-item__header">
<strong class="report-list-item__title"></strong>
<span class="report-list-item__status badge"></span>
</span>
<span class="report-list-item__meta"></span>
</button>
</template>
<script type="module" src="/app.js"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script type="module" src="/user-app.js"></script>
</body>
</html>