Working version before modification.
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ services:
|
|||||||
MARIADB_USER: ${DB_USER:-check_list_user}
|
MARIADB_USER: ${DB_USER:-check_list_user}
|
||||||
MARIADB_PASSWORD: ${DB_PASSWORD:-check_list_password}
|
MARIADB_PASSWORD: ${DB_PASSWORD:-check_list_password}
|
||||||
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-root_password}
|
MARIADB_ROOT_PASSWORD: ${MARIADB_ROOT_PASSWORD:-root_password}
|
||||||
|
MARIADB_EXTRA_FLAGS: --max-allowed-packet=64M
|
||||||
ports:
|
ports:
|
||||||
- "${DB_PORT:-3306}:3306"
|
- "${DB_PORT:-3306}:3306"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
Generated
+20
@@ -8,6 +8,7 @@
|
|||||||
"name": "check-list-poc-api",
|
"name": "check-list-poc-api",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
@@ -143,6 +144,25 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
|||||||
+35
@@ -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).
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
* Admin entry point — bootstraps the admin console.
|
||||||
|
*
|
||||||
|
* Opens the shared IndexedDB, loads cached image rules, then hands control to
|
||||||
|
* the admin module which manages navigation, CRUD, and rendering for every
|
||||||
|
* admin category (Settings, Users, Sites, Check Lists, Reports).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { state } from './js/state.js';
|
||||||
|
import { openDatabase, dbGet } from './js/db.js';
|
||||||
|
import { STORE_CONFIG } from './js/constants.js';
|
||||||
|
import { initAdmin } from './js/admin.js';
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
/* Open the shared IndexedDB so image-policy read/write keeps working. */
|
||||||
|
state.db = await openDatabase();
|
||||||
|
|
||||||
|
/* Load cached image rules (may be null on first visit). */
|
||||||
|
const configRow = await dbGet(STORE_CONFIG, 'imageRules');
|
||||||
|
|
||||||
|
/* Hand off to the admin module for all further initialization. */
|
||||||
|
await initAdmin({ imageRules: configRow?.value || null });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Re-init when navigating back (fixes stale localStorage after bfcache) */
|
||||||
|
window.addEventListener('pageshow', async (e) => {
|
||||||
|
if (e.persisted) {
|
||||||
|
state.db = await openDatabase();
|
||||||
|
const configRow = await dbGet(STORE_CONFIG, 'imageRules');
|
||||||
|
await initAdmin({ imageRules: configRow?.value || null });
|
||||||
|
}
|
||||||
|
});
|
||||||
+554
-158
@@ -6,191 +6,587 @@
|
|||||||
<meta name="theme-color" content="#f3efe6" />
|
<meta name="theme-color" content="#f3efe6" />
|
||||||
<title>Check List PoC — Admin</title>
|
<title>Check List PoC — Admin</title>
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<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" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!--
|
<div class="d-flex vh-100">
|
||||||
Administrator workspace: server-backed configuration editing for image
|
<!-- Sidebar -->
|
||||||
policies and other centrally managed settings.
|
<aside class="sidebar-bs d-flex flex-column border-end bg-light" style="width:260px;min-width:260px;">
|
||||||
-->
|
<div class="p-3 border-bottom">
|
||||||
<div class="app-shell">
|
<p class="text-uppercase text-muted small fw-semibold mb-0">Hybrid Inspection Reporting</p>
|
||||||
<aside class="sidebar panel">
|
<h5 class="fw-bold mb-0">Check List</h5>
|
||||||
<div class="brand-block">
|
<small class="text-muted">Administration console</small>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section">
|
<div class="p-3 border-bottom">
|
||||||
<div class="status-row">
|
<span id="connectionBadge" class="badge bg-secondary">Checking…</span>
|
||||||
<span id="connectionBadge" class="badge badge-neutral">Checking connection</span>
|
</div>
|
||||||
<span id="saveBadge" class="badge badge-neutral">No changes</span>
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
<button id="syncTemplatesButton" class="button button-secondary" type="button">
|
<!-- Users -->
|
||||||
Sync templates
|
<div class="admin-nav-cat mb-1">
|
||||||
</button>
|
<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">
|
||||||
</div>
|
<span><i class="bi bi-people me-1"></i>Users</span><span class="nav-arrow">▸</span>
|
||||||
|
</button>
|
||||||
<div class="sidebar-section">
|
<div class="admin-nav-sub ms-3 mt-1">
|
||||||
<label class="field-label" for="templateSelect">Template</label>
|
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="users">Users</button>
|
||||||
<select id="templateSelect" class="select-input"></select>
|
</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>
|
</div>
|
||||||
<a id="userAreaLink" class="button button-secondary sidebar-link" href="/user">User area</a>
|
<!-- Sites -->
|
||||||
<a id="adminAreaLink" class="button button-secondary sidebar-link is-active" href="/admin">Admin area</a>
|
<div class="admin-nav-cat mb-1">
|
||||||
<a class="button button-secondary sidebar-link" href="/">Back to portal</a>
|
<button class="admin-nav-cat-btn btn btn-sm btn-light w-100 text-start d-flex justify-content-between align-items-center" type="button">
|
||||||
|
<span><i class="bi bi-building me-1"></i>Sites</span><span class="nav-arrow">▸</span>
|
||||||
|
</button>
|
||||||
|
<div class="admin-nav-sub ms-3 mt-1">
|
||||||
|
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="sites">Sites</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Check Lists -->
|
||||||
|
<div class="admin-nav-cat mb-1">
|
||||||
|
<button class="admin-nav-cat-btn btn btn-sm btn-light w-100 text-start d-flex justify-content-between align-items-center" type="button">
|
||||||
|
<span><i class="bi bi-list-check me-1"></i>Check Lists</span><span class="nav-arrow">▸</span>
|
||||||
|
</button>
|
||||||
|
<div class="admin-nav-sub ms-3 mt-1">
|
||||||
|
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="cl-templates">Templates</button>
|
||||||
|
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="cl-records">Records</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Reports -->
|
||||||
|
<div class="admin-nav-cat mb-1">
|
||||||
|
<button class="admin-nav-cat-btn btn btn-sm btn-light w-100 text-start d-flex justify-content-between align-items-center" type="button">
|
||||||
|
<span><i class="bi bi-file-earmark-text me-1"></i>Reports</span><span class="nav-arrow">▸</span>
|
||||||
|
</button>
|
||||||
|
<div class="admin-nav-sub ms-3 mt-1">
|
||||||
|
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="reports">Tasks</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="p-3 border-top">
|
||||||
|
<a class="btn btn-outline-secondary btn-sm w-100 mb-1" href="/user">User area</a>
|
||||||
|
<a class="btn btn-secondary btn-sm w-100 mb-1" href="/admin">Admin area</a>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm w-100" href="/">Back to portal</a>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="workspace">
|
<!-- Main content area -->
|
||||||
<section id="adminWorkspace" class="workspace-view workspace-view-active">
|
<main class="flex-grow-1 overflow-auto p-4 bg-white">
|
||||||
<section class="hero panel">
|
|
||||||
|
<!-- 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>
|
<div>
|
||||||
<p class="eyebrow">Administrator workspace</p>
|
<p class="text-muted small mb-0">Settings › Image Policy</p>
|
||||||
<h2>Configuration control</h2>
|
<h3 class="fw-bold">Image Policy Editor</h3>
|
||||||
<p class="hero-copy">
|
<p class="text-muted">Update centrally managed image requirements.</p>
|
||||||
Update centrally managed image requirements used by the inspection frontend.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-actions">
|
<span id="adminSyncState" class="badge bg-secondary">Server-backed</span>
|
||||||
<span id="adminSyncState" class="badge badge-neutral">Server-backed settings</span>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="editor-grid">
|
<div class="row">
|
||||||
<section class="panel editor-panel">
|
<div class="col-lg-8">
|
||||||
<div class="section-heading-row">
|
<div class="card">
|
||||||
<h2>Image policy editor</h2>
|
<div class="card-body">
|
||||||
<span class="panel-note">Updates the active server rule</span>
|
<form id="adminImageRulesForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Allowed MIME types</label>
|
||||||
|
<div id="adminMimeCheckboxes" class="row g-2">
|
||||||
|
<div class="col-6 col-md-4"><div class="form-check"><input class="form-check-input" type="checkbox" name="mimeType" value="image/jpeg" id="mime-jpeg"><label class="form-check-label" for="mime-jpeg">image/jpeg</label></div></div>
|
||||||
|
<div class="col-6 col-md-4"><div class="form-check"><input class="form-check-input" type="checkbox" name="mimeType" value="image/png" id="mime-png"><label class="form-check-label" for="mime-png">image/png</label></div></div>
|
||||||
|
<div class="col-6 col-md-4"><div class="form-check"><input class="form-check-input" type="checkbox" name="mimeType" value="image/webp" id="mime-webp"><label class="form-check-label" for="mime-webp">image/webp</label></div></div>
|
||||||
|
<div class="col-6 col-md-4"><div class="form-check"><input class="form-check-input" type="checkbox" name="mimeType" value="image/gif" id="mime-gif"><label class="form-check-label" for="mime-gif">image/gif</label></div></div>
|
||||||
|
<div class="col-6 col-md-4"><div class="form-check"><input class="form-check-input" type="checkbox" name="mimeType" value="image/bmp" id="mime-bmp"><label class="form-check-label" for="mime-bmp">image/bmp</label></div></div>
|
||||||
|
<div class="col-6 col-md-4"><div class="form-check"><input class="form-check-input" type="checkbox" name="mimeType" value="image/tiff" id="mime-tiff"><label class="form-check-label" for="mime-tiff">image/tiff</label></div></div>
|
||||||
|
<div class="col-6 col-md-4"><div class="form-check"><input class="form-check-input" type="checkbox" name="mimeType" value="image/svg+xml" id="mime-svg"><label class="form-check-label" for="mime-svg">image/svg+xml</label></div></div>
|
||||||
|
<div class="col-6 col-md-4"><div class="form-check"><input class="form-check-input" type="checkbox" name="mimeType" value="image/heic" id="mime-heic"><label class="form-check-label" for="mime-heic">image/heic</label></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="adminMaxFileSizeKb" class="form-label">Max file size (KB)</label>
|
||||||
|
<input id="adminMaxFileSizeKb" class="form-control" type="number" min="1" step="1" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="adminMaxWidthPx" class="form-label">Max width (px)</label>
|
||||||
|
<input id="adminMaxWidthPx" class="form-control" type="number" min="1" step="1" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="adminMaxHeightPx" class="form-label">Max height (px)</label>
|
||||||
|
<input id="adminMaxHeightPx" class="form-control" type="number" min="1" step="1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="adminImageQuality" class="form-label">Image quality (%)</label>
|
||||||
|
<input id="adminImageQuality" class="form-control" type="number" min="1" max="100" step="1" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="adminOversizeBehavior" class="form-label">Oversize behavior</label>
|
||||||
|
<select id="adminOversizeBehavior" class="form-select">
|
||||||
|
<option value="auto_optimize">Auto optimize</option>
|
||||||
|
<option value="warn_then_optimize">Warn then optimize</option>
|
||||||
|
<option value="block">Block oversized files</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button id="saveImageRulesButton" class="btn btn-primary" type="submit">Save image policy</button>
|
||||||
|
<button id="resetImageRulesButton" class="btn btn-outline-secondary" type="button">Reset form</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form id="adminImageRulesForm" class="report-form admin-form">
|
</div>
|
||||||
<section class="template-section">
|
<div class="col-lg-4">
|
||||||
<div class="field-grid">
|
<div class="card">
|
||||||
<div class="field field-full">
|
<div class="card-header fw-semibold">Live Preview</div>
|
||||||
<div class="field-header">
|
<div class="card-body">
|
||||||
<label class="field-label" for="adminPolicyName">Policy name</label>
|
<dl class="mb-0">
|
||||||
</div>
|
<dt class="small text-muted">Allowed types</dt>
|
||||||
<input id="adminPolicyName" name="name" class="text-input" type="text" />
|
<dd id="adminPolicyMimeTypes" class="mb-2">-</dd>
|
||||||
</div>
|
<dt class="small text-muted">Max file size</dt>
|
||||||
|
<dd id="adminPolicyFileSize" class="mb-2">-</dd>
|
||||||
|
<dt class="small text-muted">Optimization</dt>
|
||||||
|
<dd id="adminPolicyOptimization" class="mb-2">-</dd>
|
||||||
|
<dt class="small text-muted">Limits</dt>
|
||||||
|
<dd id="adminPolicyLimits" class="mb-0">-</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="field field-full">
|
<!-- SETTINGS > TEMPLATE -->
|
||||||
<div class="field-header">
|
<section id="panel-settings-template" class="admin-panel">
|
||||||
<label class="field-label" for="adminAllowedMimeTypes">Allowed MIME types</label>
|
<div class="mb-3">
|
||||||
</div>
|
<p class="text-muted small mb-0">Settings › Template</p>
|
||||||
<input
|
<h3 class="fw-bold">Record Dropdown Configuration</h3>
|
||||||
id="adminAllowedMimeTypes"
|
<p class="text-muted">Define dropdown values for checklist record forms.</p>
|
||||||
name="allowedMimeTypes"
|
</div>
|
||||||
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="card">
|
||||||
<div class="field-header">
|
<div class="card-body">
|
||||||
<label class="field-label" for="adminMaxFileSizeMb">Max file size (MB)</label>
|
<!-- Categories -->
|
||||||
</div>
|
<h6 class="fw-semibold">Categories</h6>
|
||||||
<input id="adminMaxFileSizeMb" name="maxFileSizeMb" class="text-input" type="number" min="1" step="0.1" />
|
<div class="input-group input-group-sm mb-2">
|
||||||
</div>
|
<input id="tsCatInput" class="form-control" type="text" placeholder="Add category…" />
|
||||||
|
<button type="button" class="btn btn-outline-primary" data-ts-action="add-cat">Add</button>
|
||||||
|
</div>
|
||||||
|
<div id="tsCatList" class="mb-4"></div>
|
||||||
|
|
||||||
<div class="field">
|
<!-- Sub Categories -->
|
||||||
<div class="field-header">
|
<h6 class="fw-semibold">Sub Categories</h6>
|
||||||
<label class="field-label" for="adminMaxAttachmentsPerField">Max attachments per field</label>
|
<p class="text-muted small">Parent category is mandatory.</p>
|
||||||
</div>
|
<div class="input-group input-group-sm mb-2">
|
||||||
<input id="adminMaxAttachmentsPerField" name="maxAttachmentsPerField" class="text-input" type="number" min="1" step="1" />
|
<select id="tsSubCatParent" class="form-select" style="max-width:180px"><option value="">Parent…</option></select>
|
||||||
</div>
|
<input id="tsSubCatInput" class="form-control" type="text" placeholder="Add sub category…" />
|
||||||
|
<button type="button" class="btn btn-outline-primary" data-ts-action="add-subcat">Add</button>
|
||||||
|
</div>
|
||||||
|
<div id="tsSubCatList" class="mb-4"></div>
|
||||||
|
|
||||||
<div class="field">
|
<!-- Severities -->
|
||||||
<div class="field-header">
|
<h6 class="fw-semibold">Severities</h6>
|
||||||
<label class="field-label" for="adminMaxWidthPx">Max width (px)</label>
|
<div class="input-group input-group-sm mb-2">
|
||||||
</div>
|
<input id="tsSevInput" class="form-control" type="text" placeholder="Add severity…" />
|
||||||
<input id="adminMaxWidthPx" name="maxWidthPx" class="text-input" type="number" min="1" step="1" />
|
<button type="button" class="btn btn-outline-primary" data-ts-action="add-sev">Add</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tsSevList" class="mb-4"></div>
|
||||||
|
|
||||||
<div class="field">
|
<!-- Statuses -->
|
||||||
<div class="field-header">
|
<h6 class="fw-semibold">Statuses</h6>
|
||||||
<label class="field-label" for="adminMaxHeightPx">Max height (px)</label>
|
<div class="input-group input-group-sm mb-1">
|
||||||
</div>
|
<input id="tsStatusInput" class="form-control" type="text" placeholder="Add status…" />
|
||||||
<input id="adminMaxHeightPx" name="maxHeightPx" class="text-input" type="number" min="1" step="1" />
|
<button type="button" class="btn btn-outline-primary" data-ts-action="add-status">Add</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-check form-check-inline small mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="tsStatusReqHandled" />
|
||||||
|
<label class="form-check-label" for="tsStatusReqHandled">Handled By required</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check form-check-inline small mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="tsStatusReqComment" />
|
||||||
|
<label class="form-check-label" for="tsStatusReqComment">Comment required</label>
|
||||||
|
</div>
|
||||||
|
<div id="tsStatusList" class="mb-4"></div>
|
||||||
|
|
||||||
<div class="field">
|
<!-- Handled By -->
|
||||||
<div class="field-header">
|
<h6 class="fw-semibold">Handled By</h6>
|
||||||
<label class="field-label" for="adminJpegQuality">JPEG quality</label>
|
<div class="input-group input-group-sm mb-2">
|
||||||
</div>
|
<input id="tsHandledInput" class="form-control" type="text" placeholder="Add handler…" />
|
||||||
<input id="adminJpegQuality" name="jpegQuality" class="text-input" type="number" min="1" max="100" step="1" />
|
<button type="button" class="btn btn-outline-primary" data-ts-action="add-handled">Add</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tsHandledList"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="field">
|
<!-- SETTINGS > TASK -->
|
||||||
<div class="field-header">
|
<section id="panel-settings-task" class="admin-panel">
|
||||||
<label class="field-label" for="adminOversizeBehavior">Oversize behavior</label>
|
<div class="mb-3">
|
||||||
</div>
|
<p class="text-muted small mb-0">Settings › Task</p>
|
||||||
<select id="adminOversizeBehavior" name="oversizeBehavior" class="select-input">
|
<h3 class="fw-bold">Task Dropdown Configuration</h3>
|
||||||
<option value="auto_optimize">Auto optimize</option>
|
<p class="text-muted">Define Project and Process values for task assignment.</p>
|
||||||
<option value="warn_then_optimize">Warn then optimize</option>
|
</div>
|
||||||
<option value="block">Block oversized files</option>
|
|
||||||
</select>
|
<div class="card">
|
||||||
</div>
|
<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>
|
||||||
</section>
|
<div class="col-md-6">
|
||||||
|
<label for="userPassword" class="form-label">Password</label>
|
||||||
<div class="admin-actions">
|
<input id="userPassword" class="form-control" type="password" required />
|
||||||
<button id="saveImageRulesButton" class="button button-primary" type="submit">
|
</div>
|
||||||
Save image policy
|
<div class="col-md-4">
|
||||||
</button>
|
<label for="userName" class="form-label">Name</label>
|
||||||
<button id="resetImageRulesButton" class="button button-secondary" type="button">
|
<input id="userName" class="form-control" type="text" required />
|
||||||
Reset form
|
</div>
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</div>
|
||||||
|
</div>
|
||||||
<aside class="panel inspector-panel">
|
|
||||||
<div class="section-heading-row">
|
|
||||||
<h2>Admin summary</h2>
|
|
||||||
<span class="panel-note">Live configuration preview</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl class="meta-list">
|
|
||||||
<div>
|
|
||||||
<dt>Active policy code</dt>
|
|
||||||
<dd id="adminPolicyCode">-</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Allowed types</dt>
|
|
||||||
<dd id="adminPolicyMimeTypes">-</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Optimization</dt>
|
|
||||||
<dd id="adminPolicyOptimization">-</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Limits</dt>
|
|
||||||
<dd id="adminPolicyLimits">-</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div class="validation-block">
|
|
||||||
<h3>Admin notes</h3>
|
|
||||||
<ul class="validation-list" id="adminNotesList">
|
|
||||||
<li>Changes are stored on the server and reused by report attachments.</li>
|
|
||||||
<li>Operators will use the updated policy after the next sync.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
</section>
|
</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>
|
</main>
|
||||||
</div>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+218
-285
@@ -6,67 +6,55 @@
|
|||||||
<meta name="theme-color" content="#f3efe6" />
|
<meta name="theme-color" content="#f3efe6" />
|
||||||
<title>Check List PoC</title>
|
<title>Check List PoC</title>
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<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" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!--
|
<div class="d-flex vh-100">
|
||||||
This document is the shared app shell for both operator and administrator
|
<!-- Sidebar -->
|
||||||
routes. JavaScript decides which workspace to reveal based on the current
|
<aside class="sidebar-bs d-flex flex-column border-end bg-light" style="width:280px;min-width:280px;">
|
||||||
URL so the project can keep one frontend bundle while still presenting two
|
<div class="p-3 border-bottom">
|
||||||
distinct entry points.
|
<p class="text-uppercase text-muted small fw-semibold mb-0">Hybrid Inspection Reporting</p>
|
||||||
-->
|
<h5 class="fw-bold mb-0">Check List</h5>
|
||||||
<div class="app-shell">
|
<small class="text-muted">Offline-first proof of concept for template-driven quality reports.</small>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section">
|
<div class="p-3 border-bottom">
|
||||||
<div class="status-row">
|
<div class="d-flex gap-2 mb-2">
|
||||||
<span id="connectionBadge" class="badge badge-neutral">Checking connection</span>
|
<span id="connectionBadge" class="badge bg-secondary">Checking connection</span>
|
||||||
<span id="saveBadge" class="badge badge-neutral">No changes</span>
|
<span id="saveBadge" class="badge bg-secondary">No changes</span>
|
||||||
</div>
|
</div>
|
||||||
<button id="syncTemplatesButton" class="button button-secondary" type="button">
|
<button id="syncTemplatesButton" class="btn btn-outline-secondary btn-sm w-100" type="button">
|
||||||
Sync templates
|
<i class="bi bi-arrow-repeat me-1"></i>Sync templates
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section">
|
<div class="p-3 border-bottom">
|
||||||
<label class="field-label" for="templateSelect">Template</label>
|
<label class="form-label small fw-semibold" for="templateSelect">Template</label>
|
||||||
<select id="templateSelect" class="select-input"></select>
|
<select id="templateSelect" class="form-select form-select-sm mb-2"></select>
|
||||||
<button id="createReportButton" class="button button-primary" type="button">
|
<button id="createReportButton" class="btn btn-primary btn-sm w-100" type="button">
|
||||||
Create new report
|
<i class="bi bi-plus-lg me-1"></i>Create new report
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section">
|
<div class="p-3 border-bottom">
|
||||||
<div class="section-heading-row sidebar-links-heading">
|
<a id="userAreaLink" class="btn btn-outline-secondary btn-sm w-100 mb-1" href="/user">User area</a>
|
||||||
<h2>Access</h2>
|
<a id="adminAreaLink" class="btn btn-outline-secondary btn-sm w-100 mb-1" href="/admin">Admin area</a>
|
||||||
<span class="muted-count">Direct links</span>
|
<a class="btn btn-outline-secondary btn-sm w-100" href="/">Back to portal</a>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section grow-section">
|
<div class="flex-grow-1 overflow-auto p-3">
|
||||||
<div class="section-heading-row">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<h2>Local reports</h2>
|
<h6 class="fw-semibold mb-0">Local reports</h6>
|
||||||
<span id="reportCount" class="muted-count">0</span>
|
<span id="reportCount" class="badge bg-secondary">0</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- F4 — Search and status filter for the local report list -->
|
<div class="mb-2">
|
||||||
<div class="report-filter-row">
|
<input id="reportSearchInput" class="form-control form-control-sm mb-1" type="search" placeholder="Search reports" />
|
||||||
<input id="reportSearchInput" class="text-input text-input-small" type="search" placeholder="Search reports" />
|
<select id="reportFilterSelect" class="form-select form-select-sm">
|
||||||
<select id="reportFilterSelect" class="select-input select-input-small">
|
|
||||||
<option value="">All statuses</option>
|
<option value="">All statuses</option>
|
||||||
<option value="draft">Draft</option>
|
<option value="draft">Draft</option>
|
||||||
<option value="in_progress">In Progress</option>
|
<option value="in_progress">In Progress</option>
|
||||||
@@ -79,259 +67,204 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="workspace">
|
<!-- Main content -->
|
||||||
<!-- Operator workspace: draft editing, validation, and local attachments. -->
|
<main class="flex-grow-1 overflow-auto p-4 bg-white">
|
||||||
|
|
||||||
|
<!-- Operator workspace -->
|
||||||
<section id="reportsWorkspace" class="workspace-view workspace-view-active">
|
<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>
|
|
||||||
</div>
|
|
||||||
<div class="hero-actions">
|
|
||||||
<label class="status-picker">
|
|
||||||
<span>Status</span>
|
|
||||||
<select id="reportStatusSelect" class="select-input">
|
|
||||||
<option value="draft">Draft</option>
|
|
||||||
<option value="in_progress">In Progress</option>
|
|
||||||
<option value="ready_for_export">Ready for Export</option>
|
|
||||||
<option value="exported">Exported</option>
|
|
||||||
<option value="archived">Archived</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button id="submitReportButton" class="button button-secondary" type="button">
|
|
||||||
Submit
|
|
||||||
</button>
|
|
||||||
<button id="exportReportButton" class="button button-secondary" type="button">
|
|
||||||
Export CSV
|
|
||||||
</button>
|
|
||||||
<button id="deleteReportButton" class="button button-ghost" type="button">
|
|
||||||
Delete report
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="summary-grid">
|
|
||||||
<article class="summary-card panel accent-card">
|
|
||||||
<p class="summary-label">Template</p>
|
|
||||||
<strong id="summaryTemplate">Not loaded</strong>
|
|
||||||
<span id="summaryVersion" class="summary-note">Version -</span>
|
|
||||||
</article>
|
|
||||||
<article class="summary-card panel">
|
|
||||||
<p class="summary-label">Validation</p>
|
|
||||||
<strong id="validationHeadline">No report selected</strong>
|
|
||||||
<span id="validationDetail" class="summary-note">Draft validation will appear here.</span>
|
|
||||||
</article>
|
|
||||||
<article class="summary-card panel">
|
|
||||||
<p class="summary-label">Offline cache</p>
|
|
||||||
<strong id="syncHeadline">No sync yet</strong>
|
|
||||||
<span id="syncDetail" class="summary-note">Templates are cached locally after the first successful sync.</span>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="editor-grid">
|
|
||||||
<section class="panel editor-panel">
|
|
||||||
<div class="section-heading-row">
|
|
||||||
<h2>Report editor</h2>
|
|
||||||
<span id="editorHint" class="panel-note">Dynamic form rendering from template JSON</span>
|
|
||||||
</div>
|
|
||||||
<form id="reportForm" class="report-form">
|
|
||||||
<div class="empty-state">
|
|
||||||
<h3>No report open</h3>
|
|
||||||
<p>Choose a template and create a report to start editing locally.</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<aside class="panel inspector-panel">
|
|
||||||
<div class="section-heading-row">
|
|
||||||
<h2>Inspector view</h2>
|
|
||||||
<span class="panel-note">Local draft summary</span>
|
|
||||||
</div>
|
|
||||||
<dl id="reportMeta" class="meta-list">
|
|
||||||
<div>
|
|
||||||
<dt>Report ID</dt>
|
|
||||||
<dd>-</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Template</dt>
|
|
||||||
<dd>-</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Created</dt>
|
|
||||||
<dd>-</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Updated</dt>
|
|
||||||
<dd>-</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div class="validation-block">
|
|
||||||
<h3>Validation issues</h3>
|
|
||||||
<ul id="validationList" class="validation-list">
|
|
||||||
<li>No report selected.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="attachment-policy">
|
|
||||||
<h3>Image policy</h3>
|
|
||||||
<p id="imagePolicyText" class="policy-copy">
|
|
||||||
Load server configuration to see image limits and optimization rules.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Administrator workspace: server-backed configuration editing. -->
|
|
||||||
<section id="adminWorkspace" class="workspace-view" hidden>
|
|
||||||
<section class="hero panel">
|
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Administrator workspace</p>
|
<p class="text-muted small mb-0">Proof of concept frontend</p>
|
||||||
<h2>Configuration control</h2>
|
<h3 id="heroTitle" class="fw-bold">No report selected</h3>
|
||||||
<p class="hero-copy">
|
<p id="heroSubtitle" class="text-muted">Start by syncing templates and creating a local draft.</p>
|
||||||
Update centrally managed image requirements used by the inspection frontend.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-actions">
|
<div class="d-flex gap-2 align-items-center">
|
||||||
<span id="adminSyncState" class="badge badge-neutral">Server-backed settings</span>
|
<label class="d-flex align-items-center gap-1 small">
|
||||||
|
<span>Status</span>
|
||||||
|
<select id="reportStatusSelect" class="form-select form-select-sm" style="width:auto">
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="ready_for_export">Ready for Export</option>
|
||||||
|
<option value="exported">Exported</option>
|
||||||
|
<option value="archived">Archived</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button id="submitReportButton" class="btn btn-outline-secondary btn-sm" type="button">Submit</button>
|
||||||
|
<button id="exportReportButton" class="btn btn-outline-secondary btn-sm" type="button">Export CSV</button>
|
||||||
|
<button id="deleteReportButton" class="btn btn-outline-danger btn-sm" type="button">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
<section class="editor-grid">
|
<!-- Summary cards -->
|
||||||
<section class="panel editor-panel">
|
<div class="row g-3 mb-4">
|
||||||
<div class="section-heading-row">
|
<div class="col-md-4">
|
||||||
<h2>Image policy editor</h2>
|
<div class="card border-primary">
|
||||||
<span class="panel-note">Updates the active server rule</span>
|
<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>
|
||||||
<form id="adminImageRulesForm" class="report-form admin-form">
|
</div>
|
||||||
<section class="template-section">
|
<div class="col-md-4">
|
||||||
<div class="field-grid">
|
<div class="card">
|
||||||
<div class="field field-full">
|
<div class="card-body py-2 px-3">
|
||||||
<div class="field-header">
|
<small class="text-muted">Validation</small>
|
||||||
<label class="field-label" for="adminPolicyName">Policy name</label>
|
<div class="fw-semibold" id="validationHeadline">No report selected</div>
|
||||||
</div>
|
<small class="text-muted" id="validationDetail">Draft validation will appear here.</small>
|
||||||
<input id="adminPolicyName" name="name" class="text-input" type="text" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field field-full">
|
|
||||||
<div class="field-header">
|
|
||||||
<label class="field-label" for="adminAllowedMimeTypes">Allowed MIME types</label>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
id="adminAllowedMimeTypes"
|
|
||||||
name="allowedMimeTypes"
|
|
||||||
class="text-input"
|
|
||||||
type="text"
|
|
||||||
placeholder="image/jpeg, image/png, image/webp"
|
|
||||||
/>
|
|
||||||
<p class="field-help">Comma-separated values used by the attachment field and browser validation.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<div class="field-header">
|
|
||||||
<label class="field-label" for="adminMaxFileSizeMb">Max file size (MB)</label>
|
|
||||||
</div>
|
|
||||||
<input id="adminMaxFileSizeMb" name="maxFileSizeMb" class="text-input" type="number" min="1" step="0.1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<div class="field-header">
|
|
||||||
<label class="field-label" for="adminMaxAttachmentsPerField">Max attachments per field</label>
|
|
||||||
</div>
|
|
||||||
<input id="adminMaxAttachmentsPerField" name="maxAttachmentsPerField" class="text-input" type="number" min="1" step="1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<div class="field-header">
|
|
||||||
<label class="field-label" for="adminMaxWidthPx">Max width (px)</label>
|
|
||||||
</div>
|
|
||||||
<input id="adminMaxWidthPx" name="maxWidthPx" class="text-input" type="number" min="1" step="1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<div class="field-header">
|
|
||||||
<label class="field-label" for="adminMaxHeightPx">Max height (px)</label>
|
|
||||||
</div>
|
|
||||||
<input id="adminMaxHeightPx" name="maxHeightPx" class="text-input" type="number" min="1" step="1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<div class="field-header">
|
|
||||||
<label class="field-label" for="adminJpegQuality">JPEG quality</label>
|
|
||||||
</div>
|
|
||||||
<input id="adminJpegQuality" name="jpegQuality" class="text-input" type="number" min="1" max="100" step="1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field">
|
|
||||||
<div class="field-header">
|
|
||||||
<label class="field-label" for="adminOversizeBehavior">Oversize behavior</label>
|
|
||||||
</div>
|
|
||||||
<select id="adminOversizeBehavior" name="oversizeBehavior" class="select-input">
|
|
||||||
<option value="auto_optimize">Auto optimize</option>
|
|
||||||
<option value="warn_then_optimize">Warn then optimize</option>
|
|
||||||
<option value="block">Block oversized files</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="admin-actions">
|
|
||||||
<button id="saveImageRulesButton" class="button button-primary" type="submit">
|
|
||||||
Save image policy
|
|
||||||
</button>
|
|
||||||
<button id="resetImageRulesButton" class="button button-secondary" type="button">
|
|
||||||
Reset form
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
</div>
|
||||||
<dl class="meta-list">
|
<div class="col-md-4">
|
||||||
<div>
|
<div class="card">
|
||||||
<dt>Active policy code</dt>
|
<div class="card-body py-2 px-3">
|
||||||
<dd id="adminPolicyCode">-</dd>
|
<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>
|
|
||||||
<dt>Allowed types</dt>
|
|
||||||
<dd id="adminPolicyMimeTypes">-</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Optimization</dt>
|
|
||||||
<dd id="adminPolicyOptimization">-</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Limits</dt>
|
|
||||||
<dd id="adminPolicyLimits">-</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div class="validation-block">
|
|
||||||
<h3>Admin notes</h3>
|
|
||||||
<ul class="validation-list" id="adminNotesList">
|
|
||||||
<li>Changes are stored on the server and reused by report attachments.</li>
|
|
||||||
<li>Operators will use the updated policy after the next sync.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
|
||||||
|
<!-- Editor + Inspector -->
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="mb-0 fw-semibold">Report editor</h6>
|
||||||
|
<small id="editorHint" class="text-muted">Dynamic form rendering from template JSON</small>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="reportForm" class="report-form">
|
||||||
|
<div class="text-center text-muted py-4">
|
||||||
|
<h5>No report open</h5>
|
||||||
|
<p>Choose a template and create a report to start editing locally.</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header fw-semibold">Inspector view</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl id="reportMeta" class="mb-0">
|
||||||
|
<dt class="small text-muted">Report ID</dt><dd class="mb-2">-</dd>
|
||||||
|
<dt class="small text-muted">Template</dt><dd class="mb-2">-</dd>
|
||||||
|
<dt class="small text-muted">Created</dt><dd class="mb-2">-</dd>
|
||||||
|
<dt class="small text-muted">Updated</dt><dd class="mb-0">-</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card mb-3">
|
||||||
|
<div class="card-header fw-semibold">Validation issues</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul id="validationList" class="mb-0 ps-3">
|
||||||
|
<li>No report selected.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header fw-semibold">Image policy</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p id="imagePolicyText" class="mb-0 text-muted small">
|
||||||
|
Load server configuration to see image limits and optimization rules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Admin workspace (legacy) -->
|
||||||
|
<section id="adminWorkspace" class="workspace-view" hidden>
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-muted small mb-0">Administrator workspace</p>
|
||||||
|
<h3 class="fw-bold">Configuration control</h3>
|
||||||
|
<p class="text-muted">Update centrally managed image requirements used by the inspection frontend.</p>
|
||||||
|
<span id="adminSyncState" class="badge bg-secondary">Server-backed settings</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header fw-semibold">Image policy editor</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form id="adminImageRulesForm">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="adminPolicyName" class="form-label">Policy name</label>
|
||||||
|
<input id="adminPolicyName" name="name" class="form-control" type="text" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<label for="adminAllowedMimeTypes" class="form-label">Allowed MIME types</label>
|
||||||
|
<input id="adminAllowedMimeTypes" name="allowedMimeTypes" class="form-control" type="text" placeholder="image/jpeg, image/png, image/webp" />
|
||||||
|
<div class="form-text">Comma-separated values used by the attachment field and browser validation.</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="adminMaxFileSizeMb" class="form-label">Max file size (MB)</label>
|
||||||
|
<input id="adminMaxFileSizeMb" name="maxFileSizeMb" class="form-control" type="number" min="1" step="0.1" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="adminMaxAttachmentsPerField" class="form-label">Max attachments per field</label>
|
||||||
|
<input id="adminMaxAttachmentsPerField" name="maxAttachmentsPerField" class="form-control" type="number" min="1" step="1" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="adminMaxWidthPx" class="form-label">Max width (px)</label>
|
||||||
|
<input id="adminMaxWidthPx" name="maxWidthPx" class="form-control" type="number" min="1" step="1" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="adminMaxHeightPx" class="form-label">Max height (px)</label>
|
||||||
|
<input id="adminMaxHeightPx" name="maxHeightPx" class="form-control" type="number" min="1" step="1" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label for="adminJpegQuality" class="form-label">JPEG quality</label>
|
||||||
|
<input id="adminJpegQuality" name="jpegQuality" class="form-control" type="number" min="1" max="100" step="1" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="adminOversizeBehavior" class="form-label">Oversize behavior</label>
|
||||||
|
<select id="adminOversizeBehavior" name="oversizeBehavior" class="form-select">
|
||||||
|
<option value="auto_optimize">Auto optimize</option>
|
||||||
|
<option value="warn_then_optimize">Warn then optimize</option>
|
||||||
|
<option value="block">Block oversized files</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button id="saveImageRulesButton" class="btn btn-primary" type="submit">Save image policy</button>
|
||||||
|
<button id="resetImageRulesButton" class="btn btn-outline-secondary" type="button">Reset form</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header fw-semibold">Admin summary</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="mb-0">
|
||||||
|
<dt class="small text-muted">Active policy code</dt><dd id="adminPolicyCode" class="mb-2">-</dd>
|
||||||
|
<dt class="small text-muted">Allowed types</dt><dd id="adminPolicyMimeTypes" class="mb-2">-</dd>
|
||||||
|
<dt class="small text-muted">Optimization</dt><dd id="adminPolicyOptimization" class="mb-2">-</dd>
|
||||||
|
<dt class="small text-muted">Limits</dt><dd id="adminPolicyLimits" class="mb-0">-</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header fw-semibold">Admin notes</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul id="adminNotesList" class="mb-0 ps-3">
|
||||||
|
<li>Changes are stored on the server and reused by report attachments.</li>
|
||||||
|
<li>Operators will use the updated policy after the next sync.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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">
|
<template id="reportListItemTemplate">
|
||||||
<button class="report-list-item" type="button" data-report-id="">
|
<button class="report-list-item" type="button" data-report-id="">
|
||||||
<span class="report-list-item__header">
|
<span class="report-list-item__header">
|
||||||
|
|||||||
+1788
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,283 @@
|
|||||||
|
/*
|
||||||
|
* exif.js — Lightweight EXIF parser for JPEG images in the browser.
|
||||||
|
*
|
||||||
|
* Extracts basic EXIF metadata (camera make/model, date taken, GPS coords,
|
||||||
|
* orientation, dimensions) from a base64 dataUrl or ArrayBuffer.
|
||||||
|
*
|
||||||
|
* This is a minimal parser — it handles the most common IFD0 and GPS tags
|
||||||
|
* without pulling in a full library.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── EXIF Tag IDs ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const TAGS = {
|
||||||
|
0x010F: 'make',
|
||||||
|
0x0110: 'model',
|
||||||
|
0x0112: 'orientation',
|
||||||
|
0x011A: 'xResolution',
|
||||||
|
0x011B: 'yResolution',
|
||||||
|
0x0132: 'dateTime',
|
||||||
|
0x8769: 'exifOffset',
|
||||||
|
0x8825: 'gpsOffset',
|
||||||
|
0xA002: 'pixelXDimension',
|
||||||
|
0xA003: 'pixelYDimension',
|
||||||
|
0x9003: 'dateTimeOriginal',
|
||||||
|
0x9004: 'dateTimeDigitized',
|
||||||
|
0x920A: 'focalLength',
|
||||||
|
0x829A: 'exposureTime',
|
||||||
|
0x829D: 'fNumber',
|
||||||
|
0x8827: 'isoSpeed'
|
||||||
|
};
|
||||||
|
|
||||||
|
const GPS_TAGS = {
|
||||||
|
0x0001: 'latRef',
|
||||||
|
0x0002: 'lat',
|
||||||
|
0x0003: 'lonRef',
|
||||||
|
0x0004: 'lon',
|
||||||
|
0x0005: 'altRef',
|
||||||
|
0x0006: 'alt'
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Public API ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse EXIF from a base64 dataUrl string.
|
||||||
|
* Returns an object with parsed tags, or null if no EXIF found.
|
||||||
|
*/
|
||||||
|
export function parseExifFromDataUrl(dataUrl) {
|
||||||
|
if (!dataUrl || !dataUrl.startsWith('data:image/jpeg')) return null;
|
||||||
|
try {
|
||||||
|
const base64 = dataUrl.split(',')[1];
|
||||||
|
const binary = atob(base64);
|
||||||
|
const buffer = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) buffer[i] = binary.charCodeAt(i);
|
||||||
|
return parseExif(buffer.buffer);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse EXIF from an ArrayBuffer.
|
||||||
|
* Returns an object with parsed tags, or null if no EXIF found.
|
||||||
|
*/
|
||||||
|
export function parseExif(arrayBuffer) {
|
||||||
|
const view = new DataView(arrayBuffer);
|
||||||
|
|
||||||
|
/* Check JPEG SOI marker */
|
||||||
|
if (view.getUint16(0) !== 0xFFD8) return null;
|
||||||
|
|
||||||
|
/* Find APP1 marker (EXIF) */
|
||||||
|
let offset = 2;
|
||||||
|
while (offset < view.byteLength - 4) {
|
||||||
|
const marker = view.getUint16(offset);
|
||||||
|
if (marker === 0xFFE1) {
|
||||||
|
/* Found APP1 */
|
||||||
|
const length = view.getUint16(offset + 2);
|
||||||
|
return parseExifData(view, offset + 4, length - 2);
|
||||||
|
}
|
||||||
|
/* Skip to next marker */
|
||||||
|
if ((marker & 0xFF00) !== 0xFF00) break;
|
||||||
|
const segLen = view.getUint16(offset + 2);
|
||||||
|
offset += 2 + segLen;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Internal parsing ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function parseExifData(view, start, length) {
|
||||||
|
/* Check "Exif\0\0" header */
|
||||||
|
const exifStr = String.fromCharCode(
|
||||||
|
view.getUint8(start), view.getUint8(start + 1),
|
||||||
|
view.getUint8(start + 2), view.getUint8(start + 3)
|
||||||
|
);
|
||||||
|
if (exifStr !== 'Exif') return null;
|
||||||
|
|
||||||
|
const tiffStart = start + 6;
|
||||||
|
const byteOrder = view.getUint16(tiffStart);
|
||||||
|
const littleEndian = byteOrder === 0x4949; /* 'II' = Intel = little-endian */
|
||||||
|
|
||||||
|
/* Verify TIFF magic number */
|
||||||
|
if (view.getUint16(tiffStart + 2, littleEndian) !== 0x002A) return null;
|
||||||
|
|
||||||
|
const ifd0Offset = view.getUint32(tiffStart + 4, littleEndian);
|
||||||
|
const result = {};
|
||||||
|
|
||||||
|
/* Parse IFD0 */
|
||||||
|
const ifd0 = parseIFD(view, tiffStart, tiffStart + ifd0Offset, littleEndian, TAGS);
|
||||||
|
Object.assign(result, ifd0);
|
||||||
|
|
||||||
|
/* Parse EXIF sub-IFD if present */
|
||||||
|
if (ifd0.exifOffset) {
|
||||||
|
const exifIfd = parseIFD(view, tiffStart, tiffStart + ifd0.exifOffset, littleEndian, TAGS);
|
||||||
|
delete result.exifOffset;
|
||||||
|
Object.assign(result, exifIfd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Parse GPS IFD if present */
|
||||||
|
if (ifd0.gpsOffset) {
|
||||||
|
const gpsIfd = parseIFD(view, tiffStart, tiffStart + ifd0.gpsOffset, littleEndian, GPS_TAGS);
|
||||||
|
delete result.gpsOffset;
|
||||||
|
if (gpsIfd.lat && gpsIfd.latRef) {
|
||||||
|
result.latitude = convertDMSToDD(gpsIfd.lat, gpsIfd.latRef);
|
||||||
|
}
|
||||||
|
if (gpsIfd.lon && gpsIfd.lonRef) {
|
||||||
|
result.longitude = convertDMSToDD(gpsIfd.lon, gpsIfd.lonRef);
|
||||||
|
}
|
||||||
|
if (gpsIfd.alt != null) {
|
||||||
|
result.altitude = gpsIfd.altRef === 1 ? -gpsIfd.alt : gpsIfd.alt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clean up internal offsets */
|
||||||
|
delete result.exifOffset;
|
||||||
|
delete result.gpsOffset;
|
||||||
|
|
||||||
|
return Object.keys(result).length ? result : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIFD(view, tiffStart, ifdStart, littleEndian, tagMap) {
|
||||||
|
const result = {};
|
||||||
|
try {
|
||||||
|
const entries = view.getUint16(ifdStart, littleEndian);
|
||||||
|
for (let i = 0; i < entries; i++) {
|
||||||
|
const entryOffset = ifdStart + 2 + (i * 12);
|
||||||
|
const tag = view.getUint16(entryOffset, littleEndian);
|
||||||
|
const tagName = tagMap[tag];
|
||||||
|
if (!tagName) continue;
|
||||||
|
|
||||||
|
const type = view.getUint16(entryOffset + 2, littleEndian);
|
||||||
|
const count = view.getUint32(entryOffset + 4, littleEndian);
|
||||||
|
const valueOffset = entryOffset + 8;
|
||||||
|
|
||||||
|
result[tagName] = readTagValue(view, tiffStart, type, count, valueOffset, littleEndian);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* Gracefully handle malformed EXIF */
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTagValue(view, tiffStart, type, count, valueOffset, littleEndian) {
|
||||||
|
/* Type: 1=BYTE, 2=ASCII, 3=SHORT, 4=LONG, 5=RATIONAL, 7=UNDEFINED, 10=SRATIONAL */
|
||||||
|
const typeSize = { 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 };
|
||||||
|
const totalBytes = (typeSize[type] || 1) * count;
|
||||||
|
const dataOffset = totalBytes > 4
|
||||||
|
? tiffStart + view.getUint32(valueOffset, littleEndian)
|
||||||
|
: valueOffset;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 2: { /* ASCII string */
|
||||||
|
let str = '';
|
||||||
|
for (let i = 0; i < count - 1; i++) str += String.fromCharCode(view.getUint8(dataOffset + i));
|
||||||
|
return str.trim();
|
||||||
|
}
|
||||||
|
case 3: /* SHORT */
|
||||||
|
return count === 1 ? view.getUint16(dataOffset, littleEndian) : readArray(view, dataOffset, count, 2, littleEndian);
|
||||||
|
case 4: /* LONG */
|
||||||
|
return count === 1 ? view.getUint32(dataOffset, littleEndian) : readArray(view, dataOffset, count, 4, littleEndian);
|
||||||
|
case 5: /* RATIONAL (unsigned) */
|
||||||
|
if (count === 1) {
|
||||||
|
const num = view.getUint32(dataOffset, littleEndian);
|
||||||
|
const den = view.getUint32(dataOffset + 4, littleEndian);
|
||||||
|
return den ? num / den : 0;
|
||||||
|
}
|
||||||
|
return readRationalArray(view, dataOffset, count, littleEndian);
|
||||||
|
case 10: /* SRATIONAL (signed) */
|
||||||
|
if (count === 1) {
|
||||||
|
const num = view.getInt32(dataOffset, littleEndian);
|
||||||
|
const den = view.getInt32(dataOffset + 4, littleEndian);
|
||||||
|
return den ? num / den : 0;
|
||||||
|
}
|
||||||
|
return readRationalArray(view, dataOffset, count, littleEndian);
|
||||||
|
default:
|
||||||
|
return count === 1 ? view.getUint8(dataOffset) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readArray(view, offset, count, size, littleEndian) {
|
||||||
|
const arr = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
arr.push(size === 2 ? view.getUint16(offset + i * size, littleEndian) : view.getUint32(offset + i * size, littleEndian));
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRationalArray(view, offset, count, littleEndian) {
|
||||||
|
const arr = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const num = view.getUint32(offset + i * 8, littleEndian);
|
||||||
|
const den = view.getUint32(offset + i * 8 + 4, littleEndian);
|
||||||
|
arr.push(den ? num / den : 0);
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertDMSToDD(dms, ref) {
|
||||||
|
if (!Array.isArray(dms) || dms.length < 3) return null;
|
||||||
|
let dd = dms[0] + dms[1] / 60 + dms[2] / 3600;
|
||||||
|
if (ref === 'S' || ref === 'W') dd = -dd;
|
||||||
|
return Math.round(dd * 1000000) / 1000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
* EXIF preservation: extract raw APP1 segment and re-inject into JPEG
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the raw EXIF APP1 segment (including FF E1 marker + length) from a
|
||||||
|
* JPEG ArrayBuffer. Returns a Uint8Array of the full segment, or null if no
|
||||||
|
* EXIF is found. This raw segment can be re-injected into a new JPEG to
|
||||||
|
* preserve metadata after canvas operations.
|
||||||
|
*/
|
||||||
|
export function extractExifSegment(arrayBuffer) {
|
||||||
|
const view = new DataView(arrayBuffer);
|
||||||
|
if (view.byteLength < 4) return null;
|
||||||
|
|
||||||
|
/* Check JPEG SOI marker */
|
||||||
|
if (view.getUint16(0) !== 0xFFD8) return null;
|
||||||
|
|
||||||
|
let offset = 2;
|
||||||
|
while (offset < view.byteLength - 4) {
|
||||||
|
const marker = view.getUint16(offset);
|
||||||
|
if (marker === 0xFFE1) {
|
||||||
|
/* APP1 found — extract the entire segment (marker + length + data) */
|
||||||
|
const segmentLength = view.getUint16(offset + 2);
|
||||||
|
const totalLength = 2 + segmentLength; /* marker (2) + length field included in segmentLength */
|
||||||
|
if (offset + totalLength > view.byteLength) return null;
|
||||||
|
/* Return a standalone copy so it survives GC of the original buffer */
|
||||||
|
return new Uint8Array(arrayBuffer.slice(offset, offset + totalLength));
|
||||||
|
}
|
||||||
|
/* Not APP1 — skip to next marker */
|
||||||
|
if ((marker & 0xFF00) !== 0xFF00) break;
|
||||||
|
const segLen = view.getUint16(offset + 2);
|
||||||
|
offset += 2 + segLen;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert a raw APP1 EXIF segment into a JPEG Blob that lacks one.
|
||||||
|
* Places the APP1 segment immediately after the SOI marker (FF D8).
|
||||||
|
* Returns a new Blob with EXIF restored, or the original blob if it's not JPEG
|
||||||
|
* or if exifSegment is null.
|
||||||
|
*/
|
||||||
|
export async function insertExifIntoJpeg(jpegBlob, exifSegment) {
|
||||||
|
if (!exifSegment || !jpegBlob || jpegBlob.type !== 'image/jpeg') return jpegBlob;
|
||||||
|
|
||||||
|
const buffer = await jpegBlob.arrayBuffer();
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
if (view.getUint16(0) !== 0xFFD8) return jpegBlob;
|
||||||
|
|
||||||
|
/* Build new JPEG: SOI (2 bytes) + EXIF segment + rest of original after SOI */
|
||||||
|
const soi = new Uint8Array(buffer, 0, 2);
|
||||||
|
const rest = new Uint8Array(buffer, 2);
|
||||||
|
|
||||||
|
const merged = new Uint8Array(soi.length + exifSegment.length + rest.length);
|
||||||
|
merged.set(soi, 0);
|
||||||
|
merged.set(exifSegment, 2);
|
||||||
|
merged.set(rest, 2 + exifSegment.length);
|
||||||
|
|
||||||
|
return new Blob([merged], { type: 'image/jpeg' });
|
||||||
|
}
|
||||||
+26
-5
@@ -4,8 +4,14 @@
|
|||||||
* during large-image processing. On browsers that lack OffscreenCanvas support
|
* during large-image processing. On browsers that lack OffscreenCanvas support
|
||||||
* (or when running inside a Worker is not possible) the module falls back to
|
* (or when running inside a Worker is not possible) the module falls back to
|
||||||
* main-thread canvas operations.
|
* 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 worker = null;
|
||||||
let workerSupported = null;
|
let workerSupported = null;
|
||||||
|
|
||||||
@@ -35,6 +41,8 @@ function getWorker() {
|
|||||||
/*
|
/*
|
||||||
* Public entry point. Validates the file against the image rules, then delegates
|
* 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.
|
* 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) {
|
export async function optimizeImage(file, imageRules) {
|
||||||
if (imageRules?.allowedMimeTypes?.length && !imageRules.allowedMimeTypes.includes(file.type)) {
|
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}`);
|
throw new Error(`File exceeds limit: ${file.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const w = getWorker();
|
/* Extract raw EXIF segment from original JPEG before canvas strips it */
|
||||||
|
let exifSegment = null;
|
||||||
if (w) {
|
if (file.type === 'image/jpeg') {
|
||||||
return optimizeInWorker(w, file, imageRules);
|
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 ────────────────────────────────────────────────────────── */
|
/* ── Worker path ────────────────────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/*
|
||||||
|
* user-db.js — IndexedDB storage for the user portal.
|
||||||
|
*
|
||||||
|
* Stores task data (including heavy base64 image dataUrls) in IndexedDB
|
||||||
|
* instead of localStorage to avoid the ~5 MB browser quota.
|
||||||
|
*
|
||||||
|
* IndexedDB provides hundreds of MB of storage (browser-managed, quota-based)
|
||||||
|
* which makes it suitable for image-heavy task data.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* import { openUserDB, loadTaskData, saveTaskData, getStorageEstimate } from './user-db.js';
|
||||||
|
*
|
||||||
|
* await openUserDB(); // Call once on init
|
||||||
|
* const data = await loadTaskData(); // Returns the full taskData object
|
||||||
|
* await saveTaskData(data); // Persist updated taskData
|
||||||
|
* const est = await getStorageEstimate(); // Get usage info
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Constants ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const DB_NAME = 'user-portal-db';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const STORE_TASK_DATA = 'taskData';
|
||||||
|
|
||||||
|
/* ── Module-level DB reference ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
let db = null;
|
||||||
|
|
||||||
|
/* ── Open / create database ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the IndexedDB database. Must be called once before any read/write.
|
||||||
|
* Creates the object store on first run or version upgrade.
|
||||||
|
*/
|
||||||
|
export function openUserDB() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
db = request.result;
|
||||||
|
resolve(db);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const database = request.result;
|
||||||
|
/* Single store keyed by taskId — each entry holds one task's data */
|
||||||
|
if (!database.objectStoreNames.contains(STORE_TASK_DATA)) {
|
||||||
|
database.createObjectStore(STORE_TASK_DATA, { keyPath: 'taskId' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Read all task data ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads all task data from IndexedDB and returns it as a plain object
|
||||||
|
* keyed by taskId (same shape as the old localStorage structure).
|
||||||
|
*
|
||||||
|
* @returns {Promise<Object>} e.g. { "task-123": { visitDate: "", records: {...} }, ... }
|
||||||
|
*/
|
||||||
|
export function loadTaskData() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!db) { resolve({}); return; }
|
||||||
|
const tx = db.transaction(STORE_TASK_DATA, 'readonly');
|
||||||
|
const store = tx.objectStore(STORE_TASK_DATA);
|
||||||
|
const request = store.getAll();
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const result = {};
|
||||||
|
for (const entry of request.result) {
|
||||||
|
const { taskId, ...data } = entry;
|
||||||
|
result[taskId] = data;
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Save all task data ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the full taskData object into IndexedDB.
|
||||||
|
* Each taskId becomes a separate record in the store for efficient access.
|
||||||
|
*
|
||||||
|
* @param {Object} taskData - Object keyed by taskId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export function saveTaskData(taskData) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!db) { reject(new Error('Database not open')); return; }
|
||||||
|
const tx = db.transaction(STORE_TASK_DATA, 'readwrite');
|
||||||
|
const store = tx.objectStore(STORE_TASK_DATA);
|
||||||
|
|
||||||
|
for (const [taskId, data] of Object.entries(taskData)) {
|
||||||
|
store.put({ taskId, ...data });
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Save single task entry ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves or updates a single task's data. More efficient than saving everything
|
||||||
|
* when only one task changed.
|
||||||
|
*
|
||||||
|
* @param {string} taskId
|
||||||
|
* @param {Object} data - The task data (visitDate, records, etc.)
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export function saveOneTaskData(taskId, data) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!db) { reject(new Error('Database not open')); return; }
|
||||||
|
const tx = db.transaction(STORE_TASK_DATA, 'readwrite');
|
||||||
|
const store = tx.objectStore(STORE_TASK_DATA);
|
||||||
|
store.put({ taskId, ...data });
|
||||||
|
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Delete a task entry ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a single task's data from IndexedDB.
|
||||||
|
*
|
||||||
|
* @param {string} taskId
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export function deleteTaskData(taskId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!db) { reject(new Error('Database not open')); return; }
|
||||||
|
const tx = db.transaction(STORE_TASK_DATA, 'readwrite');
|
||||||
|
const store = tx.objectStore(STORE_TASK_DATA);
|
||||||
|
store.delete(taskId);
|
||||||
|
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Storage estimate ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an estimate of IndexedDB usage (if the StorageManager API is available).
|
||||||
|
* Falls back to counting serialized task data size.
|
||||||
|
*
|
||||||
|
* @param {Object} taskData - Current in-memory taskData for fallback sizing
|
||||||
|
* @returns {Promise<{usedMB: string, quotaMB: string, pct: number}>}
|
||||||
|
*/
|
||||||
|
export async function getStorageEstimate(taskData) {
|
||||||
|
/* Try the modern Storage API (available in secure contexts) */
|
||||||
|
if (navigator.storage && navigator.storage.estimate) {
|
||||||
|
const est = await navigator.storage.estimate();
|
||||||
|
const usedMB = ((est.usage || 0) / (1024 * 1024)).toFixed(2);
|
||||||
|
const quotaMB = ((est.quota || 0) / (1024 * 1024)).toFixed(0);
|
||||||
|
const pct = est.quota ? Math.min(100, ((est.usage / est.quota) * 100)) : 0;
|
||||||
|
return { usedMB, quotaMB, pct: Math.round(pct) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback: estimate from serialized data */
|
||||||
|
const json = JSON.stringify(taskData || {});
|
||||||
|
const bytes = json.length * 2; /* UTF-16 */
|
||||||
|
const usedMB = (bytes / (1024 * 1024)).toFixed(2);
|
||||||
|
return { usedMB, quotaMB: '∞', pct: 0 };
|
||||||
|
}
|
||||||
+1588
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,131 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#f3efe6" />
|
||||||
|
<title>Check List PoC — Admin Login</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: "Inter", "Segoe UI", sans-serif;
|
||||||
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.login-header p {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.alert {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.alert.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="login-header">
|
||||||
|
<i class="bi bi-shield-lock fs-1 text-primary mb-2 d-block"></i>
|
||||||
|
<h1>Admin Login</h1>
|
||||||
|
<p>Sign in to access the administration console</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="errorAlert" class="alert alert-danger" role="alert"></div>
|
||||||
|
|
||||||
|
<form id="loginForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Username</label>
|
||||||
|
<input type="text" class="form-control" id="username" name="username" required autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100" id="loginBtn">
|
||||||
|
<span id="loginText">Sign In</span>
|
||||||
|
<span id="loginSpinner" class="spinner-border spinner-border-sm d-none" role="status"></span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<a href="/" class="text-decoration-none"><i class="bi bi-arrow-left me-1"></i>Back to portal</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const username = document.getElementById('username').value.trim();
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const errorAlert = document.getElementById('errorAlert');
|
||||||
|
const loginBtn = document.getElementById('loginBtn');
|
||||||
|
const loginText = document.getElementById('loginText');
|
||||||
|
const loginSpinner = document.getElementById('loginSpinner');
|
||||||
|
|
||||||
|
/* Hide previous error */
|
||||||
|
errorAlert.classList.remove('show');
|
||||||
|
|
||||||
|
/* Show loading state */
|
||||||
|
loginBtn.disabled = true;
|
||||||
|
loginText.textContent = 'Signing in...';
|
||||||
|
loginSpinner.classList.remove('d-none');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/auth/admin/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
/* Store token in sessionStorage for API calls */
|
||||||
|
sessionStorage.setItem('auth_token', data.token);
|
||||||
|
sessionStorage.setItem('auth_type', 'admin');
|
||||||
|
/* Redirect to admin page */
|
||||||
|
window.location.href = '/admin';
|
||||||
|
} else {
|
||||||
|
errorAlert.textContent = data.message || 'Login failed. Please check your credentials.';
|
||||||
|
errorAlert.classList.add('show');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorAlert.textContent = 'Network error. Please try again.';
|
||||||
|
errorAlert.classList.add('show');
|
||||||
|
} finally {
|
||||||
|
loginBtn.disabled = false;
|
||||||
|
loginText.textContent = 'Sign In';
|
||||||
|
loginSpinner.classList.add('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#f3efe6" />
|
||||||
|
<title>Check List PoC — User Login</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: "Inter", "Segoe UI", sans-serif;
|
||||||
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.login-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.login-header p {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.alert {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.alert.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="login-header">
|
||||||
|
<i class="bi bi-person-circle fs-1 text-primary mb-2 d-block"></i>
|
||||||
|
<h1>User Login</h1>
|
||||||
|
<p>Sign in to access your task workspace</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="errorAlert" class="alert alert-danger" role="alert"></div>
|
||||||
|
|
||||||
|
<form id="loginForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="email" class="form-label">Email</label>
|
||||||
|
<input type="email" class="form-control" id="email" name="email" required autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Password</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100" id="loginBtn">
|
||||||
|
<span id="loginText">Sign In</span>
|
||||||
|
<span id="loginSpinner" class="spinner-border spinner-border-sm d-none" role="status"></span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<a href="/" class="text-decoration-none"><i class="bi bi-arrow-left me-1"></i>Back to portal</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const email = document.getElementById('email').value.trim();
|
||||||
|
const password = document.getElementById('password').value;
|
||||||
|
const errorAlert = document.getElementById('errorAlert');
|
||||||
|
const loginBtn = document.getElementById('loginBtn');
|
||||||
|
const loginText = document.getElementById('loginText');
|
||||||
|
const loginSpinner = document.getElementById('loginSpinner');
|
||||||
|
|
||||||
|
/* Hide previous error */
|
||||||
|
errorAlert.classList.remove('show');
|
||||||
|
|
||||||
|
/* Show loading state */
|
||||||
|
loginBtn.disabled = true;
|
||||||
|
loginText.textContent = 'Signing in...';
|
||||||
|
loginSpinner.classList.remove('d-none');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/auth/user/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
/* Store token and user info in sessionStorage */
|
||||||
|
sessionStorage.setItem('auth_token', data.token);
|
||||||
|
sessionStorage.setItem('auth_type', 'user');
|
||||||
|
sessionStorage.setItem('auth_user', JSON.stringify(data.user));
|
||||||
|
/* Redirect to user page with userId */
|
||||||
|
window.location.href = '/user?userId=' + data.user.id;
|
||||||
|
} else {
|
||||||
|
errorAlert.textContent = data.message || 'Login failed. Please check your credentials.';
|
||||||
|
errorAlert.classList.add('show');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorAlert.textContent = 'Network error. Please try again.';
|
||||||
|
errorAlert.classList.add('show');
|
||||||
|
} finally {
|
||||||
|
loginBtn.disabled = false;
|
||||||
|
loginText.textContent = 'Sign In';
|
||||||
|
loginSpinner.classList.add('d-none');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+126
-34
@@ -5,44 +5,136 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="theme-color" content="#f3efe6" />
|
<meta name="theme-color" content="#f3efe6" />
|
||||||
<title>Check List Portal</title>
|
<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" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body class="portal-body">
|
<body class="portal-body d-flex align-items-center justify-content-center min-vh-100">
|
||||||
<!--
|
<main class="container" style="max-width: 720px;">
|
||||||
The portal intentionally acts as a simple role chooser rather than a real
|
<div class="text-center mb-4">
|
||||||
authentication page. It separates user and admin entry points in the PoC
|
<p class="text-uppercase text-muted small fw-semibold mb-1">Check List Access</p>
|
||||||
without committing the project to a security model too early.
|
<h1 class="fw-bold">Choose workspace</h1>
|
||||||
-->
|
<p class="text-muted">Select a user, then open the operator or administrator workspace.</p>
|
||||||
<main class="portal-shell">
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<section class="portal-grid">
|
<!-- User selection -->
|
||||||
<!-- Direct operator entry for report creation and local draft work. -->
|
<div class="card mb-4">
|
||||||
<a class="portal-card panel" href="/user">
|
<div class="card-body">
|
||||||
<p class="summary-label">User area</p>
|
<label for="portalUserSelect" class="form-label fw-semibold">Select User</label>
|
||||||
<h2>Operator workspace</h2>
|
<select id="portalUserSelect" class="form-select">
|
||||||
<p class="portal-copy">
|
<option value="">— Choose a user to see their tasks —</option>
|
||||||
Create reports, work offline, attach images, and manage local drafts.
|
</select>
|
||||||
</p>
|
</div>
|
||||||
<span class="button button-primary portal-button">Open user area</span>
|
</div>
|
||||||
</a>
|
|
||||||
|
|
||||||
<!-- Direct administrator entry for centrally managed configuration. -->
|
<div class="row g-3">
|
||||||
<a class="portal-card panel" href="/admin">
|
<!-- Operator workspace -->
|
||||||
<p class="summary-label">Admin area</p>
|
<div class="col-md-6">
|
||||||
<h2>Administrator workspace</h2>
|
<a id="portalUserLink" class="card text-decoration-none h-100 portal-card" href="#">
|
||||||
<p class="portal-copy">
|
<div class="card-body text-center">
|
||||||
Maintain image requirements and other centrally managed configuration.
|
<i class="bi bi-clipboard-check fs-1 text-primary mb-2 d-block"></i>
|
||||||
</p>
|
<p class="text-muted small mb-1">User area</p>
|
||||||
<span class="button button-secondary portal-button">Open admin area</span>
|
<h5 class="fw-bold">Operator workspace</h5>
|
||||||
</a>
|
<p class="text-muted small">Process assigned tasks, attach images, save reports.</p>
|
||||||
</section>
|
<span class="btn btn-primary btn-sm mt-2">Open user area</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Administrator workspace -->
|
||||||
|
<div class="col-md-6">
|
||||||
|
<a class="card text-decoration-none h-100 portal-card" href="/admin">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<i class="bi bi-gear fs-1 text-secondary mb-2 d-block"></i>
|
||||||
|
<p class="text-muted small mb-1">Admin area</p>
|
||||||
|
<h5 class="fw-bold">Administrator workspace</h5>
|
||||||
|
<p class="text-muted small">Manage settings, users, sites, templates, and tasks.</p>
|
||||||
|
<span class="btn btn-outline-secondary btn-sm mt-2">Open admin area</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data cleanup -->
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-body d-flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h6 class="fw-semibold mb-1"><i class="bi bi-trash3 me-1"></i>Data Cleanup</h6>
|
||||||
|
<p class="text-muted small mb-0">Remove all localStorage data and reset the application.</p>
|
||||||
|
</div>
|
||||||
|
<button id="cleanupBtn" class="btn btn-outline-danger btn-sm">Clear all data</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</main>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+247
-548
@@ -1,638 +1,337 @@
|
|||||||
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
* Check List PoC — Custom styles (Bootstrap 5 handles layout + components)
|
||||||
|
* Only app-specific overrides and classes that JS depends on live here.
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
/*
|
--font-ui: "Inter", "Segoe UI", sans-serif;
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
html,
|
|
||||||
body {
|
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);
|
font-family: var(--font-ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
/* ── Sidebar (shared across pages) ──────────────────────────────────────── */
|
||||||
min-height: 100vh;
|
|
||||||
|
.sidebar-bs {
|
||||||
|
font-family: var(--font-ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
.portal-body {
|
/* ── Admin panel visibility (JS toggles these) ──────────────────────────── */
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
.admin-panel {
|
||||||
padding: 24px;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
button,
|
.admin-panel-active {
|
||||||
input,
|
display: block;
|
||||||
select,
|
|
||||||
textarea {
|
|
||||||
font: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-shell {
|
/* ── Admin navigation (sidebar category tree) ───────────────────────────── */
|
||||||
/*
|
|
||||||
* The main layout keeps navigation and report context visible at the same time.
|
.admin-nav-sub {
|
||||||
* This is important for a checklist workflow where users often switch reports
|
display: none;
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.admin-nav-cat.is-open > .admin-nav-sub {
|
||||||
border: 1px solid var(--panel-border);
|
display: block;
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
background: var(--panel);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.admin-nav-item.is-active {
|
||||||
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;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
color: var(--bs-primary) !important;
|
||||||
|
|
||||||
.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;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-link.is-active {
|
/* ── Workspace views (JS toggling for user page) ────────────────────────── */
|
||||||
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-view {
|
.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;
|
display: none;
|
||||||
gap: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.workspace-view-active {
|
.workspace-view-active {
|
||||||
display: grid;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero {
|
/* ── Toast notification ─────────────────────────────────────────────────── */
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 20px;
|
|
||||||
padding: 24px 28px;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero::after {
|
.admin-toast {
|
||||||
content: "";
|
position: fixed;
|
||||||
position: absolute;
|
top: 20px;
|
||||||
inset: auto -80px -80px auto;
|
right: 20px;
|
||||||
width: 220px;
|
z-index: 9000;
|
||||||
height: 220px;
|
padding: 12px 20px;
|
||||||
border-radius: 50%;
|
border-radius: 999px;
|
||||||
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;
|
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
text-transform: uppercase;
|
font-size: 0.88rem;
|
||||||
letter-spacing: 0.08em;
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 200ms ease, transform 200ms ease;
|
||||||
|
background: #198754;
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-grid {
|
.admin-toast-visible {
|
||||||
grid-template-columns: minmax(0, 1.7fr) minmax(300px, 380px);
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-panel,
|
/* ── Report list (index.html sidebar) ───────────────────────────────────── */
|
||||||
.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 {
|
.report-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
max-height: 420px;
|
max-height: 420px;
|
||||||
overflow: auto;
|
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 {
|
.report-list-item {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
border: 1px solid rgba(93, 67, 35, 0.12);
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
background: rgba(255, 255, 255, 0.74);
|
background: #fff;
|
||||||
border-radius: 16px;
|
border-radius: 8px;
|
||||||
padding: 14px;
|
padding: 10px 12px;
|
||||||
display: grid;
|
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;
|
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 {
|
.report-list-item__title {
|
||||||
font-size: 0.96rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted-count {
|
.report-list-item__meta {
|
||||||
color: var(--muted);
|
color: #6c757d;
|
||||||
font-size: 0.88rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
/* ── Task record cards (user page) ──────────────────────────────────────── */
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
.task-record-card {
|
||||||
min-height: 240px;
|
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||||
text-align: center;
|
border-radius: 8px;
|
||||||
padding: 24px;
|
padding: 16px;
|
||||||
border-radius: var(--radius-md);
|
margin-bottom: 12px;
|
||||||
background: rgba(243, 239, 230, 0.68);
|
|
||||||
border: 1px dashed rgba(93, 67, 35, 0.24);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-list {
|
.task-record-header {
|
||||||
display: grid;
|
display: flex;
|
||||||
gap: 14px;
|
flex-wrap: wrap;
|
||||||
margin: 0;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-list div {
|
.task-record-sort {
|
||||||
display: grid;
|
font-weight: 700;
|
||||||
gap: 4px;
|
font-size: 0.9rem;
|
||||||
padding-bottom: 12px;
|
color: var(--bs-primary);
|
||||||
border-bottom: 1px solid rgba(93, 67, 35, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-list dd {
|
.task-record-desc {
|
||||||
margin: 0;
|
flex: 1;
|
||||||
|
font-size: 0.92rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.validation-block,
|
.task-record-meta {
|
||||||
.attachment-policy {
|
font-size: 0.8rem;
|
||||||
margin-top: 26px;
|
color: #6c757d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.validation-list {
|
.task-record-fields {
|
||||||
padding-left: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.validation-list li + li {
|
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-list {
|
.task-record-images {
|
||||||
display: grid;
|
display: flex;
|
||||||
gap: 12px;
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-card {
|
.task-img-thumb {
|
||||||
/*
|
position: relative;
|
||||||
* Attachments need enough visual weight to confirm that a photo is really tied
|
display: inline-block;
|
||||||
* 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-preview {
|
.task-img-thumb img {
|
||||||
width: 88px;
|
width: 64px;
|
||||||
height: 88px;
|
height: 64px;
|
||||||
border-radius: 12px;
|
|
||||||
object-fit: cover;
|
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;
|
display: grid;
|
||||||
gap: 4px;
|
place-items: center;
|
||||||
min-width: 0;
|
min-height: 100vh;
|
||||||
|
padding: 24px;
|
||||||
|
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-card__copy strong,
|
/* ── Connection badges (JS sets these classes) ──────────────────────────── */
|
||||||
.attachment-card__copy span {
|
|
||||||
overflow: hidden;
|
.badge-online {
|
||||||
text-overflow: ellipsis;
|
background-color: #198754 !important;
|
||||||
white-space: nowrap;
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
.badge-offline {
|
||||||
/*
|
background-color: #ffc107 !important;
|
||||||
* Tablet and narrow laptop layouts collapse the two-column structure into a
|
color: #000 !important;
|
||||||
* single column so the editing surface remains usable without horizontal scroll.
|
}
|
||||||
*/
|
|
||||||
.app-shell,
|
|
||||||
.editor-grid,
|
|
||||||
.summary-grid,
|
|
||||||
.portal-grid {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
.badge-error {
|
||||||
min-height: auto;
|
background-color: #dc3545 !important;
|
||||||
}
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
.report-list {
|
/* ── Sub-category group styling ─────────────────────────────────────────── */
|
||||||
max-height: 260px;
|
|
||||||
|
.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) {
|
@media (max-width: 768px) {
|
||||||
/*
|
.sidebar-bs {
|
||||||
* Mobile layout prioritizes single-column readability and larger preview areas.
|
display: flex !important;
|
||||||
* This matters because one of the project requirements is viable use on phones
|
position: fixed;
|
||||||
* where camera capture and image attachment happen directly in the browser.
|
top: 0;
|
||||||
*/
|
left: 0;
|
||||||
.app-shell {
|
height: 100vh;
|
||||||
padding: 14px;
|
width: 260px !important;
|
||||||
gap: 14px;
|
min-width: 260px !important;
|
||||||
|
z-index: 1050;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 250ms ease;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
.sidebar-bs.sidebar-open {
|
||||||
.hero,
|
transform: translateX(0);
|
||||||
.hero-actions,
|
box-shadow: 4px 0 16px rgba(0,0,0,0.15);
|
||||||
.field-grid,
|
|
||||||
.attachment-card {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
display: grid;
|
|
||||||
}
|
}
|
||||||
|
.sidebar-backdrop {
|
||||||
.field-grid {
|
display: none;
|
||||||
gap: 14px;
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1040;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
}
|
}
|
||||||
|
.sidebar-backdrop.show {
|
||||||
.attachment-card {
|
display: block;
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
}
|
||||||
|
.mobile-menu-btn {
|
||||||
.attachment-preview {
|
display: flex !important;
|
||||||
width: 100%;
|
|
||||||
height: 180px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* User entry point — bootstraps the task-processing workspace.
|
||||||
|
*
|
||||||
|
* Opens the shared IndexedDB for admin entity cache, then initializes the
|
||||||
|
* user module which manages task processing: open task, fill in records,
|
||||||
|
* attach images, save as draft or final.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { state } from './js/state.js';
|
||||||
|
import { openDatabase } from './js/db.js';
|
||||||
|
import { initUser } from './js/user.js';
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
/* Open the shared IndexedDB so admin entity cache is accessible. */
|
||||||
|
state.db = await openDatabase();
|
||||||
|
await initUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* Re-init when navigating back (fixes stale localStorage after bfcache) */
|
||||||
|
window.addEventListener('pageshow', async (e) => {
|
||||||
|
if (e.persisted) {
|
||||||
|
state.db = await openDatabase();
|
||||||
|
await initUser();
|
||||||
|
}
|
||||||
|
});
|
||||||
+154
-155
@@ -6,189 +6,188 @@
|
|||||||
<meta name="theme-color" content="#f3efe6" />
|
<meta name="theme-color" content="#f3efe6" />
|
||||||
<title>Check List PoC — User</title>
|
<title>Check List PoC — User</title>
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<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" />
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!--
|
<div class="d-flex vh-100">
|
||||||
Operator workspace: report creation, local draft editing, validation,
|
<!-- Mobile sidebar backdrop -->
|
||||||
image attachments, submission, and CSV export.
|
<div id="sidebarBackdrop" class="sidebar-backdrop"></div>
|
||||||
-->
|
<!-- Mobile menu button -->
|
||||||
<div class="app-shell">
|
<button id="mobileMenuBtn" class="mobile-menu-btn btn btn-primary" type="button" aria-label="Open menu"><i class="bi bi-list"></i></button>
|
||||||
<aside class="sidebar panel">
|
<!-- Sidebar -->
|
||||||
<div class="brand-block">
|
<aside class="sidebar-bs d-flex flex-column border-end bg-light" style="width:260px;min-width:260px;">
|
||||||
<p class="eyebrow">Hybrid Inspection Reporting</p>
|
<div class="p-3 border-bottom">
|
||||||
<h1>Check List</h1>
|
<p class="text-uppercase text-muted small fw-semibold mb-0">Hybrid Inspection Reporting</p>
|
||||||
<p class="lede">
|
<h5 class="fw-bold mb-0">Check List</h5>
|
||||||
Offline-first proof of concept for template-driven quality reports.
|
<small class="text-muted">Task processing workspace</small>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section">
|
<div class="p-3 border-bottom">
|
||||||
<div class="status-row">
|
<span id="connectionBadge" class="badge bg-secondary">Checking…</span>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section">
|
<div class="flex-grow-1 overflow-auto p-3">
|
||||||
<label class="field-label" for="templateSelect">Template</label>
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<select id="templateSelect" class="select-input"></select>
|
<h6 class="fw-semibold mb-0">My Tasks</h6>
|
||||||
<button id="createReportButton" class="button button-primary" type="button">
|
<span id="taskCount" class="badge bg-secondary">0</span>
|
||||||
Create new report
|
</div>
|
||||||
</button>
|
<div id="taskListSidebar"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-section">
|
<div class="p-3 border-top">
|
||||||
<div class="section-heading-row sidebar-links-heading">
|
<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>
|
||||||
<h2>Access</h2>
|
<a class="btn btn-secondary btn-sm w-100 mb-1" href="/user">User area</a>
|
||||||
<span class="muted-count">Direct links</span>
|
<a class="btn btn-outline-secondary btn-sm w-100 mb-1" href="/admin">Admin area</a>
|
||||||
</div>
|
<a class="btn btn-outline-secondary btn-sm w-100" href="/">Back to portal</a>
|
||||||
<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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="workspace">
|
<!-- Main content -->
|
||||||
<section id="reportsWorkspace" class="workspace-view workspace-view-active">
|
<main class="flex-grow-1 overflow-auto p-4 bg-white">
|
||||||
<section class="hero panel">
|
|
||||||
<div>
|
<!-- SETTINGS VIEW -->
|
||||||
<p class="eyebrow">Proof of concept frontend</p>
|
<section id="settingsView" class="workspace-view">
|
||||||
<h2 id="heroTitle">No report selected</h2>
|
<div class="mb-4">
|
||||||
<p id="heroSubtitle" class="hero-copy">
|
<p class="text-muted small mb-0">User workspace</p>
|
||||||
Start by syncing templates and creating a local draft.
|
<h3 class="fw-bold">Settings</h3>
|
||||||
</p>
|
<p class="text-muted">Configure your workspace preferences.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="hero-actions">
|
<div class="card" style="max-width:480px">
|
||||||
<label class="status-picker">
|
<div class="card-body">
|
||||||
<span>Status</span>
|
<h6 class="fw-semibold mb-3">Language</h6>
|
||||||
<select id="reportStatusSelect" class="select-input">
|
<p class="text-muted small">Choose the language for record descriptions.</p>
|
||||||
<option value="draft">Draft</option>
|
<select id="userLanguageSelect" class="form-select">
|
||||||
<option value="in_progress">In Progress</option>
|
<option value="EN">English</option>
|
||||||
<option value="ready_for_export">Ready for Export</option>
|
<option value="FR">Français</option>
|
||||||
<option value="exported">Exported</option>
|
<option value="NL">Nederlands</option>
|
||||||
<option value="archived">Archived</option>
|
|
||||||
</select>
|
</select>
|
||||||
</label>
|
<button id="closeSettingsBtn" class="btn btn-primary btn-sm mt-3" type="button">Done</button>
|
||||||
<button id="submitReportButton" class="button button-secondary" type="button">
|
</div>
|
||||||
Submit
|
|
||||||
</button>
|
|
||||||
<button id="exportReportButton" class="button button-secondary" type="button">
|
|
||||||
Export CSV
|
|
||||||
</button>
|
|
||||||
<button id="deleteReportButton" class="button button-ghost" type="button">
|
|
||||||
Delete report
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="summary-grid">
|
<!-- TASK LIST VIEW (shown by default) -->
|
||||||
<article class="summary-card panel accent-card">
|
<section id="taskListView" class="workspace-view workspace-view-active">
|
||||||
<p class="summary-label">Template</p>
|
<div class="mb-4">
|
||||||
<strong id="summaryTemplate">Not loaded</strong>
|
<p class="text-muted small mb-0">User workspace</p>
|
||||||
<span id="summaryVersion" class="summary-note">Version -</span>
|
<h3 class="fw-bold">Assigned Tasks</h3>
|
||||||
</article>
|
<p class="text-muted">Select a task to begin processing.</p>
|
||||||
<article class="summary-card panel">
|
</div>
|
||||||
<p class="summary-label">Validation</p>
|
|
||||||
<strong id="validationHeadline">No report selected</strong>
|
<div class="card">
|
||||||
<span id="validationDetail" class="summary-note">Draft validation will appear here.</span>
|
<div class="card-body p-0">
|
||||||
</article>
|
<div id="taskListContainer"></div>
|
||||||
<article class="summary-card panel">
|
</div>
|
||||||
<p class="summary-label">Offline cache</p>
|
</div>
|
||||||
<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>
|
||||||
|
|
||||||
<section class="editor-grid">
|
<!-- TASK DETAIL VIEW (shown when a task is opened) -->
|
||||||
<section class="panel editor-panel">
|
<section id="taskDetailView" class="workspace-view">
|
||||||
<div class="section-heading-row">
|
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||||
<h2>Report editor</h2>
|
<div>
|
||||||
<span id="editorHint" class="panel-note">Dynamic form rendering from template JSON</span>
|
<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>
|
</div>
|
||||||
<form id="reportForm" class="report-form">
|
<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 class="empty-state">
|
</div>
|
||||||
<h3>No report open</h3>
|
|
||||||
<p>Choose a template and create a report to start editing locally.</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<aside class="panel inspector-panel">
|
<!-- Task info summary cards (collapsible on small screens) -->
|
||||||
<div class="section-heading-row">
|
<div class="d-flex justify-content-between align-items-center mb-2 d-md-none">
|
||||||
<h2>Inspector view</h2>
|
<small class="text-muted fw-semibold">Task Info</small>
|
||||||
<span class="panel-note">Local draft summary</span>
|
<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>
|
||||||
<dl id="reportMeta" class="meta-list">
|
</div>
|
||||||
<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">
|
<!-- Visit date + records form -->
|
||||||
<h3>Validation issues</h3>
|
<div class="card mb-4">
|
||||||
<ul id="validationList" class="validation-list">
|
<div class="card-body">
|
||||||
<li>No report selected.</li>
|
<form id="taskProcessingForm">
|
||||||
</ul>
|
<div class="row g-3 mb-4">
|
||||||
</div>
|
<div class="col-md-4">
|
||||||
|
<label for="visitDate" class="form-label">Visit Date</label>
|
||||||
|
<input id="visitDate" class="form-control" type="date" />
|
||||||
|
<div class="form-text">Pick the inspection visit date.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="attachment-policy">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h3>Image policy</h3>
|
<h5 class="fw-semibold mb-0">Records</h5>
|
||||||
<p id="imagePolicyText" class="policy-copy">
|
<span id="taskRecordCount" class="badge bg-secondary">0 record(s)</span>
|
||||||
Load server configuration to see image limits and optimization rules.
|
</div>
|
||||||
</p>
|
|
||||||
|
<!-- Search bar for records -->
|
||||||
|
<div class="mb-3">
|
||||||
|
<input id="recordSearchInput" class="form-control form-control-sm" type="search" placeholder="Search records (full text)…" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category tabs -->
|
||||||
|
<ul id="recordCategoryTabs" class="nav nav-tabs mb-3"></ul>
|
||||||
|
|
||||||
|
<!-- Records container (filtered by active tab + search) -->
|
||||||
|
<div id="taskRecordsContainer"></div>
|
||||||
|
|
||||||
|
<div class="d-flex gap-2 mt-4 pt-3 border-top">
|
||||||
|
<button id="saveDraftBtn" class="btn btn-outline-secondary" type="button">Save as Draft</button>
|
||||||
|
<button id="saveFinalBtn" class="btn btn-primary" type="button">Save as Final</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
</section>
|
|
||||||
|
<!-- Validation panel -->
|
||||||
|
<div id="taskValidationPanel" class="card border-warning" style="display:none">
|
||||||
|
<div class="card-header bg-warning-subtle fw-semibold">Validation Issues</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul id="taskValidationList" class="mb-0"></ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template id="reportListItemTemplate">
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
<button class="report-list-item" type="button" data-report-id="">
|
<script type="module" src="/user-app.js"></script>
|
||||||
<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>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+208
-1
@@ -129,7 +129,7 @@ CREATE TABLE IF NOT EXISTS reports (
|
|||||||
report_number VARCHAR(100) NOT NULL,
|
report_number VARCHAR(100) NOT NULL,
|
||||||
template_code VARCHAR(100) NOT NULL,
|
template_code VARCHAR(100) NOT NULL,
|
||||||
template_version INT 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,
|
answers_json JSON NOT NULL,
|
||||||
submitted_at DATETIME NULL,
|
submitted_at DATETIME NULL,
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -155,3 +155,210 @@ CREATE TABLE IF NOT EXISTS audit_log (
|
|||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
KEY idx_audit_entity (entity_type, entity_code)
|
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
@@ -1,9 +1,13 @@
|
|||||||
|
import cookieParser from 'cookie-parser';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { errorHandler, notFoundHandler } from './middleware/errorHandler.js';
|
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 configRoutes from './routes/configRoutes.js';
|
||||||
import healthRoutes from './routes/healthRoutes.js';
|
import healthRoutes from './routes/healthRoutes.js';
|
||||||
import lookupRoutes from './routes/lookupRoutes.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 userPagePath = path.join(publicDir, 'user.html');
|
||||||
const adminPagePath = path.join(publicDir, 'admin.html');
|
const adminPagePath = path.join(publicDir, 'admin.html');
|
||||||
const portalPath = path.join(publicDir, 'portal.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(cors());
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(cookieParser());
|
||||||
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
|
||||||
app.get('/api/v1', (_req, res) => {
|
app.get('/api/v1', (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
@@ -48,11 +55,23 @@ app.use('/api/v1/templates', templateRoutes);
|
|||||||
app.use('/api/v1/lookups', lookupRoutes);
|
app.use('/api/v1/lookups', lookupRoutes);
|
||||||
app.use('/api/v1/config', configRoutes);
|
app.use('/api/v1/config', configRoutes);
|
||||||
app.use('/api/v1/reports', reportRoutes);
|
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
|
* The root route intentionally serves a neutral portal page. This gives the
|
||||||
* project distinct user and administrator entry points without introducing a
|
* project distinct user and administrator entry points.
|
||||||
* full authentication flow yet.
|
|
||||||
*/
|
*/
|
||||||
app.get('/', (_req, res) => {
|
app.get('/', (_req, res) => {
|
||||||
res.sendFile(portalPath);
|
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
|
* 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
|
* the markup it needs. Authentication is required for both areas.
|
||||||
* elements are present and binds behavior accordingly.
|
|
||||||
*/
|
*/
|
||||||
app.get(['/user', '/user/'], (_req, res) => {
|
app.get(['/user', '/user/'], requireUserAuth, (_req, res) => {
|
||||||
res.sendFile(userPagePath);
|
res.sendFile(userPagePath);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get(['/admin', '/admin/'], (_req, res) => {
|
app.get(['/admin', '/admin/'], requireAdminAuth, (_req, res) => {
|
||||||
res.sendFile(adminPagePath);
|
res.sendFile(adminPagePath);
|
||||||
});
|
});
|
||||||
app.use(express.static(publicDir));
|
app.use(express.static(publicDir));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -2,9 +2,11 @@ import { Router } from 'express';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getAppConfig,
|
getAppConfig,
|
||||||
|
getAppConfigValue,
|
||||||
getExportProfile,
|
getExportProfile,
|
||||||
getImageRules,
|
getImageRules,
|
||||||
updateImageRules
|
updateImageRules,
|
||||||
|
upsertAppConfig
|
||||||
} from '../services/configService.js';
|
} from '../services/configService.js';
|
||||||
import { logAuditEvent } from '../services/auditService.js';
|
import { logAuditEvent } from '../services/auditService.js';
|
||||||
import { asyncHandler } from '../utils/asyncHandler.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;
|
export default router;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router } from 'express';
|
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 { logAuditEvent } from '../services/auditService.js';
|
||||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||||
import { validateParam } from '../middleware/validateParams.js';
|
import { validateParam } from '../middleware/validateParams.js';
|
||||||
@@ -29,7 +29,7 @@ router.get(
|
|||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/:reportId',
|
'/:reportId',
|
||||||
validateParam('reportId'),
|
validateParam('reportId', { pattern: /^[a-zA-Z0-9_-]{1,100}$/ }),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
const report = await getReport(req.params.reportId);
|
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;
|
export default router;
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -121,3 +121,31 @@ export async function getAppConfig() {
|
|||||||
value: parseJsonColumn(row.configValue)
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { query } from '../db/pool.js';
|
|||||||
import { parseJsonColumn } from '../utils/json.js';
|
import { parseJsonColumn } from '../utils/json.js';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* The report service handles server-side storage of submitted reports. In
|
* The report service handles server-side storage of submitted reports.
|
||||||
* phase 1, reports are created locally in the browser and only uploaded when
|
* Images are stored as BLOBs in the report_images table alongside metadata.
|
||||||
* the operator explicitly submits. This keeps the offline-first workflow intact
|
|
||||||
* while giving the backend a durable copy for review, export, or archival.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export async function submitReport(report) {
|
export async function submitReport(report) {
|
||||||
|
/* Strip image dataUrls from answers before storing in JSON column */
|
||||||
|
const answersForJson = stripImagesFromAnswers(report.answers);
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`
|
`
|
||||||
INSERT INTO reports (report_uuid, report_number, template_code, template_version, status, answers_json, submitted_at)
|
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.templateCode,
|
||||||
report.templateVersion,
|
report.templateVersion,
|
||||||
report.status,
|
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);
|
return getReport(report.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,3 +112,122 @@ function mapReportRow(row) {
|
|||||||
updatedAt: row.updatedAt
|
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]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user