Working version before modification.

This commit is contained in:
Stan
2026-04-20 21:04:54 +02:00
parent 28d167f11f
commit e7127f3215
30 changed files with 7046 additions and 1201 deletions
+4
View File
@@ -0,0 +1,4 @@
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
+1
View File
@@ -26,6 +26,7 @@ services:
MARIADB_USER: ${DB_USER:-check_list_user}
MARIADB_PASSWORD: ${DB_PASSWORD:-check_list_password}
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-root_password}
MARIADB_EXTRA_FLAGS: --max-allowed-packet=64M
ports:
- "${DB_PORT:-3306}:3306"
volumes:
+20
View File
@@ -8,6 +8,7 @@
"name": "check-list-poc-api",
"version": "0.1.0",
"dependencies": {
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
@@ -143,6 +144,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+1
View File
@@ -13,6 +13,7 @@
"node": ">=20.0.0"
},
"dependencies": {
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.2",
+35
View File
@@ -0,0 +1,35 @@
admin page:
Settings
- Image policy
- Allowed mime types <- multiselect list with know image formats
- Max file size <- maximum files size in KB
- Max width <- maximum image width in PX
- Max height <- maximum image height in PX
- Image quality
- Oversize behavior <- dropdown that defines behavior if image does not match defined settings (keep actual options)
- Template
- Categories <- text field to add categories values for the check lists records. view should be in form of the list with edit and removal option.
- Sub categories <- text field to add sub categories for the check lists records. it is mandatory to define parent category when adding subcategory. view should be in form of the list with edit and removal option.
- Severities <- text field to add severities values for the check lists records. view should be in form of the list with edit and removal option.
- Statuses <- text field to add statuses values for the check lists records. view should be in form of the list with edit and removal option.
- Handled by <- text field to add severities values for the check lists records. view should be in form of the list with edit and removal option.
- Task
- Projects <- text field to add projects values for the check lists records. view should be in form of the list with edit and removal option.
- Processes <- text field to add processes values for the check lists records. it is mandatory to define parent project when adding process. view should be in form of the list with edit and removal option.
Users <- list view of the users with the button "Add user" on the top and option to edit and remove users from the list. In add user page it should be possible to define Email, Password, Name, Famili name, Company and role (CW, ANT or CW/ANT).
Sites <- list view of the sites with the button "Add site" on the top and option to edit and remove sites from the list. In add site page it should be possible to define Site Code, Host (OBE, PXS), OBE Site Code and PXS Site Code.
Check lists
- Templates <- list view with the "Add template" button on the top and option to edit and remove templates from the list. in add page it should be possible to define "Template name", Scope (CW, ANT, ANT_CPsite), Version, Valid from (date in form of dd/mm/yyyy and date picker), Valid till (date in form of dd/mm/yyyy and date picker) and a list of records assigned to the template with the checkbox in the first column.
- Records <- list view with the "Add record" button on the top and option to edit and remove record from the list. in add page it should be possible to define "Sort" (unique number), "Category" (dropdown vith values from the setting part), "Sub category" (dropdown with values from the setting part), "Severity" (dropdown with values from the setting part), "Image required" (checkbox that will indicate if user will have to add image to the record) "Description EN", "Description FR", "Description NL", "Status" (dropdown with values from the setting part but disabled from editing as it is just a placeholder here), "Handled by" (dropdown with values from the setting part but disabled from editing as it is just a placeholder here), "Comment" (disabled from editing as it is only a placeholder here).
Reports <- list view with the "Add task" button on the top and option to edit and remove task from the list. on the list status of the task should be visible. value for it will be taken from the user part. in add page it should be possible to assigne task to the user based on user, site, template, project and process.
user page:
Tasks <- list view with the assigned tasks and option to open task for processing. after opening user should see information like Site Code, Project, Process. User should be able to pickup "Visit date" from the date picker. User should see records that are assigned to the task template and should be able to set values of "Status", "Handled by", "Comment" and should be albe to add images to the record. There should be an option that will allow user to save task as draft or final. when user chose option draft no checks has to be made. if user chose to save as final, it should be check if all records have "Status" value set and if record has value of "NOK", "TBC" or "ADD work" if the "Handled by" and "Comment" has a value and if images are added if they are mandatory for the record (based on record checkbox value).
+32
View File
@@ -0,0 +1,32 @@
/*
* Admin entry point — bootstraps the admin console.
*
* Opens the shared IndexedDB, loads cached image rules, then hands control to
* the admin module which manages navigation, CRUD, and rendering for every
* admin category (Settings, Users, Sites, Check Lists, Reports).
*/
import { state } from './js/state.js';
import { openDatabase, dbGet } from './js/db.js';
import { STORE_CONFIG } from './js/constants.js';
import { initAdmin } from './js/admin.js';
document.addEventListener('DOMContentLoaded', async () => {
/* Open the shared IndexedDB so image-policy read/write keeps working. */
state.db = await openDatabase();
/* Load cached image rules (may be null on first visit). */
const configRow = await dbGet(STORE_CONFIG, 'imageRules');
/* Hand off to the admin module for all further initialization. */
await initAdmin({ imageRules: configRow?.value || null });
});
/* Re-init when navigating back (fixes stale localStorage after bfcache) */
window.addEventListener('pageshow', async (e) => {
if (e.persisted) {
state.db = await openDatabase();
const configRow = await dbGet(STORE_CONFIG, 'imageRules');
await initAdmin({ imageRules: configRow?.value || null });
}
});
+541 -145
View File
@@ -6,191 +6,587 @@
<meta name="theme-color" content="#f3efe6" />
<title>Check List PoC — Admin</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<!--
Administrator workspace: server-backed configuration editing for image
policies and other centrally managed settings.
-->
<div class="app-shell">
<aside class="sidebar panel">
<div class="brand-block">
<p class="eyebrow">Hybrid Inspection Reporting</p>
<h1>Check List</h1>
<p class="lede">
Offline-first proof of concept for template-driven quality reports.
</p>
<div class="d-flex vh-100">
<!-- Sidebar -->
<aside class="sidebar-bs d-flex flex-column border-end bg-light" style="width:260px;min-width:260px;">
<div class="p-3 border-bottom">
<p class="text-uppercase text-muted small fw-semibold mb-0">Hybrid Inspection Reporting</p>
<h5 class="fw-bold mb-0">Check List</h5>
<small class="text-muted">Administration console</small>
</div>
<div class="sidebar-section">
<div class="status-row">
<span id="connectionBadge" class="badge badge-neutral">Checking connection</span>
<span id="saveBadge" class="badge badge-neutral">No changes</span>
<div class="p-3 border-bottom">
<span id="connectionBadge" class="badge bg-secondary">Checking…</span>
</div>
<button id="syncTemplatesButton" class="button button-secondary" type="button">
Sync templates
<!-- 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>
<!-- 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>
<!-- 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="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>
</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>
<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>
<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>
<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" />
<span id="adminSyncState" class="badge bg-secondary">Server-backed</span>
</div>
<div class="field field-full">
<div class="field-header">
<label class="field-label" for="adminAllowedMimeTypes">Allowed MIME types</label>
<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>
<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 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>
<input id="adminMaxFileSizeMb" name="maxFileSizeMb" class="text-input" type="number" min="1" step="0.1" />
<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="field">
<div class="field-header">
<label class="field-label" for="adminMaxAttachmentsPerField">Max attachments per field</label>
<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>
<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 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>
<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">
<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>
</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="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>
</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>
<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 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>
</div>
</div>
</div>
</section>
<!-- 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="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>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
<!-- 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>
<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>
</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>
+173 -240
View File
@@ -6,67 +6,55 @@
<meta name="theme-color" content="#f3efe6" />
<title>Check List PoC</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<!--
This document is the shared app shell for both operator and administrator
routes. JavaScript decides which workspace to reveal based on the current
URL so the project can keep one frontend bundle while still presenting two
distinct entry points.
-->
<div class="app-shell">
<aside class="sidebar panel">
<!--
The sidebar keeps app-level actions visible across both workspaces:
sync status, template selection, navigation links, and the local draft
list. That supports quick report switching on small operational screens.
-->
<div class="brand-block">
<p class="eyebrow">Hybrid Inspection Reporting</p>
<h1>Check List</h1>
<p class="lede">
Offline-first proof of concept for template-driven quality reports.
</p>
<div class="d-flex vh-100">
<!-- Sidebar -->
<aside class="sidebar-bs d-flex flex-column border-end bg-light" style="width:280px;min-width:280px;">
<div class="p-3 border-bottom">
<p class="text-uppercase text-muted small fw-semibold mb-0">Hybrid Inspection Reporting</p>
<h5 class="fw-bold mb-0">Check List</h5>
<small class="text-muted">Offline-first proof of concept for template-driven quality reports.</small>
</div>
<div class="sidebar-section">
<div class="status-row">
<span id="connectionBadge" class="badge badge-neutral">Checking connection</span>
<span id="saveBadge" class="badge badge-neutral">No changes</span>
<div class="p-3 border-bottom">
<div class="d-flex gap-2 mb-2">
<span id="connectionBadge" class="badge bg-secondary">Checking connection</span>
<span id="saveBadge" class="badge bg-secondary">No changes</span>
</div>
<button id="syncTemplatesButton" class="button button-secondary" type="button">
Sync templates
<button id="syncTemplatesButton" class="btn btn-outline-secondary btn-sm w-100" type="button">
<i class="bi bi-arrow-repeat me-1"></i>Sync templates
</button>
</div>
<div class="sidebar-section">
<label class="field-label" for="templateSelect">Template</label>
<select id="templateSelect" class="select-input"></select>
<button id="createReportButton" class="button button-primary" type="button">
Create new report
<div class="p-3 border-bottom">
<label class="form-label small fw-semibold" for="templateSelect">Template</label>
<select id="templateSelect" class="form-select form-select-sm mb-2"></select>
<button id="createReportButton" class="btn btn-primary btn-sm w-100" type="button">
<i class="bi bi-plus-lg me-1"></i>Create new report
</button>
</div>
<div class="sidebar-section">
<div class="section-heading-row sidebar-links-heading">
<h2>Access</h2>
<span class="muted-count">Direct links</span>
</div>
<a id="userAreaLink" class="button button-secondary sidebar-link" href="/user">User area</a>
<a id="adminAreaLink" class="button button-secondary sidebar-link" href="/admin">Admin area</a>
<a class="button button-secondary sidebar-link" href="/">Back to portal</a>
<div class="p-3 border-bottom">
<a id="userAreaLink" class="btn btn-outline-secondary btn-sm w-100 mb-1" href="/user">User area</a>
<a id="adminAreaLink" class="btn btn-outline-secondary btn-sm w-100 mb-1" href="/admin">Admin area</a>
<a class="btn btn-outline-secondary btn-sm w-100" href="/">Back to portal</a>
</div>
<div class="sidebar-section grow-section">
<div class="section-heading-row">
<h2>Local reports</h2>
<span id="reportCount" class="muted-count">0</span>
<div class="flex-grow-1 overflow-auto p-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="fw-semibold mb-0">Local reports</h6>
<span id="reportCount" class="badge bg-secondary">0</span>
</div>
<!-- F4 — Search and status filter for the local report list -->
<div class="report-filter-row">
<input id="reportSearchInput" class="text-input text-input-small" type="search" placeholder="Search reports" />
<select id="reportFilterSelect" class="select-input select-input-small">
<div class="mb-2">
<input id="reportSearchInput" class="form-control form-control-sm mb-1" type="search" placeholder="Search reports" />
<select id="reportFilterSelect" class="form-select form-select-sm">
<option value="">All statuses</option>
<option value="draft">Draft</option>
<option value="in_progress">In Progress</option>
@@ -79,21 +67,21 @@
</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 class="d-flex justify-content-between align-items-start mb-4">
<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>
<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">
<label class="status-picker">
<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="select-input">
<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>
@@ -101,237 +89,182 @@
<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>
<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>
<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>
<!-- 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>
</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>
</div>
</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>
</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="empty-state">
<h3>No report open</h3>
<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>
</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>
<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 class="validation-block">
<h3>Validation issues</h3>
<ul id="validationList" class="validation-list">
</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 class="attachment-policy">
<h3>Image policy</h3>
<p id="imagePolicyText" class="policy-copy">
</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>
</aside>
</section>
</div>
</div>
</div>
</section>
<!-- Administrator workspace: server-backed configuration editing. -->
<!-- Admin workspace (legacy) -->
<section id="adminWorkspace" class="workspace-view" hidden>
<section class="hero panel">
<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>
</div>
<div class="hero-actions">
<span id="adminSyncState" class="badge badge-neutral">Server-backed settings</span>
</div>
</section>
<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>
<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 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="field field-full">
<div class="field-header">
<label class="field-label" for="adminAllowedMimeTypes">Allowed MIME types</label>
<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>
<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 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="field">
<div class="field-header">
<label class="field-label" for="adminMaxFileSizeMb">Max file size (MB)</label>
<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>
<input id="adminMaxFileSizeMb" name="maxFileSizeMb" class="text-input" type="number" min="1" step="0.1" />
<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="field">
<div class="field-header">
<label class="field-label" for="adminMaxAttachmentsPerField">Max attachments per field</label>
<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>
<input id="adminMaxAttachmentsPerField" name="maxAttachmentsPerField" class="text-input" type="number" min="1" step="1" />
<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="field">
<div class="field-header">
<label class="field-label" for="adminMaxWidthPx">Max width (px)</label>
<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>
<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">
<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>
</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="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>
</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>
<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 class="validation-block">
<h3>Admin notes</h3>
<ul class="validation-list" id="adminNotesList">
</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>
</aside>
</section>
</div>
</div>
</div>
</section>
</main>
</div>
<!--
Report list items are rendered from this template at runtime so the sidebar
can update without rebuilding the entire page markup from strings.
-->
<template id="reportListItemTemplate">
<button class="report-list-item" type="button" data-report-id="">
<span class="report-list-item__header">
+1788
View File
File diff suppressed because it is too large Load Diff
+283
View File
@@ -0,0 +1,283 @@
/*
* exif.js — Lightweight EXIF parser for JPEG images in the browser.
*
* Extracts basic EXIF metadata (camera make/model, date taken, GPS coords,
* orientation, dimensions) from a base64 dataUrl or ArrayBuffer.
*
* This is a minimal parser — it handles the most common IFD0 and GPS tags
* without pulling in a full library.
*/
/* ── EXIF Tag IDs ───────────────────────────────────────────────────────── */
const TAGS = {
0x010F: 'make',
0x0110: 'model',
0x0112: 'orientation',
0x011A: 'xResolution',
0x011B: 'yResolution',
0x0132: 'dateTime',
0x8769: 'exifOffset',
0x8825: 'gpsOffset',
0xA002: 'pixelXDimension',
0xA003: 'pixelYDimension',
0x9003: 'dateTimeOriginal',
0x9004: 'dateTimeDigitized',
0x920A: 'focalLength',
0x829A: 'exposureTime',
0x829D: 'fNumber',
0x8827: 'isoSpeed'
};
const GPS_TAGS = {
0x0001: 'latRef',
0x0002: 'lat',
0x0003: 'lonRef',
0x0004: 'lon',
0x0005: 'altRef',
0x0006: 'alt'
};
/* ── Public API ─────────────────────────────────────────────────────────── */
/**
* Parse EXIF from a base64 dataUrl string.
* Returns an object with parsed tags, or null if no EXIF found.
*/
export function parseExifFromDataUrl(dataUrl) {
if (!dataUrl || !dataUrl.startsWith('data:image/jpeg')) return null;
try {
const base64 = dataUrl.split(',')[1];
const binary = atob(base64);
const buffer = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) buffer[i] = binary.charCodeAt(i);
return parseExif(buffer.buffer);
} catch {
return null;
}
}
/**
* Parse EXIF from an ArrayBuffer.
* Returns an object with parsed tags, or null if no EXIF found.
*/
export function parseExif(arrayBuffer) {
const view = new DataView(arrayBuffer);
/* Check JPEG SOI marker */
if (view.getUint16(0) !== 0xFFD8) return null;
/* Find APP1 marker (EXIF) */
let offset = 2;
while (offset < view.byteLength - 4) {
const marker = view.getUint16(offset);
if (marker === 0xFFE1) {
/* Found APP1 */
const length = view.getUint16(offset + 2);
return parseExifData(view, offset + 4, length - 2);
}
/* Skip to next marker */
if ((marker & 0xFF00) !== 0xFF00) break;
const segLen = view.getUint16(offset + 2);
offset += 2 + segLen;
}
return null;
}
/* ── Internal parsing ───────────────────────────────────────────────────── */
function parseExifData(view, start, length) {
/* Check "Exif\0\0" header */
const exifStr = String.fromCharCode(
view.getUint8(start), view.getUint8(start + 1),
view.getUint8(start + 2), view.getUint8(start + 3)
);
if (exifStr !== 'Exif') return null;
const tiffStart = start + 6;
const byteOrder = view.getUint16(tiffStart);
const littleEndian = byteOrder === 0x4949; /* 'II' = Intel = little-endian */
/* Verify TIFF magic number */
if (view.getUint16(tiffStart + 2, littleEndian) !== 0x002A) return null;
const ifd0Offset = view.getUint32(tiffStart + 4, littleEndian);
const result = {};
/* Parse IFD0 */
const ifd0 = parseIFD(view, tiffStart, tiffStart + ifd0Offset, littleEndian, TAGS);
Object.assign(result, ifd0);
/* Parse EXIF sub-IFD if present */
if (ifd0.exifOffset) {
const exifIfd = parseIFD(view, tiffStart, tiffStart + ifd0.exifOffset, littleEndian, TAGS);
delete result.exifOffset;
Object.assign(result, exifIfd);
}
/* Parse GPS IFD if present */
if (ifd0.gpsOffset) {
const gpsIfd = parseIFD(view, tiffStart, tiffStart + ifd0.gpsOffset, littleEndian, GPS_TAGS);
delete result.gpsOffset;
if (gpsIfd.lat && gpsIfd.latRef) {
result.latitude = convertDMSToDD(gpsIfd.lat, gpsIfd.latRef);
}
if (gpsIfd.lon && gpsIfd.lonRef) {
result.longitude = convertDMSToDD(gpsIfd.lon, gpsIfd.lonRef);
}
if (gpsIfd.alt != null) {
result.altitude = gpsIfd.altRef === 1 ? -gpsIfd.alt : gpsIfd.alt;
}
}
/* Clean up internal offsets */
delete result.exifOffset;
delete result.gpsOffset;
return Object.keys(result).length ? result : null;
}
function parseIFD(view, tiffStart, ifdStart, littleEndian, tagMap) {
const result = {};
try {
const entries = view.getUint16(ifdStart, littleEndian);
for (let i = 0; i < entries; i++) {
const entryOffset = ifdStart + 2 + (i * 12);
const tag = view.getUint16(entryOffset, littleEndian);
const tagName = tagMap[tag];
if (!tagName) continue;
const type = view.getUint16(entryOffset + 2, littleEndian);
const count = view.getUint32(entryOffset + 4, littleEndian);
const valueOffset = entryOffset + 8;
result[tagName] = readTagValue(view, tiffStart, type, count, valueOffset, littleEndian);
}
} catch {
/* Gracefully handle malformed EXIF */
}
return result;
}
function readTagValue(view, tiffStart, type, count, valueOffset, littleEndian) {
/* Type: 1=BYTE, 2=ASCII, 3=SHORT, 4=LONG, 5=RATIONAL, 7=UNDEFINED, 10=SRATIONAL */
const typeSize = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 };
const totalBytes = (typeSize[type] || 1) * count;
const dataOffset = totalBytes > 4
? tiffStart + view.getUint32(valueOffset, littleEndian)
: valueOffset;
switch (type) {
case 2: { /* ASCII string */
let str = '';
for (let i = 0; i < count - 1; i++) str += String.fromCharCode(view.getUint8(dataOffset + i));
return str.trim();
}
case 3: /* SHORT */
return count === 1 ? view.getUint16(dataOffset, littleEndian) : readArray(view, dataOffset, count, 2, littleEndian);
case 4: /* LONG */
return count === 1 ? view.getUint32(dataOffset, littleEndian) : readArray(view, dataOffset, count, 4, littleEndian);
case 5: /* RATIONAL (unsigned) */
if (count === 1) {
const num = view.getUint32(dataOffset, littleEndian);
const den = view.getUint32(dataOffset + 4, littleEndian);
return den ? num / den : 0;
}
return readRationalArray(view, dataOffset, count, littleEndian);
case 10: /* SRATIONAL (signed) */
if (count === 1) {
const num = view.getInt32(dataOffset, littleEndian);
const den = view.getInt32(dataOffset + 4, littleEndian);
return den ? num / den : 0;
}
return readRationalArray(view, dataOffset, count, littleEndian);
default:
return count === 1 ? view.getUint8(dataOffset) : null;
}
}
function readArray(view, offset, count, size, littleEndian) {
const arr = [];
for (let i = 0; i < count; i++) {
arr.push(size === 2 ? view.getUint16(offset + i * size, littleEndian) : view.getUint32(offset + i * size, littleEndian));
}
return arr;
}
function readRationalArray(view, offset, count, littleEndian) {
const arr = [];
for (let i = 0; i < count; i++) {
const num = view.getUint32(offset + i * 8, littleEndian);
const den = view.getUint32(offset + i * 8 + 4, littleEndian);
arr.push(den ? num / den : 0);
}
return arr;
}
function convertDMSToDD(dms, ref) {
if (!Array.isArray(dms) || dms.length < 3) return null;
let dd = dms[0] + dms[1] / 60 + dms[2] / 3600;
if (ref === 'S' || ref === 'W') dd = -dd;
return Math.round(dd * 1000000) / 1000000;
}
/* ═══════════════════════════════════════════════════════════════════════════
* EXIF preservation: extract raw APP1 segment and re-inject into JPEG
* ═══════════════════════════════════════════════════════════════════════════ */
/**
* Extract the raw EXIF APP1 segment (including FF E1 marker + length) from a
* JPEG ArrayBuffer. Returns a Uint8Array of the full segment, or null if no
* EXIF is found. This raw segment can be re-injected into a new JPEG to
* preserve metadata after canvas operations.
*/
export function extractExifSegment(arrayBuffer) {
const view = new DataView(arrayBuffer);
if (view.byteLength < 4) return null;
/* Check JPEG SOI marker */
if (view.getUint16(0) !== 0xFFD8) return null;
let offset = 2;
while (offset < view.byteLength - 4) {
const marker = view.getUint16(offset);
if (marker === 0xFFE1) {
/* APP1 found — extract the entire segment (marker + length + data) */
const segmentLength = view.getUint16(offset + 2);
const totalLength = 2 + segmentLength; /* marker (2) + length field included in segmentLength */
if (offset + totalLength > view.byteLength) return null;
/* Return a standalone copy so it survives GC of the original buffer */
return new Uint8Array(arrayBuffer.slice(offset, offset + totalLength));
}
/* Not APP1 — skip to next marker */
if ((marker & 0xFF00) !== 0xFF00) break;
const segLen = view.getUint16(offset + 2);
offset += 2 + segLen;
}
return null;
}
/**
* Insert a raw APP1 EXIF segment into a JPEG Blob that lacks one.
* Places the APP1 segment immediately after the SOI marker (FF D8).
* Returns a new Blob with EXIF restored, or the original blob if it's not JPEG
* or if exifSegment is null.
*/
export async function insertExifIntoJpeg(jpegBlob, exifSegment) {
if (!exifSegment || !jpegBlob || jpegBlob.type !== 'image/jpeg') return jpegBlob;
const buffer = await jpegBlob.arrayBuffer();
const view = new DataView(buffer);
if (view.getUint16(0) !== 0xFFD8) return jpegBlob;
/* Build new JPEG: SOI (2 bytes) + EXIF segment + rest of original after SOI */
const soi = new Uint8Array(buffer, 0, 2);
const rest = new Uint8Array(buffer, 2);
const merged = new Uint8Array(soi.length + exifSegment.length + rest.length);
merged.set(soi, 0);
merged.set(exifSegment, 2);
merged.set(rest, 2 + exifSegment.length);
return new Blob([merged], { type: 'image/jpeg' });
}
+26 -5
View File
@@ -4,8 +4,14 @@
* during large-image processing. On browsers that lack OffscreenCanvas support
* (or when running inside a Worker is not possible) the module falls back to
* main-thread canvas operations.
*
* EXIF preservation: Canvas operations strip all metadata. After resize/compress
* we re-inject the original EXIF APP1 segment into the output JPEG so that
* camera info, GPS, date-taken etc. survive the optimization.
*/
import { extractExifSegment, insertExifIntoJpeg } from './exif.js';
let worker = null;
let workerSupported = null;
@@ -35,6 +41,8 @@ function getWorker() {
/*
* Public entry point. Validates the file against the image rules, then delegates
* the actual resize/compress work to the worker or the main-thread fallback.
* After optimization, EXIF metadata from the original file is re-injected into
* the output JPEG so that camera/GPS/date info is preserved.
*/
export async function optimizeImage(file, imageRules) {
if (imageRules?.allowedMimeTypes?.length && !imageRules.allowedMimeTypes.includes(file.type)) {
@@ -45,13 +53,26 @@ export async function optimizeImage(file, imageRules) {
throw new Error(`File exceeds limit: ${file.name}`);
}
const w = getWorker();
if (w) {
return optimizeInWorker(w, file, imageRules);
/* Extract raw EXIF segment from original JPEG before canvas strips it */
let exifSegment = null;
if (file.type === 'image/jpeg') {
try {
const originalBuffer = await file.arrayBuffer();
exifSegment = extractExifSegment(originalBuffer);
} catch { /* non-critical — proceed without EXIF */ }
}
return optimizeOnMainThread(file, imageRules);
const w = getWorker();
const result = w
? await optimizeInWorker(w, file, imageRules)
: await optimizeOnMainThread(file, imageRules);
/* Re-inject EXIF into the optimized JPEG */
if (exifSegment && result.blob.type === 'image/jpeg') {
result.blob = await insertExifIntoJpeg(result.blob, exifSegment);
}
return result;
}
/* ── Worker path ────────────────────────────────────────────────────────── */
+173
View File
@@ -0,0 +1,173 @@
/*
* user-db.js — IndexedDB storage for the user portal.
*
* Stores task data (including heavy base64 image dataUrls) in IndexedDB
* instead of localStorage to avoid the ~5 MB browser quota.
*
* IndexedDB provides hundreds of MB of storage (browser-managed, quota-based)
* which makes it suitable for image-heavy task data.
*
* Usage:
* import { openUserDB, loadTaskData, saveTaskData, getStorageEstimate } from './user-db.js';
*
* await openUserDB(); // Call once on init
* const data = await loadTaskData(); // Returns the full taskData object
* await saveTaskData(data); // Persist updated taskData
* const est = await getStorageEstimate(); // Get usage info
*/
/* ── Constants ──────────────────────────────────────────────────────────── */
const DB_NAME = 'user-portal-db';
const DB_VERSION = 1;
const STORE_TASK_DATA = 'taskData';
/* ── Module-level DB reference ──────────────────────────────────────────── */
let db = null;
/* ── Open / create database ─────────────────────────────────────────────── */
/**
* Opens the IndexedDB database. Must be called once before any read/write.
* Creates the object store on first run or version upgrade.
*/
export function openUserDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
db = request.result;
resolve(db);
};
request.onupgradeneeded = () => {
const database = request.result;
/* Single store keyed by taskId — each entry holds one task's data */
if (!database.objectStoreNames.contains(STORE_TASK_DATA)) {
database.createObjectStore(STORE_TASK_DATA, { keyPath: 'taskId' });
}
};
});
}
/* ── Read all task data ─────────────────────────────────────────────────── */
/**
* Loads all task data from IndexedDB and returns it as a plain object
* keyed by taskId (same shape as the old localStorage structure).
*
* @returns {Promise<Object>} e.g. { "task-123": { visitDate: "", records: {...} }, ... }
*/
export function loadTaskData() {
return new Promise((resolve, reject) => {
if (!db) { resolve({}); return; }
const tx = db.transaction(STORE_TASK_DATA, 'readonly');
const store = tx.objectStore(STORE_TASK_DATA);
const request = store.getAll();
request.onsuccess = () => {
const result = {};
for (const entry of request.result) {
const { taskId, ...data } = entry;
result[taskId] = data;
}
resolve(result);
};
request.onerror = () => reject(request.error);
});
}
/* ── Save all task data ─────────────────────────────────────────────────── */
/**
* Persists the full taskData object into IndexedDB.
* Each taskId becomes a separate record in the store for efficient access.
*
* @param {Object} taskData - Object keyed by taskId
* @returns {Promise<void>}
*/
export function saveTaskData(taskData) {
return new Promise((resolve, reject) => {
if (!db) { reject(new Error('Database not open')); return; }
const tx = db.transaction(STORE_TASK_DATA, 'readwrite');
const store = tx.objectStore(STORE_TASK_DATA);
for (const [taskId, data] of Object.entries(taskData)) {
store.put({ taskId, ...data });
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/* ── Save single task entry ─────────────────────────────────────────────── */
/**
* Saves or updates a single task's data. More efficient than saving everything
* when only one task changed.
*
* @param {string} taskId
* @param {Object} data - The task data (visitDate, records, etc.)
* @returns {Promise<void>}
*/
export function saveOneTaskData(taskId, data) {
return new Promise((resolve, reject) => {
if (!db) { reject(new Error('Database not open')); return; }
const tx = db.transaction(STORE_TASK_DATA, 'readwrite');
const store = tx.objectStore(STORE_TASK_DATA);
store.put({ taskId, ...data });
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/* ── Delete a task entry ────────────────────────────────────────────────── */
/**
* Removes a single task's data from IndexedDB.
*
* @param {string} taskId
* @returns {Promise<void>}
*/
export function deleteTaskData(taskId) {
return new Promise((resolve, reject) => {
if (!db) { reject(new Error('Database not open')); return; }
const tx = db.transaction(STORE_TASK_DATA, 'readwrite');
const store = tx.objectStore(STORE_TASK_DATA);
store.delete(taskId);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
/* ── Storage estimate ───────────────────────────────────────────────────── */
/**
* Returns an estimate of IndexedDB usage (if the StorageManager API is available).
* Falls back to counting serialized task data size.
*
* @param {Object} taskData - Current in-memory taskData for fallback sizing
* @returns {Promise<{usedMB: string, quotaMB: string, pct: number}>}
*/
export async function getStorageEstimate(taskData) {
/* Try the modern Storage API (available in secure contexts) */
if (navigator.storage && navigator.storage.estimate) {
const est = await navigator.storage.estimate();
const usedMB = ((est.usage || 0) / (1024 * 1024)).toFixed(2);
const quotaMB = ((est.quota || 0) / (1024 * 1024)).toFixed(0);
const pct = est.quota ? Math.min(100, ((est.usage / est.quota) * 100)) : 0;
return { usedMB, quotaMB, pct: Math.round(pct) };
}
/* Fallback: estimate from serialized data */
const json = JSON.stringify(taskData || {});
const bytes = json.length * 2; /* UTF-16 */
const usedMB = (bytes / (1024 * 1024)).toFixed(2);
return { usedMB, quotaMB: '∞', pct: 0 };
}
+1588
View File
File diff suppressed because it is too large Load Diff
+131
View File
@@ -0,0 +1,131 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f3efe6" />
<title>Check List PoC — Admin Login</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
<style>
body {
font-family: "Inter", "Segoe UI", sans-serif;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-card {
max-width: 400px;
width: 100%;
}
.login-header {
text-align: center;
margin-bottom: 24px;
}
.login-header h1 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 8px;
}
.login-header p {
color: #6c757d;
font-size: 0.9rem;
}
.alert {
display: none;
}
.alert.show {
display: block;
}
</style>
</head>
<body>
<div class="login-card">
<div class="card shadow-sm">
<div class="card-body p-4">
<div class="login-header">
<i class="bi bi-shield-lock fs-1 text-primary mb-2 d-block"></i>
<h1>Admin Login</h1>
<p>Sign in to access the administration console</p>
</div>
<div id="errorAlert" class="alert alert-danger" role="alert"></div>
<form id="loginForm">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required autofocus />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required />
</div>
<button type="submit" class="btn btn-primary w-100" id="loginBtn">
<span id="loginText">Sign In</span>
<span id="loginSpinner" class="spinner-border spinner-border-sm d-none" role="status"></span>
</button>
</form>
<div class="text-center mt-4">
<a href="/" class="text-decoration-none"><i class="bi bi-arrow-left me-1"></i>Back to portal</a>
</div>
</div>
</div>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const errorAlert = document.getElementById('errorAlert');
const loginBtn = document.getElementById('loginBtn');
const loginText = document.getElementById('loginText');
const loginSpinner = document.getElementById('loginSpinner');
/* Hide previous error */
errorAlert.classList.remove('show');
/* Show loading state */
loginBtn.disabled = true;
loginText.textContent = 'Signing in...';
loginSpinner.classList.remove('d-none');
try {
const response = await fetch('/api/v1/auth/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (data.success) {
/* Store token in sessionStorage for API calls */
sessionStorage.setItem('auth_token', data.token);
sessionStorage.setItem('auth_type', 'admin');
/* Redirect to admin page */
window.location.href = '/admin';
} else {
errorAlert.textContent = data.message || 'Login failed. Please check your credentials.';
errorAlert.classList.add('show');
}
} catch (err) {
errorAlert.textContent = 'Network error. Please try again.';
errorAlert.classList.add('show');
} finally {
loginBtn.disabled = false;
loginText.textContent = 'Sign In';
loginSpinner.classList.add('d-none');
}
});
</script>
</body>
</html>
+132
View File
@@ -0,0 +1,132 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f3efe6" />
<title>Check List PoC — User Login</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
<style>
body {
font-family: "Inter", "Segoe UI", sans-serif;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-card {
max-width: 400px;
width: 100%;
}
.login-header {
text-align: center;
margin-bottom: 24px;
}
.login-header h1 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 8px;
}
.login-header p {
color: #6c757d;
font-size: 0.9rem;
}
.alert {
display: none;
}
.alert.show {
display: block;
}
</style>
</head>
<body>
<div class="login-card">
<div class="card shadow-sm">
<div class="card-body p-4">
<div class="login-header">
<i class="bi bi-person-circle fs-1 text-primary mb-2 d-block"></i>
<h1>User Login</h1>
<p>Sign in to access your task workspace</p>
</div>
<div id="errorAlert" class="alert alert-danger" role="alert"></div>
<form id="loginForm">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" required autofocus />
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required />
</div>
<button type="submit" class="btn btn-primary w-100" id="loginBtn">
<span id="loginText">Sign In</span>
<span id="loginSpinner" class="spinner-border spinner-border-sm d-none" role="status"></span>
</button>
</form>
<div class="text-center mt-4">
<a href="/" class="text-decoration-none"><i class="bi bi-arrow-left me-1"></i>Back to portal</a>
</div>
</div>
</div>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
const errorAlert = document.getElementById('errorAlert');
const loginBtn = document.getElementById('loginBtn');
const loginText = document.getElementById('loginText');
const loginSpinner = document.getElementById('loginSpinner');
/* Hide previous error */
errorAlert.classList.remove('show');
/* Show loading state */
loginBtn.disabled = true;
loginText.textContent = 'Signing in...';
loginSpinner.classList.remove('d-none');
try {
const response = await fetch('/api/v1/auth/user/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await response.json();
if (data.success) {
/* Store token and user info in sessionStorage */
sessionStorage.setItem('auth_token', data.token);
sessionStorage.setItem('auth_type', 'user');
sessionStorage.setItem('auth_user', JSON.stringify(data.user));
/* Redirect to user page with userId */
window.location.href = '/user?userId=' + data.user.id;
} else {
errorAlert.textContent = data.message || 'Login failed. Please check your credentials.';
errorAlert.classList.add('show');
}
} catch (err) {
errorAlert.textContent = 'Network error. Please try again.';
errorAlert.classList.add('show');
} finally {
loginBtn.disabled = false;
loginText.textContent = 'Sign In';
loginSpinner.classList.add('d-none');
}
});
</script>
</body>
</html>
+125 -33
View File
@@ -5,44 +5,136 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#f3efe6" />
<title>Check List Portal</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body class="portal-body">
<!--
The portal intentionally acts as a simple role chooser rather than a real
authentication page. It separates user and admin entry points in the PoC
without committing the project to a security model too early.
-->
<main class="portal-shell">
<section class="portal-hero panel">
<p class="eyebrow">Check List Access</p>
<h1>Choose workspace</h1>
<p class="portal-copy">
Use the operator workspace for quality reports and the administrator workspace for configuration.
</p>
</section>
<body class="portal-body d-flex align-items-center justify-content-center min-vh-100">
<main class="container" style="max-width: 720px;">
<div class="text-center mb-4">
<p class="text-uppercase text-muted small fw-semibold mb-1">Check List Access</p>
<h1 class="fw-bold">Choose workspace</h1>
<p class="text-muted">Select a user, then open the operator or administrator workspace.</p>
</div>
<section class="portal-grid">
<!-- Direct operator entry for report creation and local draft work. -->
<a class="portal-card panel" href="/user">
<p class="summary-label">User area</p>
<h2>Operator workspace</h2>
<p class="portal-copy">
Create reports, work offline, attach images, and manage local drafts.
</p>
<span class="button button-primary portal-button">Open user area</span>
</a>
<!-- User selection -->
<div class="card mb-4">
<div class="card-body">
<label for="portalUserSelect" class="form-label fw-semibold">Select User</label>
<select id="portalUserSelect" class="form-select">
<option value="">— Choose a user to see their tasks —</option>
</select>
</div>
</div>
<!-- Direct administrator entry for centrally managed configuration. -->
<a class="portal-card panel" href="/admin">
<p class="summary-label">Admin area</p>
<h2>Administrator workspace</h2>
<p class="portal-copy">
Maintain image requirements and other centrally managed configuration.
</p>
<span class="button button-secondary portal-button">Open admin area</span>
<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>
</section>
</div>
<!-- Administrator workspace -->
<div class="col-md-6">
<a class="card text-decoration-none h-100 portal-card" href="/admin">
<div class="card-body text-center">
<i class="bi bi-gear fs-1 text-secondary mb-2 d-block"></i>
<p class="text-muted small mb-1">Admin area</p>
<h5 class="fw-bold">Administrator workspace</h5>
<p class="text-muted small">Manage settings, users, sites, templates, and tasks.</p>
<span class="btn btn-outline-secondary btn-sm mt-2">Open admin area</span>
</div>
</a>
</div>
</div>
<!-- Data cleanup -->
<div class="card mt-4">
<div class="card-body d-flex align-items-center justify-content-between">
<div>
<h6 class="fw-semibold mb-1"><i class="bi bi-trash3 me-1"></i>Data Cleanup</h6>
<p class="text-muted small mb-0">Remove all localStorage data and reset the application.</p>
</div>
<button id="cleanupBtn" class="btn btn-outline-danger btn-sm">Clear all data</button>
</div>
</div>
</main>
<script>
// Populate user dropdown from server API
(function() {
const sel = document.getElementById('portalUserSelect');
const userLink = document.getElementById('portalUserLink');
/* Prevent opening user area without selecting a user */
userLink.addEventListener('click', (e) => {
if (!sel.value) {
e.preventDefault();
sel.focus();
sel.classList.add('is-invalid');
setTimeout(() => sel.classList.remove('is-invalid'), 2000);
}
});
async function populateUsers() {
try {
const resp = await fetch('/api/v1/admin/users', { headers: { Accept: 'application/json' } });
if (!resp.ok) throw new Error('Failed to load users');
const data = await resp.json();
const users = data.items || [];
sel.innerHTML = '<option value="">— Choose a user to see their tasks —</option>';
users.forEach(u => {
const opt = document.createElement('option');
opt.value = u.id;
opt.textContent = `${u.name} ${u.familyName} (${u.email}) — ${u.role}`;
sel.appendChild(opt);
});
const saved = localStorage.getItem('portal_selected_user');
if (saved) sel.value = saved;
updateUserLink();
} catch (err) {
console.warn('Could not load users from server:', err.message);
}
}
sel.addEventListener('change', () => {
localStorage.setItem('portal_selected_user', sel.value);
updateUserLink();
});
function updateUserLink() {
const uid = sel.value;
userLink.href = uid ? `/user?userId=${encodeURIComponent(uid)}` : '#';
}
populateUsers();
/* Re-populate when navigating back (fixes stale data after bfcache) */
window.addEventListener('pageshow', (e) => { if (e.persisted) populateUsers(); });
// Cleanup button
document.getElementById('cleanupBtn').addEventListener('click', () => {
if (!confirm('This will remove ALL application data (cached data, settings). Continue?')) return;
localStorage.removeItem('portal_selected_user');
localStorage.removeItem('user_language');
// Clear IndexedDB
indexedDB.databases().then(dbs => {
dbs.forEach(db => indexedDB.deleteDatabase(db.name));
}).catch(() => {});
alert('All data cleared. Page will reload.');
location.reload();
});
})();
</script>
</body>
</html>
+247 -548
View File
@@ -1,638 +1,337 @@
/* ═══════════════════════════════════════════════════════════════════════════
* Check List PoC — Custom styles (Bootstrap 5 handles layout + components)
* Only app-specific overrides and classes that JS depends on live here.
* ═══════════════════════════════════════════════════════════════════════════ */
:root {
/*
* The visual system uses a warm industrial palette instead of generic neutral
* SaaS colors. The goal is to make the PoC feel closer to an operational tool
* used in inspection environments than to a stock admin dashboard.
*/
--bg: #f3efe6;
--bg-strong: #e8dcc7;
--panel: rgba(255, 252, 247, 0.92);
--panel-border: rgba(93, 67, 35, 0.16);
--text: #1c1a18;
--muted: #685f53;
--accent: #9d3d2e;
--accent-strong: #7f2c20;
--accent-soft: #f4d2bf;
--success: #25624c;
--warning: #8a6119;
--danger: #8b2e34;
--shadow: 0 20px 50px rgba(77, 48, 18, 0.12);
--radius-lg: 24px;
--radius-md: 16px;
--radius-sm: 12px;
--font-ui: "Aptos", "Segoe UI Variable", "Segoe UI", sans-serif;
--font-ui: "Inter", "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.7), transparent 35%),
linear-gradient(135deg, #f8f2e8 0%, #ead9bb 44%, #e9d8c5 100%);
color: var(--text);
font-family: var(--font-ui);
}
body {
min-height: 100vh;
/* ── Sidebar (shared across pages) ──────────────────────────────────────── */
.sidebar-bs {
font-family: var(--font-ui);
}
.portal-body {
display: grid;
place-items: center;
padding: 24px;
/* ── Admin panel visibility (JS toggles these) ──────────────────────────── */
.admin-panel {
display: none;
}
button,
input,
select,
textarea {
font: inherit;
.admin-panel-active {
display: block;
}
.app-shell {
/*
* The main layout keeps navigation and report context visible at the same time.
* This is important for a checklist workflow where users often switch reports
* and need immediate awareness of save state, sync state, and current draft.
*/
display: grid;
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
min-height: 100vh;
gap: 20px;
padding: 20px;
/* ── Admin navigation (sidebar category tree) ───────────────────────────── */
.admin-nav-sub {
display: none;
}
.panel {
border: 1px solid var(--panel-border);
border-radius: var(--radius-lg);
background: var(--panel);
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
.admin-nav-cat.is-open > .admin-nav-sub {
display: block;
}
.sidebar {
display: flex;
flex-direction: column;
padding: 24px;
gap: 22px;
}
.brand-block h1,
.hero h2,
.section-heading-row h2,
.empty-state h3,
.validation-block h3,
.attachment-policy h3 {
margin: 0;
}
.eyebrow {
margin: 0 0 8px;
color: var(--accent-strong);
letter-spacing: 0.16em;
text-transform: uppercase;
font-size: 0.74rem;
.admin-nav-item.is-active {
font-weight: 700;
}
.lede,
.hero-copy,
.panel-note,
.summary-note,
.policy-copy,
.empty-state p,
.meta-list dd,
.validation-list,
.report-list-item__meta {
color: var(--muted);
}
.sidebar-section {
display: grid;
gap: 12px;
}
.sidebar-link {
display: inline-flex;
justify-content: center;
color: var(--bs-primary) !important;
text-decoration: none;
}
.sidebar-link.is-active {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
color: #fff8f0;
}
.portal-shell {
/*
* The chooser page is intentionally sparse. Its only job is to separate entry
* points for operators and administrators without forcing a more complex auth
* design into the PoC before roles and identity are finalized.
*/
width: min(1100px, 100%);
display: grid;
gap: 24px;
}
.portal-hero {
padding: 32px;
}
.portal-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.portal-card {
display: grid;
gap: 16px;
padding: 28px;
text-decoration: none;
color: inherit;
}
.portal-copy {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.portal-button {
width: fit-content;
}
.grow-section {
min-height: 0;
flex: 1;
}
.status-row,
.section-heading-row,
.hero-actions,
.field-header,
.attachment-toolbar,
.report-list-item__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.section-heading-row {
align-items: baseline;
}
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
}
.badge-neutral {
background: rgba(28, 26, 24, 0.08);
color: var(--text);
}
.badge-online,
.status-in_progress,
.status-ready_for_export {
background: rgba(37, 98, 76, 0.12);
color: var(--success);
}
.badge-offline,
.status-draft,
.status-archived {
background: rgba(138, 97, 25, 0.12);
color: var(--warning);
}
.badge-error,
.status-exported {
background: rgba(139, 46, 52, 0.12);
color: var(--danger);
}
.button {
border: 0;
border-radius: 999px;
padding: 12px 16px;
font-weight: 700;
cursor: pointer;
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
}
.button:hover {
transform: translateY(-1px);
}
.button-primary {
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
color: #fff8f0;
box-shadow: 0 12px 24px rgba(157, 61, 46, 0.25);
}
.button-secondary {
background: rgba(28, 26, 24, 0.08);
color: var(--text);
}
.button-ghost {
background: transparent;
color: var(--danger);
border: 1px solid rgba(139, 46, 52, 0.18);
}
.button-small {
padding: 8px 12px;
font-size: 0.84rem;
}
.workspace {
display: grid;
gap: 20px;
}
/* ── Workspace views (JS toggling for user page) ────────────────────────── */
.workspace-view {
/*
* User and admin workspaces share one HTML document. Hidden sections let the
* route control which workspace is visible while still reusing common styling
* and keeping asset delivery simple.
*/
display: none;
gap: 20px;
}
.workspace-view-active {
display: grid;
display: block;
}
.hero {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 24px 28px;
overflow: hidden;
position: relative;
}
/* ── Toast notification ─────────────────────────────────────────────────── */
.hero::after {
content: "";
position: absolute;
inset: auto -80px -80px auto;
width: 220px;
height: 220px;
border-radius: 50%;
background: radial-gradient(circle, rgba(157, 61, 46, 0.18), transparent 70%);
}
.hero-actions {
align-items: end;
flex-wrap: wrap;
}
.status-picker {
display: grid;
gap: 6px;
min-width: 220px;
}
.summary-grid,
.editor-grid {
/*
* These grids create a consistent rhythm between overview cards and working
* panels so the operator can scan status quickly before dropping into detail.
*/
display: grid;
gap: 20px;
}
.summary-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.summary-card {
padding: 20px 22px;
}
.accent-card {
background: linear-gradient(145deg, rgba(157, 61, 46, 0.12), rgba(255, 252, 247, 0.96));
}
.summary-label,
.field-label,
.meta-list dt {
margin: 0 0 8px;
color: var(--muted);
font-size: 0.84rem;
.admin-toast {
position: fixed;
top: 20px;
right: 20px;
z-index: 9000;
padding: 12px 20px;
border-radius: 999px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.88rem;
opacity: 0;
transform: translateY(-10px);
pointer-events: none;
transition: opacity 200ms ease, transform 200ms ease;
background: #198754;
color: #fff;
}
.editor-grid {
grid-template-columns: minmax(0, 1.7fr) minmax(300px, 380px);
.admin-toast-visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
.editor-panel,
.inspector-panel {
padding: 24px;
}
.admin-form {
margin-top: 16px;
}
.admin-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.report-form {
/*
* The form styles are generic on purpose because fields are generated from
* template JSON. The same primitives must support report editing and admin
* configuration without each field type needing a dedicated page-specific skin.
*/
display: grid;
gap: 20px;
margin-top: 16px;
}
.template-section {
display: grid;
gap: 16px;
padding: 20px;
border-radius: var(--radius-md);
background: rgba(243, 239, 230, 0.68);
border: 1px solid rgba(93, 67, 35, 0.12);
}
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.field {
display: grid;
gap: 8px;
}
.field-full {
grid-column: 1 / -1;
}
.field-header {
align-items: baseline;
}
.required-pill {
color: var(--accent-strong);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.text-input,
.select-input,
.text-area,
.file-input {
width: 100%;
border: 1px solid rgba(93, 67, 35, 0.18);
background: rgba(255, 255, 255, 0.82);
color: var(--text);
border-radius: 14px;
padding: 12px 14px;
}
.text-input:focus,
.select-input:focus,
.text-area:focus,
.file-input:focus {
outline: 2px solid rgba(157, 61, 46, 0.22);
outline-offset: 2px;
}
.text-area {
min-height: 120px;
resize: vertical;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(93, 67, 35, 0.18);
}
.field-help {
margin: 0;
font-size: 0.86rem;
color: var(--muted);
}
/* ── Report list (index.html sidebar) ───────────────────────────────────── */
.report-list {
display: grid;
gap: 10px;
gap: 8px;
max-height: 420px;
overflow: auto;
padding-right: 4px;
}
/* F4 — Search and filter controls above the report list */
.report-filter-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.text-input-small,
.select-input-small {
padding: 8px 12px;
font-size: 0.88rem;
border-radius: 12px;
}
.select-input-small {
min-width: 120px;
}
.report-list-item {
width: 100%;
text-align: left;
border: 1px solid rgba(93, 67, 35, 0.12);
background: rgba(255, 255, 255, 0.74);
border-radius: 16px;
padding: 14px;
border: 1px solid rgba(0, 0, 0, 0.1);
background: #fff;
border-radius: 8px;
padding: 10px 12px;
display: grid;
gap: 4px;
cursor: pointer;
}
.report-list-item:hover {
background: #f8f9fa;
}
.report-list-item.is-active,
.report-list-item--active {
border-color: var(--bs-primary);
background: rgba(13, 110, 253, 0.05);
}
.report-list-item__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.report-list-item.is-active {
border-color: rgba(157, 61, 46, 0.36);
box-shadow: inset 0 0 0 1px rgba(157, 61, 46, 0.16);
background: rgba(244, 210, 191, 0.36);
}
.report-list-item__title {
font-size: 0.96rem;
font-size: 0.9rem;
}
.muted-count {
color: var(--muted);
font-size: 0.88rem;
.report-list-item__meta {
color: #6c757d;
font-size: 0.8rem;
}
.empty-state {
display: grid;
place-items: center;
min-height: 240px;
text-align: center;
padding: 24px;
border-radius: var(--radius-md);
background: rgba(243, 239, 230, 0.68);
border: 1px dashed rgba(93, 67, 35, 0.24);
/* ── Task record cards (user page) ──────────────────────────────────────── */
.task-record-card {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
}
.meta-list {
display: grid;
gap: 14px;
margin: 0;
.task-record-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.meta-list div {
display: grid;
gap: 4px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(93, 67, 35, 0.1);
.task-record-sort {
font-weight: 700;
font-size: 0.9rem;
color: var(--bs-primary);
}
.meta-list dd {
margin: 0;
.task-record-desc {
flex: 1;
font-size: 0.92rem;
}
.validation-block,
.attachment-policy {
margin-top: 26px;
.task-record-meta {
font-size: 0.8rem;
color: #6c757d;
}
.validation-list {
padding-left: 18px;
}
.validation-list li + li {
.task-record-fields {
margin-top: 8px;
}
.attachment-list {
display: grid;
gap: 12px;
.task-record-images {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-start;
}
.attachment-card {
/*
* Attachments need enough visual weight to confirm that a photo is really tied
* to a report item. The card layout reserves space for preview, metadata, and
* removal action without requiring a modal or separate gallery screen.
*/
display: grid;
grid-template-columns: 88px minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 12px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(93, 67, 35, 0.12);
.task-img-thumb {
position: relative;
display: inline-block;
}
.attachment-preview {
width: 88px;
height: 88px;
border-radius: 12px;
.task-img-thumb img {
width: 64px;
height: 64px;
object-fit: cover;
background: rgba(93, 67, 35, 0.08);
border-radius: 6px;
border: 1px solid rgba(0, 0, 0, 0.1);
}
.attachment-card__copy {
.task-img-thumb .btn-remove {
position: absolute;
top: -4px;
right: -4px;
background: #fff;
border-radius: 50%;
width: 20px;
height: 20px;
padding: 0;
line-height: 20px;
text-align: center;
font-size: 0.8rem;
border: 1px solid rgba(0, 0, 0, 0.15);
}
/* ── Drop zone (drag & drop images) ────────────────────────────────────── */
.drop-zone {
cursor: pointer;
transition: border-color 150ms ease, background-color 150ms ease;
}
.drop-zone:hover {
border-color: var(--bs-primary) !important;
background: rgba(13, 110, 253, 0.03);
}
/* ── Bulk image drop zone (smaller for inline use) ────────────────────── */
.bulk-drop-zone {
cursor: pointer;
transition: border-color 150ms ease, background-color 150ms ease;
font-size: 0.8rem;
}
.bulk-drop-zone:hover {
border-color: var(--bs-primary) !important;
background: rgba(13, 110, 253, 0.05);
}
.bulk-img-thumb img {
border: 1px solid #dee2e6;
}
/* ── Portal page ────────────────────────────────────────────────────────── */
.portal-body {
display: grid;
gap: 4px;
min-width: 0;
place-items: center;
min-height: 100vh;
padding: 24px;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
}
.attachment-card__copy strong,
.attachment-card__copy span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* ── Connection badges (JS sets these classes) ──────────────────────────── */
.badge-online {
background-color: #198754 !important;
color: #fff !important;
}
@media (max-width: 1180px) {
/*
* Tablet and narrow laptop layouts collapse the two-column structure into a
* single column so the editing surface remains usable without horizontal scroll.
*/
.app-shell,
.editor-grid,
.summary-grid,
.portal-grid {
grid-template-columns: 1fr;
}
.badge-offline {
background-color: #ffc107 !important;
color: #000 !important;
}
.sidebar {
min-height: auto;
}
.badge-error {
background-color: #dc3545 !important;
color: #fff !important;
}
.report-list {
max-height: 260px;
/* ── Sub-category group styling ─────────────────────────────────────────── */
.sub-cat-header {
cursor: pointer;
user-select: none;
}
.sub-cat-chevron {
transition: transform 200ms ease;
}
.sub-cat-chevron.rotated {
transform: rotate(90deg);
}
/* ── Mobile hamburger button (hidden on desktop) ───────────────────────── */
.mobile-menu-btn {
display: none;
position: fixed;
bottom: 16px;
left: 16px;
z-index: 1060;
width: 48px;
height: 48px;
border-radius: 50%;
align-items: center;
justify-content: center;
font-size: 1.3rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
/* ── Responsive ─────────────────────────────────────────────────────────── */
/* Toggle button chevron rotation */
#toggleTaskInfoBtn .bi-chevron-up {
transition: transform 200ms ease;
}
#taskInfoCollapse:not(.show) ~ .d-md-none #toggleTaskInfoBtn .bi-chevron-up,
.collapsed .bi-chevron-up {
transform: rotate(180deg);
}
@media (max-width: 992px) {
.sidebar-bs {
width: 220px !important;
min-width: 220px !important;
}
}
@media (max-width: 760px) {
/*
* Mobile layout prioritizes single-column readability and larger preview areas.
* This matters because one of the project requirements is viable use on phones
* where camera capture and image attachment happen directly in the browser.
*/
.app-shell {
padding: 14px;
gap: 14px;
@media (max-width: 768px) {
.sidebar-bs {
display: flex !important;
position: fixed;
top: 0;
left: 0;
height: 100vh;
width: 260px !important;
min-width: 260px !important;
z-index: 1050;
transform: translateX(-100%);
transition: transform 250ms ease;
box-shadow: none;
}
.hero,
.hero-actions,
.field-grid,
.attachment-card {
grid-template-columns: 1fr;
display: grid;
.sidebar-bs.sidebar-open {
transform: translateX(0);
box-shadow: 4px 0 16px rgba(0,0,0,0.15);
}
.field-grid {
gap: 14px;
.sidebar-backdrop {
display: none;
position: fixed;
inset: 0;
z-index: 1040;
background: rgba(0,0,0,0.3);
}
.attachment-card {
grid-template-columns: 1fr;
.sidebar-backdrop.show {
display: block;
}
.attachment-preview {
width: 100%;
height: 180px;
.mobile-menu-btn {
display: flex !important;
}
}
+25
View File
@@ -0,0 +1,25 @@
/*
* User entry point — bootstraps the task-processing workspace.
*
* Opens the shared IndexedDB for admin entity cache, then initializes the
* user module which manages task processing: open task, fill in records,
* attach images, save as draft or final.
*/
import { state } from './js/state.js';
import { openDatabase } from './js/db.js';
import { initUser } from './js/user.js';
document.addEventListener('DOMContentLoaded', async () => {
/* Open the shared IndexedDB so admin entity cache is accessible. */
state.db = await openDatabase();
await initUser();
});
/* Re-init when navigating back (fixes stale localStorage after bfcache) */
window.addEventListener('pageshow', async (e) => {
if (e.persisted) {
state.db = await openDatabase();
await initUser();
}
});
+153 -154
View File
@@ -6,189 +6,188 @@
<meta name="theme-color" content="#f3efe6" />
<title>Check List PoC — User</title>
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<!--
Operator workspace: report creation, local draft editing, validation,
image attachments, submission, and CSV export.
-->
<div class="app-shell">
<aside class="sidebar panel">
<div class="brand-block">
<p class="eyebrow">Hybrid Inspection Reporting</p>
<h1>Check List</h1>
<p class="lede">
Offline-first proof of concept for template-driven quality reports.
</p>
<div class="d-flex vh-100">
<!-- Mobile sidebar backdrop -->
<div id="sidebarBackdrop" class="sidebar-backdrop"></div>
<!-- Mobile menu button -->
<button id="mobileMenuBtn" class="mobile-menu-btn btn btn-primary" type="button" aria-label="Open menu"><i class="bi bi-list"></i></button>
<!-- Sidebar -->
<aside class="sidebar-bs d-flex flex-column border-end bg-light" style="width:260px;min-width:260px;">
<div class="p-3 border-bottom">
<p class="text-uppercase text-muted small fw-semibold mb-0">Hybrid Inspection Reporting</p>
<h5 class="fw-bold mb-0">Check List</h5>
<small class="text-muted">Task processing workspace</small>
</div>
<div class="sidebar-section">
<div class="status-row">
<span id="connectionBadge" class="badge badge-neutral">Checking connection</span>
<span id="saveBadge" class="badge badge-neutral">No changes</span>
</div>
<button id="syncTemplatesButton" class="button button-secondary" type="button">
Sync templates
</button>
<div class="p-3 border-bottom">
<span id="connectionBadge" class="badge bg-secondary">Checking…</span>
</div>
<div class="sidebar-section">
<label class="field-label" for="templateSelect">Template</label>
<select id="templateSelect" class="select-input"></select>
<button id="createReportButton" class="button button-primary" type="button">
Create new report
</button>
<div class="flex-grow-1 overflow-auto p-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="fw-semibold mb-0">My Tasks</h6>
<span id="taskCount" class="badge bg-secondary">0</span>
</div>
<div id="taskListSidebar"></div>
</div>
<div class="sidebar-section">
<div class="section-heading-row sidebar-links-heading">
<h2>Access</h2>
<span class="muted-count">Direct links</span>
</div>
<a id="userAreaLink" class="button button-secondary sidebar-link is-active" href="/user">User area</a>
<a id="adminAreaLink" class="button button-secondary sidebar-link" href="/admin">Admin area</a>
<a class="button button-secondary sidebar-link" href="/">Back to portal</a>
</div>
<div class="sidebar-section grow-section">
<div class="section-heading-row">
<h2>Local reports</h2>
<span id="reportCount" class="muted-count">0</span>
</div>
<div class="report-filter-row">
<input id="reportSearchInput" class="text-input text-input-small" type="search" placeholder="Search reports" />
<select id="reportFilterSelect" class="select-input select-input-small">
<option value="">All statuses</option>
<option value="draft">Draft</option>
<option value="in_progress">In Progress</option>
<option value="ready_for_export">Ready for Export</option>
<option value="exported">Exported</option>
<option value="archived">Archived</option>
</select>
</div>
<div id="reportList" class="report-list"></div>
<div class="p-3 border-top">
<button id="showSettingsBtn" class="btn btn-outline-primary btn-sm w-100 mb-1" type="button"><i class="bi bi-gear me-1"></i>Settings</button>
<a class="btn btn-secondary btn-sm w-100 mb-1" href="/user">User area</a>
<a class="btn btn-outline-secondary btn-sm w-100 mb-1" href="/admin">Admin area</a>
<a class="btn btn-outline-secondary btn-sm w-100" href="/">Back to portal</a>
</div>
</aside>
<main class="workspace">
<section id="reportsWorkspace" class="workspace-view workspace-view-active">
<section class="hero panel">
<div>
<p class="eyebrow">Proof of concept frontend</p>
<h2 id="heroTitle">No report selected</h2>
<p id="heroSubtitle" class="hero-copy">
Start by syncing templates and creating a local draft.
</p>
<!-- Main content -->
<main class="flex-grow-1 overflow-auto p-4 bg-white">
<!-- SETTINGS VIEW -->
<section id="settingsView" class="workspace-view">
<div class="mb-4">
<p class="text-muted small mb-0">User workspace</p>
<h3 class="fw-bold">Settings</h3>
<p class="text-muted">Configure your workspace preferences.</p>
</div>
<div class="hero-actions">
<label class="status-picker">
<span>Status</span>
<select id="reportStatusSelect" class="select-input">
<option value="draft">Draft</option>
<option value="in_progress">In Progress</option>
<option value="ready_for_export">Ready for Export</option>
<option value="exported">Exported</option>
<option value="archived">Archived</option>
<div class="card" style="max-width:480px">
<div class="card-body">
<h6 class="fw-semibold mb-3">Language</h6>
<p class="text-muted small">Choose the language for record descriptions.</p>
<select id="userLanguageSelect" class="form-select">
<option value="EN">English</option>
<option value="FR">Français</option>
<option value="NL">Nederlands</option>
</select>
</label>
<button id="submitReportButton" class="button button-secondary" type="button">
Submit
</button>
<button id="exportReportButton" class="button button-secondary" type="button">
Export CSV
</button>
<button id="deleteReportButton" class="button button-ghost" type="button">
Delete report
</button>
<button id="closeSettingsBtn" class="btn btn-primary btn-sm mt-3" type="button">Done</button>
</div>
</div>
</section>
<section class="summary-grid">
<article class="summary-card panel accent-card">
<p class="summary-label">Template</p>
<strong id="summaryTemplate">Not loaded</strong>
<span id="summaryVersion" class="summary-note">Version -</span>
</article>
<article class="summary-card panel">
<p class="summary-label">Validation</p>
<strong id="validationHeadline">No report selected</strong>
<span id="validationDetail" class="summary-note">Draft validation will appear here.</span>
</article>
<article class="summary-card panel">
<p class="summary-label">Offline cache</p>
<strong id="syncHeadline">No sync yet</strong>
<span id="syncDetail" class="summary-note">Templates are cached locally after the first successful sync.</span>
</article>
<!-- TASK LIST VIEW (shown by default) -->
<section id="taskListView" class="workspace-view workspace-view-active">
<div class="mb-4">
<p class="text-muted small mb-0">User workspace</p>
<h3 class="fw-bold">Assigned Tasks</h3>
<p class="text-muted">Select a task to begin processing.</p>
</div>
<div class="card">
<div class="card-body p-0">
<div id="taskListContainer"></div>
</div>
</div>
</section>
<section class="editor-grid">
<section class="panel editor-panel">
<div class="section-heading-row">
<h2>Report editor</h2>
<span id="editorHint" class="panel-note">Dynamic form rendering from template JSON</span>
<!-- TASK DETAIL VIEW (shown when a task is opened) -->
<section id="taskDetailView" class="workspace-view">
<div class="d-flex justify-content-between align-items-start mb-4">
<div>
<p class="text-muted small mb-0" id="taskDetailEyebrow">Task</p>
<h3 class="fw-bold" id="taskDetailTitle">-</h3>
<p class="text-muted" id="taskDetailSubtitle">-</p>
</div>
<form id="reportForm" class="report-form">
<div class="empty-state">
<h3>No report open</h3>
<p>Choose a template and create a report to start editing locally.</p>
<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>
<!-- 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>
</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="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>
</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>
<!-- 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>
</aside>
</section>
</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>
+208 -1
View File
@@ -129,7 +129,7 @@ CREATE TABLE IF NOT EXISTS reports (
report_number VARCHAR(100) NOT NULL,
template_code VARCHAR(100) NOT NULL,
template_version INT NOT NULL,
status ENUM('draft', 'in_progress', 'ready_for_export', 'exported', 'archived') NOT NULL DEFAULT 'draft',
status ENUM('draft', 'final', 'in_progress', 'ready_for_export', 'exported', 'archived') NOT NULL DEFAULT 'draft',
answers_json JSON NOT NULL,
submitted_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -155,3 +155,210 @@ CREATE TABLE IF NOT EXISTS audit_log (
PRIMARY KEY (id),
KEY idx_audit_entity (entity_type, entity_code)
);
-- Report images are stored as binary BLOBs in the database alongside metadata.
-- This keeps image storage self-contained without filesystem dependencies.
CREATE TABLE IF NOT EXISTS report_images (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
report_uuid CHAR(36) NOT NULL,
record_id VARCHAR(100) NOT NULL,
image_index SMALLINT UNSIGNED NOT NULL DEFAULT 0,
file_name VARCHAR(500) NOT NULL,
file_size INT UNSIGNED NOT NULL DEFAULT 0,
mime_type VARCHAR(100) NOT NULL DEFAULT 'image/jpeg',
width_px INT UNSIGNED NULL,
height_px INT UNSIGNED NULL,
exif_json JSON NULL,
image_data LONGBLOB NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_report_images_report (report_uuid),
KEY idx_report_images_record (report_uuid, record_id),
CONSTRAINT fk_report_images_report
FOREIGN KEY (report_uuid) REFERENCES reports (report_uuid)
ON DELETE CASCADE
);
-- ═══════════════════════════════════════════════════════════════════════════════
-- Admin entity tables — store all admin-managed data relationally so it
-- persists across Docker restarts and browser sessions.
-- ═══════════════════════════════════════════════════════════════════════════════
-- Categories for template settings (e.g. "Electrical", "Mechanical")
CREATE TABLE IF NOT EXISTS admin_categories (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
value VARCHAR(200) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_admin_categories_value (value)
);
-- Sub-categories are children of categories
CREATE TABLE IF NOT EXISTS admin_sub_categories (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
value VARCHAR(200) NOT NULL,
category_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_sub_categories_category (category_id),
CONSTRAINT fk_sub_categories_category
FOREIGN KEY (category_id) REFERENCES admin_categories (id)
ON DELETE CASCADE
);
-- Severity levels
CREATE TABLE IF NOT EXISTS admin_severities (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
value VARCHAR(200) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_admin_severities_value (value)
);
-- Status options
CREATE TABLE IF NOT EXISTS admin_statuses (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
value VARCHAR(200) NOT NULL,
require_handled_by TINYINT(1) NOT NULL DEFAULT 0,
require_comment TINYINT(1) NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_admin_statuses_value (value)
);
-- Handled By options
CREATE TABLE IF NOT EXISTS admin_handled_by (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
value VARCHAR(200) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_admin_handled_by_value (value)
);
-- Projects for task settings
CREATE TABLE IF NOT EXISTS admin_projects (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
value VARCHAR(200) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_admin_projects_value (value)
);
-- Processes are children of projects
CREATE TABLE IF NOT EXISTS admin_processes (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
value VARCHAR(200) NOT NULL,
project_id BIGINT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_processes_project (project_id),
CONSTRAINT fk_processes_project
FOREIGN KEY (project_id) REFERENCES admin_projects (id)
ON DELETE CASCADE
);
-- Users managed by the admin console
CREATE TABLE IF NOT EXISTS admin_users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(255) NOT NULL,
password_hash VARCHAR(255) NOT NULL DEFAULT '',
name VARCHAR(200) NOT NULL,
family_name VARCHAR(200) NOT NULL,
company VARCHAR(200) NOT NULL DEFAULT '',
role VARCHAR(50) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_admin_users_email (email)
);
-- Sites managed by the admin console
CREATE TABLE IF NOT EXISTS admin_sites (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
site_code VARCHAR(100) NOT NULL,
host VARCHAR(50) NOT NULL DEFAULT '',
obe_site_code VARCHAR(100) NOT NULL DEFAULT '',
pxs_site_code VARCHAR(100) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_admin_sites_code (site_code)
);
-- Check list records (inspection items)
CREATE TABLE IF NOT EXISTS admin_cl_records (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
sort_order INT NOT NULL DEFAULT 0,
category VARCHAR(200) NOT NULL DEFAULT '',
sub_category VARCHAR(200) NOT NULL DEFAULT '',
severity VARCHAR(200) NOT NULL DEFAULT '',
image_required TINYINT(1) NOT NULL DEFAULT 0,
description_en TEXT NOT NULL,
description_fr TEXT NOT NULL,
description_nl TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_admin_cl_records_sort (sort_order)
);
-- Check list templates
CREATE TABLE IF NOT EXISTS admin_cl_templates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(200) NOT NULL,
scope VARCHAR(50) NOT NULL DEFAULT '',
version VARCHAR(50) NOT NULL DEFAULT '',
valid_from DATE NULL,
valid_till DATE NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
-- Join table: which records belong to which template
CREATE TABLE IF NOT EXISTS admin_cl_template_records (
template_id BIGINT UNSIGNED NOT NULL,
record_id BIGINT UNSIGNED NOT NULL,
PRIMARY KEY (template_id, record_id),
CONSTRAINT fk_tpl_rec_template
FOREIGN KEY (template_id) REFERENCES admin_cl_templates (id)
ON DELETE CASCADE,
CONSTRAINT fk_tpl_rec_record
FOREIGN KEY (record_id) REFERENCES admin_cl_records (id)
ON DELETE CASCADE
);
-- Task assignments (user + site + template + project/process)
CREATE TABLE IF NOT EXISTS admin_tasks (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
site_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
template_id BIGINT UNSIGNED NOT NULL,
project VARCHAR(200) NOT NULL DEFAULT '',
process VARCHAR(200) NOT NULL DEFAULT '',
status VARCHAR(50) NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_tasks_user (user_id),
KEY idx_tasks_site (site_id),
CONSTRAINT fk_tasks_site
FOREIGN KEY (site_id) REFERENCES admin_sites (id)
ON DELETE CASCADE,
CONSTRAINT fk_tasks_user
FOREIGN KEY (user_id) REFERENCES admin_users (id)
ON DELETE CASCADE,
CONSTRAINT fk_tasks_template
FOREIGN KEY (template_id) REFERENCES admin_cl_templates (id)
ON DELETE CASCADE
);
-- Admin credentials for application administrator login
CREATE TABLE IF NOT EXISTS admin_credentials (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
username VARCHAR(100) NOT NULL,
password VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_admin_credentials_username (username)
);
+25 -7
View File
@@ -1,9 +1,13 @@
import cookieParser from 'cookie-parser';
import cors from 'cors';
import express from 'express';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { errorHandler, notFoundHandler } from './middleware/errorHandler.js';
import { requireAdminAuth, requireUserAuth } from './middleware/authMiddleware.js';
import adminRoutes from './routes/adminRoutes.js';
import authRoutes from './routes/authRoutes.js';
import configRoutes from './routes/configRoutes.js';
import healthRoutes from './routes/healthRoutes.js';
import lookupRoutes from './routes/lookupRoutes.js';
@@ -26,9 +30,12 @@ const publicDir = fileURLToPath(new URL('../public', import.meta.url));
const userPagePath = path.join(publicDir, 'user.html');
const adminPagePath = path.join(publicDir, 'admin.html');
const portalPath = path.join(publicDir, 'portal.html');
const loginAdminPath = path.join(publicDir, 'login-admin.html');
const loginUserPath = path.join(publicDir, 'login-user.html');
app.use(cors());
app.use(express.json({ limit: '10mb' }));
app.use(cookieParser());
app.use(express.json({ limit: '50mb' }));
app.get('/api/v1', (_req, res) => {
res.json({
@@ -48,11 +55,23 @@ app.use('/api/v1/templates', templateRoutes);
app.use('/api/v1/lookups', lookupRoutes);
app.use('/api/v1/config', configRoutes);
app.use('/api/v1/reports', reportRoutes);
app.use('/api/v1/admin', adminRoutes);
app.use('/api/v1/auth', authRoutes);
/*
* Login pages are served without authentication.
*/
app.get('/login-admin', (_req, res) => {
res.sendFile(loginAdminPath);
});
app.get('/login-user', (_req, res) => {
res.sendFile(loginUserPath);
});
/*
* The root route intentionally serves a neutral portal page. This gives the
* project distinct user and administrator entry points without introducing a
* full authentication flow yet.
* project distinct user and administrator entry points.
*/
app.get('/', (_req, res) => {
res.sendFile(portalPath);
@@ -60,14 +79,13 @@ app.get('/', (_req, res) => {
/*
* User and admin workspaces live in separate HTML files so each page only loads
* the markup it needs. The shared frontend JavaScript (app.js) detects which
* elements are present and binds behavior accordingly.
* the markup it needs. Authentication is required for both areas.
*/
app.get(['/user', '/user/'], (_req, res) => {
app.get(['/user', '/user/'], requireUserAuth, (_req, res) => {
res.sendFile(userPagePath);
});
app.get(['/admin', '/admin/'], (_req, res) => {
app.get(['/admin', '/admin/'], requireAdminAuth, (_req, res) => {
res.sendFile(adminPagePath);
});
app.use(express.static(publicDir));
+85
View File
@@ -0,0 +1,85 @@
/*
* Authentication middleware for protecting routes.
*
* Provides middleware functions to:
* - requireAdminAuth - Protect admin-only routes
* - requireUserAuth - Protect user-only routes
* - requireAnyAuth - Protect routes requiring any authenticated user
*/
import { validateSession } from '../services/authService.js';
/**
* Extract auth token from request (cookie or Authorization header).
*/
function getAuthToken(req) {
return req.cookies?.auth_token || req.headers.authorization?.replace('Bearer ', '') || null;
}
/**
* Middleware to require admin authentication.
* Redirects to login page for HTML requests, returns 401 for API requests.
*/
export function requireAdminAuth(req, res, next) {
const token = getAuthToken(req);
const session = token ? validateSession(token) : null;
if (!session || session.type !== 'admin') {
/* Check if this is an API request or page request */
const isApiRequest = req.path.startsWith('/api/') || req.xhr || req.headers.accept?.includes('application/json');
if (isApiRequest) {
return res.status(401).json({ message: 'Admin authentication required.' });
}
/* Redirect to admin login page */
return res.redirect('/login-admin');
}
req.session = session;
next();
}
/**
* Middleware to require user authentication.
* Redirects to login page for HTML requests, returns 401 for API requests.
*/
export function requireUserAuth(req, res, next) {
const token = getAuthToken(req);
const session = token ? validateSession(token) : null;
if (!session || session.type !== 'user') {
const isApiRequest = req.path.startsWith('/api/') || req.xhr || req.headers.accept?.includes('application/json');
if (isApiRequest) {
return res.status(401).json({ message: 'User authentication required.' });
}
/* Redirect to user login page */
return res.redirect('/login-user');
}
req.session = session;
next();
}
/**
* Middleware to require any authentication (admin or user).
*/
export function requireAnyAuth(req, res, next) {
const token = getAuthToken(req);
const session = token ? validateSession(token) : null;
if (!session) {
const isApiRequest = req.path.startsWith('/api/') || req.xhr || req.headers.accept?.includes('application/json');
if (isApiRequest) {
return res.status(401).json({ message: 'Authentication required.' });
}
return res.redirect('/');
}
req.session = session;
next();
}
+325
View File
@@ -0,0 +1,325 @@
import { Router } from 'express';
import { asyncHandler } from '../utils/asyncHandler.js';
import * as svc from '../services/adminService.js';
const router = Router();
/* ── Bulk load — single request to get all admin data ───────────────────── */
router.get(
'/all',
asyncHandler(async (_req, res) => {
const data = await svc.loadAllAdminData();
res.json(data);
})
);
/* ── Categories ─────────────────────────────────────────────────────────── */
router.get('/categories', asyncHandler(async (_req, res) => {
res.json({ items: await svc.listCategories() });
}));
router.post('/categories', asyncHandler(async (req, res) => {
const { value } = req.body;
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
const result = await svc.createCategory(value.trim());
res.status(201).json(result);
}));
router.put('/categories/:id', asyncHandler(async (req, res) => {
const { value } = req.body;
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
const result = await svc.updateCategory(Number(req.params.id), value.trim());
res.json(result);
}));
router.delete('/categories/:id', asyncHandler(async (req, res) => {
await svc.deleteCategory(Number(req.params.id));
res.json({ message: 'Deleted.' });
}));
/* ── Sub-Categories ─────────────────────────────────────────────────────── */
router.get('/sub-categories', asyncHandler(async (_req, res) => {
res.json({ items: await svc.listSubCategories() });
}));
router.post('/sub-categories', asyncHandler(async (req, res) => {
const { value, categoryId } = req.body;
if (!value?.trim() || !categoryId) return res.status(400).json({ message: 'value and categoryId are required.' });
const result = await svc.createSubCategory(value.trim(), Number(categoryId));
res.status(201).json(result);
}));
router.put('/sub-categories/:id', asyncHandler(async (req, res) => {
const { value, categoryId } = req.body;
if (!value?.trim() || !categoryId) return res.status(400).json({ message: 'value and categoryId are required.' });
const result = await svc.updateSubCategory(Number(req.params.id), value.trim(), Number(categoryId));
res.json(result);
}));
router.delete('/sub-categories/:id', asyncHandler(async (req, res) => {
await svc.deleteSubCategory(Number(req.params.id));
res.json({ message: 'Deleted.' });
}));
/* ── Severities ─────────────────────────────────────────────────────────── */
router.get('/severities', asyncHandler(async (_req, res) => {
res.json({ items: await svc.listSeverities() });
}));
router.post('/severities', asyncHandler(async (req, res) => {
const { value } = req.body;
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
const result = await svc.createSeverity(value.trim());
res.status(201).json(result);
}));
router.put('/severities/:id', asyncHandler(async (req, res) => {
const { value } = req.body;
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
const result = await svc.updateSeverity(Number(req.params.id), value.trim());
res.json(result);
}));
router.delete('/severities/:id', asyncHandler(async (req, res) => {
await svc.deleteSeverity(Number(req.params.id));
res.json({ message: 'Deleted.' });
}));
/* ── Statuses ───────────────────────────────────────────────────────────── */
router.get('/statuses', asyncHandler(async (_req, res) => {
res.json({ items: await svc.listStatuses() });
}));
router.post('/statuses', asyncHandler(async (req, res) => {
const { value, requireHandledBy, requireComment } = req.body;
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
const result = await svc.createStatus(value.trim(), !!requireHandledBy, !!requireComment);
res.status(201).json(result);
}));
router.put('/statuses/:id', asyncHandler(async (req, res) => {
const { value, requireHandledBy, requireComment } = req.body;
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
const result = await svc.updateStatus(Number(req.params.id), value.trim(), !!requireHandledBy, !!requireComment);
res.json(result);
}));
router.delete('/statuses/:id', asyncHandler(async (req, res) => {
await svc.deleteStatus(Number(req.params.id));
res.json({ message: 'Deleted.' });
}));
/* ── Handled By ─────────────────────────────────────────────────────────── */
router.get('/handled-by', asyncHandler(async (_req, res) => {
res.json({ items: await svc.listHandledBy() });
}));
router.post('/handled-by', asyncHandler(async (req, res) => {
const { value } = req.body;
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
const result = await svc.createHandledBy(value.trim());
res.status(201).json(result);
}));
router.put('/handled-by/:id', asyncHandler(async (req, res) => {
const { value } = req.body;
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
const result = await svc.updateHandledBy(Number(req.params.id), value.trim());
res.json(result);
}));
router.delete('/handled-by/:id', asyncHandler(async (req, res) => {
await svc.deleteHandledBy(Number(req.params.id));
res.json({ message: 'Deleted.' });
}));
/* ── Projects ───────────────────────────────────────────────────────────── */
router.get('/projects', asyncHandler(async (_req, res) => {
res.json({ items: await svc.listProjects() });
}));
router.post('/projects', asyncHandler(async (req, res) => {
const { value } = req.body;
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
const result = await svc.createProject(value.trim());
res.status(201).json(result);
}));
router.put('/projects/:id', asyncHandler(async (req, res) => {
const { value } = req.body;
if (!value?.trim()) return res.status(400).json({ message: 'value is required.' });
const result = await svc.updateProject(Number(req.params.id), value.trim());
res.json(result);
}));
router.delete('/projects/:id', asyncHandler(async (req, res) => {
await svc.deleteProject(Number(req.params.id));
res.json({ message: 'Deleted.' });
}));
/* ── Processes ──────────────────────────────────────────────────────────── */
router.get('/processes', asyncHandler(async (_req, res) => {
res.json({ items: await svc.listProcesses() });
}));
router.post('/processes', asyncHandler(async (req, res) => {
const { value, projectId } = req.body;
if (!value?.trim() || !projectId) return res.status(400).json({ message: 'value and projectId are required.' });
const result = await svc.createProcess(value.trim(), Number(projectId));
res.status(201).json(result);
}));
router.put('/processes/:id', asyncHandler(async (req, res) => {
const { value, projectId } = req.body;
if (!value?.trim() || !projectId) return res.status(400).json({ message: 'value and projectId are required.' });
const result = await svc.updateProcess(Number(req.params.id), value.trim(), Number(projectId));
res.json(result);
}));
router.delete('/processes/:id', asyncHandler(async (req, res) => {
await svc.deleteProcess(Number(req.params.id));
res.json({ message: 'Deleted.' });
}));
/* ── Users ──────────────────────────────────────────────────────────────── */
router.get('/users', asyncHandler(async (_req, res) => {
res.json({ items: await svc.listUsers() });
}));
router.post('/users', asyncHandler(async (req, res) => {
const { email, password, name, familyName, company, role } = req.body;
if (!email?.trim() || !name?.trim() || !familyName?.trim() || !role?.trim()) {
return res.status(400).json({ message: 'email, name, familyName, and role are required.' });
}
const result = await svc.createUser({ email: email.trim(), password: password || '', name: name.trim(), familyName: familyName.trim(), company: company || '', role: role.trim() });
res.status(201).json(result);
}));
router.put('/users/:id', asyncHandler(async (req, res) => {
const { email, password, name, familyName, company, role } = req.body;
if (!email?.trim() || !name?.trim() || !familyName?.trim() || !role?.trim()) {
return res.status(400).json({ message: 'email, name, familyName, and role are required.' });
}
const result = await svc.updateUser(Number(req.params.id), { email: email.trim(), password: password || '', name: name.trim(), familyName: familyName.trim(), company: company || '', role: role.trim() });
res.json(result);
}));
router.delete('/users/:id', asyncHandler(async (req, res) => {
await svc.deleteUser(Number(req.params.id));
res.json({ message: 'Deleted.' });
}));
/* ── Sites ──────────────────────────────────────────────────────────────── */
router.get('/sites', asyncHandler(async (_req, res) => {
res.json({ items: await svc.listSites() });
}));
router.post('/sites', asyncHandler(async (req, res) => {
const { siteCode, host, obeSiteCode, pxsSiteCode } = req.body;
if (!siteCode?.trim()) return res.status(400).json({ message: 'siteCode is required.' });
const result = await svc.createSite({ siteCode: siteCode.trim(), host: host || '', obeSiteCode: obeSiteCode || '', pxsSiteCode: pxsSiteCode || '' });
res.status(201).json(result);
}));
router.put('/sites/:id', asyncHandler(async (req, res) => {
const { siteCode, host, obeSiteCode, pxsSiteCode } = req.body;
if (!siteCode?.trim()) return res.status(400).json({ message: 'siteCode is required.' });
const result = await svc.updateSite(Number(req.params.id), { siteCode: siteCode.trim(), host: host || '', obeSiteCode: obeSiteCode || '', pxsSiteCode: pxsSiteCode || '' });
res.json(result);
}));
router.delete('/sites/:id', asyncHandler(async (req, res) => {
await svc.deleteSite(Number(req.params.id));
res.json({ message: 'Deleted.' });
}));
/* ── CL Records ─────────────────────────────────────────────────────────── */
router.get('/cl-records', asyncHandler(async (_req, res) => {
res.json({ items: await svc.listClRecords() });
}));
router.post('/cl-records', asyncHandler(async (req, res) => {
const data = req.body;
if (data.sort == null) return res.status(400).json({ message: 'sort is required.' });
const result = await svc.createClRecord(data);
res.status(201).json(result);
}));
router.put('/cl-records/:id', asyncHandler(async (req, res) => {
const data = req.body;
if (data.sort == null) return res.status(400).json({ message: 'sort is required.' });
const result = await svc.updateClRecord(Number(req.params.id), data);
res.json(result);
}));
router.delete('/cl-records/:id', asyncHandler(async (req, res) => {
await svc.deleteClRecord(Number(req.params.id));
res.json({ message: 'Deleted.' });
}));
/* ── CL Templates ───────────────────────────────────────────────────────── */
router.get('/cl-templates', asyncHandler(async (_req, res) => {
res.json({ items: await svc.listClTemplates() });
}));
router.post('/cl-templates', asyncHandler(async (req, res) => {
const data = req.body;
if (!data.name?.trim()) return res.status(400).json({ message: 'name is required.' });
const result = await svc.createClTemplate(data);
res.status(201).json(result);
}));
router.put('/cl-templates/:id', asyncHandler(async (req, res) => {
const data = req.body;
if (!data.name?.trim()) return res.status(400).json({ message: 'name is required.' });
const result = await svc.updateClTemplate(Number(req.params.id), data);
res.json(result);
}));
router.delete('/cl-templates/:id', asyncHandler(async (req, res) => {
await svc.deleteClTemplate(Number(req.params.id));
res.json({ message: 'Deleted.' });
}));
/* ── Tasks ──────────────────────────────────────────────────────────────── */
router.get('/tasks', asyncHandler(async (_req, res) => {
res.json({ items: await svc.listTasks() });
}));
router.post('/tasks', asyncHandler(async (req, res) => {
const { siteId, userId, templateId, project, process } = req.body;
if (!siteId || !userId || !templateId) {
return res.status(400).json({ message: 'siteId, userId, and templateId are required.' });
}
const result = await svc.createTask({ siteId: Number(siteId), userId: Number(userId), templateId: Number(templateId), project: project || '', process: process || '', status: 'pending' });
res.status(201).json(result);
}));
router.put('/tasks/:id', asyncHandler(async (req, res) => {
const { siteId, userId, templateId, project, process, status } = req.body;
if (!siteId || !userId || !templateId) {
return res.status(400).json({ message: 'siteId, userId, and templateId are required.' });
}
const result = await svc.updateTask(Number(req.params.id), { siteId: Number(siteId), userId: Number(userId), templateId: Number(templateId), project: project || '', process: process || '', status: status || 'pending' });
res.json(result);
}));
router.delete('/tasks/:id', asyncHandler(async (req, res) => {
await svc.deleteTask(Number(req.params.id));
res.json({ message: 'Deleted.' });
}));
export default router;
+127
View File
@@ -0,0 +1,127 @@
/*
* Authentication routes for the PoC application.
*
* Provides endpoints for:
* - POST /auth/admin/login - Admin login
* - POST /auth/user/login - User login
* - POST /auth/logout - Logout (both admin and user)
* - GET /auth/check - Check current session validity
*/
import { Router } from 'express';
import { asyncHandler } from '../utils/asyncHandler.js';
import {
verifyAdminCredentials,
verifyUserCredentials,
generateSessionToken,
createSession,
removeSession,
validateSession
} from '../services/authService.js';
const router = Router();
/**
* Admin login endpoint.
* Expects: { username: string, password: string }
* Returns: { success: true, token: string } or { success: false, message: string }
*/
router.post(
'/admin/login',
asyncHandler(async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ success: false, message: 'Username and password required.' });
}
const result = await verifyAdminCredentials(username, password);
if (!result.valid) {
return res.status(401).json({ success: false, message: 'Invalid credentials.' });
}
const token = generateSessionToken();
createSession(token, { type: 'admin', ...result.admin });
/* Set cookie for browser-based auth */
res.cookie('auth_token', token, {
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
sameSite: 'lax'
});
return res.json({ success: true, token, admin: result.admin });
})
);
/**
* User login endpoint.
* Expects: { email: string, password: string }
* Returns: { success: true, token: string, user: object } or { success: false, message: string }
*/
router.post(
'/user/login',
asyncHandler(async (req, res) => {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ success: false, message: 'Email and password required.' });
}
const result = await verifyUserCredentials(email, password);
if (!result.valid) {
return res.status(401).json({ success: false, message: 'Invalid credentials.' });
}
const token = generateSessionToken();
createSession(token, { type: 'user', ...result.user });
/* Set cookie for browser-based auth */
res.cookie('auth_token', token, {
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
sameSite: 'lax'
});
return res.json({ success: true, token, user: result.user });
})
);
/**
* Logout endpoint - clears session.
*/
router.post('/logout', (req, res) => {
const token = req.cookies?.auth_token || req.headers.authorization?.replace('Bearer ', '');
if (token) {
removeSession(token);
}
res.clearCookie('auth_token');
return res.json({ success: true, message: 'Logged out.' });
});
/**
* Check current session validity.
* Returns session data if valid, 401 if not.
*/
router.get('/check', (req, res) => {
const token = req.cookies?.auth_token || req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ authenticated: false, message: 'No session token.' });
}
const session = validateSession(token);
if (!session) {
res.clearCookie('auth_token');
return res.status(401).json({ authenticated: false, message: 'Session expired or invalid.' });
}
return res.json({ authenticated: true, session });
});
export default router;
+35 -1
View File
@@ -2,9 +2,11 @@ import { Router } from 'express';
import {
getAppConfig,
getAppConfigValue,
getExportProfile,
getImageRules,
updateImageRules
updateImageRules,
upsertAppConfig
} from '../services/configService.js';
import { logAuditEvent } from '../services/auditService.js';
import { asyncHandler } from '../utils/asyncHandler.js';
@@ -154,4 +156,36 @@ router.get(
})
);
router.get(
'/app-config/:key',
asyncHandler(async (req, res) => {
const value = await getAppConfigValue(req.params.key);
if (value === null) {
return res.status(404).json({ message: 'Config key not found.' });
}
return res.json({ key: req.params.key, value });
})
);
router.put(
'/app-config/:key',
asyncHandler(async (req, res) => {
const key = req.params.key;
if (!key || typeof key !== 'string' || key.length > 100) {
return res.status(400).json({ message: 'Invalid config key.' });
}
if (req.body?.value === undefined) {
return res.status(400).json({ message: 'Request body must include a value property.' });
}
const result = await upsertAppConfig(key, req.body.value);
configCache.invalidate(key);
return res.json(result);
})
);
export default router;
+42 -2
View File
@@ -1,6 +1,6 @@
import { Router } from 'express';
import { getReport, listReports, submitReport } from '../services/reportService.js';
import { deleteReport, deleteReportImage, getReport, getReportImages, listReports, submitReport } from '../services/reportService.js';
import { logAuditEvent } from '../services/auditService.js';
import { asyncHandler } from '../utils/asyncHandler.js';
import { validateParam } from '../middleware/validateParams.js';
@@ -29,7 +29,7 @@ router.get(
router.get(
'/:reportId',
validateParam('reportId'),
validateParam('reportId', { pattern: /^[a-zA-Z0-9_-]{1,100}$/ }),
asyncHandler(async (req, res) => {
const report = await getReport(req.params.reportId);
@@ -72,4 +72,44 @@ router.post(
})
);
/* Get all images for a report grouped by record */
router.get(
'/:reportId/images',
validateParam('reportId', { pattern: /^[a-zA-Z0-9_-]{1,100}$/ }),
asyncHandler(async (req, res) => {
const images = await getReportImages(req.params.reportId);
return res.json(images);
})
);
/* Delete a report and all associated images */
router.delete(
'/:reportId',
validateParam('reportId', { pattern: /^[a-zA-Z0-9_-]{1,100}$/ }),
asyncHandler(async (req, res) => {
await deleteReport(req.params.reportId);
await logAuditEvent({
entityType: 'report',
entityCode: req.params.reportId,
action: 'delete',
newValue: null
});
return res.json({ message: 'Report and images deleted.' });
})
);
/* Safe pattern for image file names: alphanumeric, underscore, hyphen, dot */
const SAFE_FILENAME_PATTERN = /^[a-zA-Z0-9_.-]{1,500}$/;
/* Delete a specific image from a report */
router.delete(
'/:reportId/images/:recordId/:fileName',
validateParam('reportId', { pattern: /^[a-zA-Z0-9_-]{1,100}$/ }),
validateParam('fileName', { pattern: SAFE_FILENAME_PATTERN }),
asyncHandler(async (req, res) => {
await deleteReportImage(req.params.reportId, req.params.recordId, req.params.fileName);
return res.json({ message: 'Image deleted.' });
})
);
export default router;
+388
View File
@@ -0,0 +1,388 @@
import { query } from '../db/pool.js';
/* ═══════════════════════════════════════════════════════════════════════════
* CATEGORIES
* ═══════════════════════════════════════════════════════════════════════════ */
export async function listCategories() {
return query('SELECT id, value FROM admin_categories ORDER BY value ASC');
}
export async function createCategory(value) {
const result = await query('INSERT INTO admin_categories (value) VALUES (?)', [value]);
return { id: Number(result.insertId), value };
}
export async function updateCategory(id, value) {
await query('UPDATE admin_categories SET value = ? WHERE id = ?', [value, id]);
return { id, value };
}
export async function deleteCategory(id) {
await query('DELETE FROM admin_categories WHERE id = ?', [id]);
}
/* ═══════════════════════════════════════════════════════════════════════════
* SUB-CATEGORIES
* ═══════════════════════════════════════════════════════════════════════════ */
export async function listSubCategories() {
return query('SELECT id, value, category_id AS categoryId FROM admin_sub_categories ORDER BY value ASC');
}
export async function createSubCategory(value, categoryId) {
const result = await query('INSERT INTO admin_sub_categories (value, category_id) VALUES (?, ?)', [value, categoryId]);
return { id: Number(result.insertId), value, categoryId };
}
export async function updateSubCategory(id, value, categoryId) {
await query('UPDATE admin_sub_categories SET value = ?, category_id = ? WHERE id = ?', [value, categoryId, id]);
return { id, value, categoryId };
}
export async function deleteSubCategory(id) {
await query('DELETE FROM admin_sub_categories WHERE id = ?', [id]);
}
/* ═══════════════════════════════════════════════════════════════════════════
* SEVERITIES
* ═══════════════════════════════════════════════════════════════════════════ */
export async function listSeverities() {
return query('SELECT id, value FROM admin_severities ORDER BY value ASC');
}
export async function createSeverity(value) {
const result = await query('INSERT INTO admin_severities (value) VALUES (?)', [value]);
return { id: Number(result.insertId), value };
}
export async function updateSeverity(id, value) {
await query('UPDATE admin_severities SET value = ? WHERE id = ?', [value, id]);
return { id, value };
}
export async function deleteSeverity(id) {
await query('DELETE FROM admin_severities WHERE id = ?', [id]);
}
/* ═══════════════════════════════════════════════════════════════════════════
* STATUSES
* ═══════════════════════════════════════════════════════════════════════════ */
export async function listStatuses() {
const rows = await query('SELECT id, value, require_handled_by, require_comment FROM admin_statuses ORDER BY value ASC');
return rows.map(r => ({ id: r.id, value: r.value, requireHandledBy: !!r.require_handled_by, requireComment: !!r.require_comment }));
}
export async function createStatus(value, requireHandledBy = false, requireComment = false) {
const result = await query('INSERT INTO admin_statuses (value, require_handled_by, require_comment) VALUES (?, ?, ?)', [value, requireHandledBy ? 1 : 0, requireComment ? 1 : 0]);
return { id: Number(result.insertId), value, requireHandledBy, requireComment };
}
export async function updateStatus(id, value, requireHandledBy = false, requireComment = false) {
await query('UPDATE admin_statuses SET value = ?, require_handled_by = ?, require_comment = ? WHERE id = ?', [value, requireHandledBy ? 1 : 0, requireComment ? 1 : 0, id]);
return { id, value, requireHandledBy, requireComment };
}
export async function deleteStatus(id) {
await query('DELETE FROM admin_statuses WHERE id = ?', [id]);
}
/* ═══════════════════════════════════════════════════════════════════════════
* HANDLED BY
* ═══════════════════════════════════════════════════════════════════════════ */
export async function listHandledBy() {
return query('SELECT id, value FROM admin_handled_by ORDER BY value ASC');
}
export async function createHandledBy(value) {
const result = await query('INSERT INTO admin_handled_by (value) VALUES (?)', [value]);
return { id: Number(result.insertId), value };
}
export async function updateHandledBy(id, value) {
await query('UPDATE admin_handled_by SET value = ? WHERE id = ?', [value, id]);
return { id, value };
}
export async function deleteHandledBy(id) {
await query('DELETE FROM admin_handled_by WHERE id = ?', [id]);
}
/* ═══════════════════════════════════════════════════════════════════════════
* PROJECTS
* ═══════════════════════════════════════════════════════════════════════════ */
export async function listProjects() {
return query('SELECT id, value FROM admin_projects ORDER BY value ASC');
}
export async function createProject(value) {
const result = await query('INSERT INTO admin_projects (value) VALUES (?)', [value]);
return { id: Number(result.insertId), value };
}
export async function updateProject(id, value) {
await query('UPDATE admin_projects SET value = ? WHERE id = ?', [value, id]);
return { id, value };
}
export async function deleteProject(id) {
await query('DELETE FROM admin_projects WHERE id = ?', [id]);
}
/* ═══════════════════════════════════════════════════════════════════════════
* PROCESSES
* ═══════════════════════════════════════════════════════════════════════════ */
export async function listProcesses() {
return query('SELECT id, value, project_id AS projectId FROM admin_processes ORDER BY value ASC');
}
export async function createProcess(value, projectId) {
const result = await query('INSERT INTO admin_processes (value, project_id) VALUES (?, ?)', [value, projectId]);
return { id: Number(result.insertId), value, projectId };
}
export async function updateProcess(id, value, projectId) {
await query('UPDATE admin_processes SET value = ?, project_id = ? WHERE id = ?', [value, projectId, id]);
return { id, value, projectId };
}
export async function deleteProcess(id) {
await query('DELETE FROM admin_processes WHERE id = ?', [id]);
}
/* ═══════════════════════════════════════════════════════════════════════════
* USERS
* ═══════════════════════════════════════════════════════════════════════════ */
export async function listUsers() {
return query(`
SELECT id, email, password_hash AS password, name, family_name AS familyName, company, role
FROM admin_users ORDER BY name ASC
`);
}
export async function createUser(data) {
const result = await query(
'INSERT INTO admin_users (email, password_hash, name, family_name, company, role) VALUES (?, ?, ?, ?, ?, ?)',
[data.email, data.password || '', data.name, data.familyName, data.company || '', data.role]
);
return { id: Number(result.insertId), ...data };
}
export async function updateUser(id, data) {
await query(
'UPDATE admin_users SET email = ?, password_hash = ?, name = ?, family_name = ?, company = ?, role = ? WHERE id = ?',
[data.email, data.password || '', data.name, data.familyName, data.company || '', data.role, id]
);
return { id, ...data };
}
export async function deleteUser(id) {
await query('DELETE FROM admin_users WHERE id = ?', [id]);
}
/* ═══════════════════════════════════════════════════════════════════════════
* SITES
* ═══════════════════════════════════════════════════════════════════════════ */
export async function listSites() {
return query(`
SELECT id, site_code AS siteCode, host, obe_site_code AS obeSiteCode, pxs_site_code AS pxsSiteCode
FROM admin_sites ORDER BY site_code ASC
`);
}
export async function createSite(data) {
const result = await query(
'INSERT INTO admin_sites (site_code, host, obe_site_code, pxs_site_code) VALUES (?, ?, ?, ?)',
[data.siteCode, data.host || '', data.obeSiteCode || '', data.pxsSiteCode || '']
);
return { id: Number(result.insertId), ...data };
}
export async function updateSite(id, data) {
await query(
'UPDATE admin_sites SET site_code = ?, host = ?, obe_site_code = ?, pxs_site_code = ? WHERE id = ?',
[data.siteCode, data.host || '', data.obeSiteCode || '', data.pxsSiteCode || '', id]
);
return { id, ...data };
}
export async function deleteSite(id) {
await query('DELETE FROM admin_sites WHERE id = ?', [id]);
}
/* ═══════════════════════════════════════════════════════════════════════════
* CL RECORDS
* ═══════════════════════════════════════════════════════════════════════════ */
export async function listClRecords() {
const records = await query(`
SELECT id, sort_order AS sort, category, sub_category AS subCategory, severity,
image_required AS imageRequired, description_en AS descriptionEN,
description_fr AS descriptionFR, description_nl AS descriptionNL
FROM admin_cl_records ORDER BY sort_order ASC
`);
/* Convert tinyint to boolean */
for (const r of records) r.imageRequired = !!r.imageRequired;
return records;
}
export async function createClRecord(data) {
const result = await query(
`INSERT INTO admin_cl_records (sort_order, category, sub_category, severity, image_required, description_en, description_fr, description_nl)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[data.sort, data.category || '', data.subCategory || '', data.severity || '',
data.imageRequired ? 1 : 0, data.descriptionEN || '', data.descriptionFR || '', data.descriptionNL || '']
);
return { id: Number(result.insertId), ...data };
}
export async function updateClRecord(id, data) {
await query(
`UPDATE admin_cl_records SET sort_order = ?, category = ?, sub_category = ?, severity = ?,
image_required = ?, description_en = ?, description_fr = ?, description_nl = ? WHERE id = ?`,
[data.sort, data.category || '', data.subCategory || '', data.severity || '',
data.imageRequired ? 1 : 0, data.descriptionEN || '', data.descriptionFR || '', data.descriptionNL || '', id]
);
return { id, ...data };
}
export async function deleteClRecord(id) {
await query('DELETE FROM admin_cl_records WHERE id = ?', [id]);
}
/* ═══════════════════════════════════════════════════════════════════════════
* CL TEMPLATES
* ═══════════════════════════════════════════════════════════════════════════ */
export async function listClTemplates() {
const templates = await query(`
SELECT id, name, scope, version, valid_from AS validFrom, valid_till AS validTill
FROM admin_cl_templates ORDER BY name ASC
`);
/* Convert dates to yyyy-MM-dd and attach recordIds to each template */
for (const tpl of templates) {
if (tpl.validFrom instanceof Date) tpl.validFrom = tpl.validFrom.toISOString().slice(0, 10);
if (tpl.validTill instanceof Date) tpl.validTill = tpl.validTill.toISOString().slice(0, 10);
const rows = await query(
'SELECT record_id AS recordId FROM admin_cl_template_records WHERE template_id = ?', [tpl.id]
);
tpl.recordIds = rows.map((r) => r.recordId);
}
return templates;
}
export async function createClTemplate(data) {
const result = await query(
'INSERT INTO admin_cl_templates (name, scope, version, valid_from, valid_till) VALUES (?, ?, ?, ?, ?)',
[data.name, data.scope || '', data.version || '', data.validFrom || null, data.validTill || null]
);
const id = Number(result.insertId);
if (data.recordIds?.length) {
const values = data.recordIds.map((rid) => [id, rid]);
await query(
'INSERT INTO admin_cl_template_records (template_id, record_id) VALUES ' +
values.map(() => '(?, ?)').join(', '),
values.flat()
);
}
return { id, ...data };
}
export async function updateClTemplate(id, data) {
await query(
'UPDATE admin_cl_templates SET name = ?, scope = ?, version = ?, valid_from = ?, valid_till = ? WHERE id = ?',
[data.name, data.scope || '', data.version || '', data.validFrom || null, data.validTill || null, id]
);
/* Replace record associations */
await query('DELETE FROM admin_cl_template_records WHERE template_id = ?', [id]);
if (data.recordIds?.length) {
const values = data.recordIds.map((rid) => [id, rid]);
await query(
'INSERT INTO admin_cl_template_records (template_id, record_id) VALUES ' +
values.map(() => '(?, ?)').join(', '),
values.flat()
);
}
return { id, ...data };
}
export async function deleteClTemplate(id) {
await query('DELETE FROM admin_cl_templates WHERE id = ?', [id]);
}
/* ═══════════════════════════════════════════════════════════════════════════
* TASKS
* ═══════════════════════════════════════════════════════════════════════════ */
export async function listTasks() {
return query(`
SELECT id, site_id AS siteId, user_id AS userId, template_id AS templateId,
project, process, status, created_at AS createdAt
FROM admin_tasks ORDER BY created_at DESC
`);
}
export async function createTask(data) {
const result = await query(
'INSERT INTO admin_tasks (site_id, user_id, template_id, project, process, status) VALUES (?, ?, ?, ?, ?, ?)',
[data.siteId, data.userId, data.templateId, data.project || '', data.process || '', data.status || 'pending']
);
return { id: Number(result.insertId), ...data };
}
export async function updateTask(id, data) {
await query(
'UPDATE admin_tasks SET site_id = ?, user_id = ?, template_id = ?, project = ?, process = ?, status = ? WHERE id = ?',
[data.siteId, data.userId, data.templateId, data.project || '', data.process || '', data.status || 'pending', id]
);
return { id, ...data };
}
export async function deleteTask(id) {
await query('DELETE FROM admin_tasks WHERE id = ?', [id]);
}
/* ═══════════════════════════════════════════════════════════════════════════
* BULK LOAD — returns all admin data in a single response
* ═══════════════════════════════════════════════════════════════════════════ */
export async function loadAllAdminData() {
const [categories, subCategories, severities, statuses, handledBy, projects, processes, users, sites, clRecords, clTemplates, tasks] =
await Promise.all([
listCategories(),
listSubCategories(),
listSeverities(),
listStatuses(),
listHandledBy(),
listProjects(),
listProcesses(),
listUsers(),
listSites(),
listClRecords(),
listClTemplates(),
listTasks()
]);
return {
templateSettings: { categories, subCategories, severities, statuses, handledBy },
taskSettings: { projects, processes },
users,
sites,
clRecords,
clTemplates,
tasks
};
}
+115
View File
@@ -0,0 +1,115 @@
/*
* Authentication service for basic PoC login.
*
* Provides simple username/password verification:
* - Admin: credentials stored in admin_credentials table
* - User: email/password stored in admin_users table
*
* Note: This is a proof-of-concept implementation without advanced security
* features like password hashing, rate limiting, or JWT tokens.
*/
import { query } from '../db/pool.js';
/**
* Verify admin credentials against admin_credentials table.
* @param {string} username - Admin username
* @param {string} password - Admin password (plain text for PoC)
* @returns {Promise<{valid: boolean, admin?: object}>}
*/
export async function verifyAdminCredentials(username, password) {
const rows = await query(
'SELECT id, username FROM admin_credentials WHERE username = ? AND password = ? LIMIT 1',
[username, password]
);
if (rows.length === 0) {
return { valid: false };
}
return {
valid: true,
admin: { id: rows[0].id, username: rows[0].username, role: 'admin' }
};
}
/**
* Verify user credentials against admin_users table.
* @param {string} email - User email
* @param {string} password - User password (stored in password_hash column)
* @returns {Promise<{valid: boolean, user?: object}>}
*/
export async function verifyUserCredentials(email, password) {
const rows = await query(
`SELECT id, email, name, family_name AS familyName, company, role
FROM admin_users
WHERE email = ? AND password_hash = ? LIMIT 1`,
[email, password]
);
if (rows.length === 0) {
return { valid: false };
}
const user = rows[0];
return {
valid: true,
user: {
id: user.id,
email: user.email,
name: user.name,
familyName: user.familyName,
company: user.company,
role: user.role
}
};
}
/**
* Generate a simple session token (for PoC, just a random string).
* In production, use proper JWT or secure session management.
*/
export function generateSessionToken() {
return Math.random().toString(36).substring(2) + Date.now().toString(36);
}
/* In-memory session store (for PoC only - not suitable for production) */
const sessions = new Map();
/**
* Create a session for an authenticated user/admin.
*/
export function createSession(token, data) {
sessions.set(token, { ...data, createdAt: Date.now() });
}
/**
* Get session data by token.
*/
export function getSession(token) {
return sessions.get(token) || null;
}
/**
* Remove a session (logout).
*/
export function removeSession(token) {
sessions.delete(token);
}
/**
* Validate session is still valid (exists and not expired).
* Sessions expire after 24 hours for PoC.
*/
export function validateSession(token) {
const session = sessions.get(token);
if (!session) return null;
const maxAge = 24 * 60 * 60 * 1000; // 24 hours
if (Date.now() - session.createdAt > maxAge) {
sessions.delete(token);
return null;
}
return session;
}
+28
View File
@@ -121,3 +121,31 @@ export async function getAppConfig() {
value: parseJsonColumn(row.configValue)
}));
}
export async function getAppConfigValue(key) {
const rows = await query(
`SELECT config_value_json AS configValue FROM app_config WHERE config_key = ? LIMIT 1`,
[key]
);
return rows.length ? parseJsonColumn(rows[0].configValue) : null;
}
export async function upsertAppConfig(key, value) {
/*
* Upsert a single app_config row. Used by the admin module to persist entity
* data (users, sites, CL records, etc.) that was previously localStorage-only.
*/
await query(
`
INSERT INTO app_config (config_key, config_value_json)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE
config_value_json = VALUES(config_value_json),
updated_at = NOW()
`,
[key, JSON.stringify(value)]
);
return { key, value };
}
+130 -5
View File
@@ -2,13 +2,14 @@ import { query } from '../db/pool.js';
import { parseJsonColumn } from '../utils/json.js';
/*
* The report service handles server-side storage of submitted reports. In
* phase 1, reports are created locally in the browser and only uploaded when
* the operator explicitly submits. This keeps the offline-first workflow intact
* while giving the backend a durable copy for review, export, or archival.
* The report service handles server-side storage of submitted reports.
* Images are stored as BLOBs in the report_images table alongside metadata.
*/
export async function submitReport(report) {
/* Strip image dataUrls from answers before storing in JSON column */
const answersForJson = stripImagesFromAnswers(report.answers);
await query(
`
INSERT INTO reports (report_uuid, report_number, template_code, template_version, status, answers_json, submitted_at)
@@ -25,10 +26,15 @@ export async function submitReport(report) {
report.templateCode,
report.templateVersion,
report.status,
JSON.stringify(report.answers)
JSON.stringify(answersForJson)
]
);
/* Store images as BLOBs in DB */
if (report.answers?.records) {
await storeReportImages(report.id, report.answers.records);
}
return getReport(report.id);
}
@@ -106,3 +112,122 @@ function mapReportRow(row) {
updatedAt: row.updatedAt
};
}
/* ═══════════════════════════════════════════════════════════════════════════
* Image storage helpers
* ═══════════════════════════════════════════════════════════════════════════ */
/**
* Strips dataUrl from image objects in answers so the JSON column stays lean.
* The full image data is stored separately in report_images.
*/
function stripImagesFromAnswers(answers) {
if (!answers?.records) return answers;
const clean = { ...answers, records: {} };
for (const [recId, rd] of Object.entries(answers.records)) {
clean.records[recId] = {
...rd,
images: (rd.images || []).map(img => ({
name: img.name,
size: img.size,
width: img.width,
height: img.height,
exif: img.exif || null
}))
};
}
return clean;
}
/**
* Stores image binary data as BLOBs in the report_images table.
* Replaces existing images for the report on re-submit.
*/
async function storeReportImages(reportUuid, records) {
/* Clear existing images for this report to avoid duplicates */
await query('DELETE FROM report_images WHERE report_uuid = ?', [reportUuid]);
for (const [recId, rd] of Object.entries(records)) {
if (!rd.images?.length) continue;
for (let i = 0; i < rd.images.length; i++) {
const img = rd.images[i];
if (!img.dataUrl) continue;
/* Convert base64 dataUrl to Buffer */
const matches = img.dataUrl.match(/^data:([^;]+);base64,(.+)$/);
if (!matches) continue;
const mimeType = matches[1];
const buffer = Buffer.from(matches[2], 'base64');
const fileName = img.name || `image_${i}.jpg`;
await query(
`INSERT INTO report_images (report_uuid, record_id, image_index, file_name, file_size, mime_type, width_px, height_px, exif_json, image_data)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
reportUuid,
recId,
i,
fileName,
img.size || buffer.length,
mimeType,
img.width || null,
img.height || null,
img.exif ? JSON.stringify(img.exif) : null,
buffer
]
);
}
}
}
/**
* Retrieves all images for a given report, grouped by record ID.
* Returns base64 dataUrls constructed from the stored BLOBs.
*/
export async function getReportImages(reportUuid) {
const rows = await query(
`SELECT record_id AS recordId, image_index AS imageIndex, file_name AS fileName,
file_size AS fileSize, mime_type AS mimeType, width_px AS widthPx,
height_px AS heightPx, exif_json AS exifJson, image_data AS imageData
FROM report_images
WHERE report_uuid = ?
ORDER BY record_id, image_index`,
[reportUuid]
);
const grouped = {};
for (const row of rows) {
if (!grouped[row.recordId]) grouped[row.recordId] = [];
const base64 = row.imageData.toString('base64');
grouped[row.recordId].push({
index: row.imageIndex,
name: row.fileName,
size: row.fileSize,
mimeType: row.mimeType,
width: row.widthPx,
height: row.heightPx,
exif: parseJsonColumn(row.exifJson, null),
dataUrl: `data:${row.mimeType};base64,${base64}`
});
}
return grouped;
}
/**
* Deletes a report and all its associated images from DB.
*/
export async function deleteReport(reportUuid) {
/* CASCADE will remove report_images rows automatically */
await query('DELETE FROM reports WHERE report_uuid = ?', [reportUuid]);
}
/**
* Deletes a specific image for a record in a report.
*/
export async function deleteReportImage(reportUuid, recordId, fileName) {
await query(
'DELETE FROM report_images WHERE report_uuid = ? AND record_id = ? AND file_name = ?',
[reportUuid, recordId, fileName]
);
}