Working version before modification.
This commit is contained in:
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 ────────────────────────────────────────────────────────── */
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user