modified version
This commit is contained in:
+269
-641
@@ -1,676 +1,304 @@
|
|||||||
# Project Files Guide
|
# Project Files Guide
|
||||||
|
|
||||||
This document explains the role of the main files in this project in a way that should be easy to follow for a junior developer.
|
A junior-friendly walk-through of every folder and file that matters in this
|
||||||
|
repository. Read it alongside [README.md](README.md): the README tells you how
|
||||||
|
to run the project, this guide tells you where things live once it is running.
|
||||||
|
|
||||||
The project is split into a few clear parts:
|
## How the application is organised
|
||||||
- server code in [src/app.js](src/app.js) and the rest of [src](src)
|
|
||||||
- browser frontend files in [public](public)
|
|
||||||
- database structure and example data in [sql](sql)
|
|
||||||
- environment and local development helpers in the root folder and [scripts](scripts)
|
|
||||||
|
|
||||||
## How The Application Works At A High Level
|
There are three parts:
|
||||||
|
|
||||||
The project has two big sides:
|
1. **Backend** — Node.js + Express in [src/](src/). Talks to MariaDB, serves
|
||||||
- the backend, which provides templates, lookups, configuration, report storage, and audit logging through REST API endpoints
|
the API under `/api/v1/`, and serves the static frontend files.
|
||||||
- the frontend, which runs in the browser, stores drafts locally, and uses the backend for centrally managed configuration and report submission
|
2. **Frontend** — two single-page shells in [public/](public/): `user.html` for
|
||||||
|
operators and `admin.html` for administrators. Both are ES-module based
|
||||||
|
(no bundler) and use Bootstrap 5.
|
||||||
|
3. **Database** — MariaDB. Schema and seed data are in [sql/](sql/). Docker
|
||||||
|
loads them automatically on a fresh volume.
|
||||||
|
|
||||||
In simple terms:
|
### End-to-end request flow (simplified)
|
||||||
1. Express starts the server.
|
|
||||||
2. The server exposes API endpoints under `/api/v1/...`.
|
|
||||||
3. The server also serves the frontend files from the [public](public) folder.
|
|
||||||
4. The browser loads the frontend entry point [public/app.js](public/app.js) which imports modules from [public/js/](public/js).
|
|
||||||
5. The frontend downloads templates and config from the API using a single batch request.
|
|
||||||
6. The frontend stores reports and images locally in IndexedDB.
|
|
||||||
7. Reports can be submitted to the server via `POST /api/v1/reports`.
|
|
||||||
|
|
||||||
## Root Files
|
1. `docker compose up` starts three containers: `app`, `db`, `phpmyadmin`.
|
||||||
|
2. [src/server.js](src/server.js) boots Express and connects to MariaDB.
|
||||||
|
3. The browser loads `/` → `portal.html` (chooser page).
|
||||||
|
4. The user clicks a link and goes to `/login-admin` or `/login-user`.
|
||||||
|
5. After login, `/admin` or `/user` is served — both require a valid
|
||||||
|
`auth_token` cookie (see [src/middleware/authMiddleware.js](src/middleware/authMiddleware.js)).
|
||||||
|
6. The frontend shell loads either `admin-app.js` or `user-app.js`, which
|
||||||
|
hands off to `public/js/admin.js` or `public/js/user.js`.
|
||||||
|
7. The frontend calls `/api/v1/admin/all` to bulk-load every entity needed
|
||||||
|
for rendering, and keeps working drafts in IndexedDB.
|
||||||
|
|
||||||
|
## Root files
|
||||||
|
|
||||||
### [package.json](package.json)
|
### [package.json](package.json)
|
||||||
|
|
||||||
Role:
|
Defines the Node project: scripts (`start`, `dev`, `test:environment`),
|
||||||
Defines the Node.js project itself.
|
dependencies (Express, MariaDB driver, cookie-parser, cors, dotenv), and the
|
||||||
|
minimum Node version.
|
||||||
What it contains:
|
|
||||||
- project name and version
|
|
||||||
- npm scripts such as `start`, `dev`, and `test:environment`
|
|
||||||
- dependency list such as `express`, `mariadb`, and `dotenv`
|
|
||||||
|
|
||||||
Why it matters:
|
|
||||||
When you run `npm install` or `npm start`, Node uses this file to know how the project should behave.
|
|
||||||
|
|
||||||
### [README.md](README.md)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Main entry document for developers.
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- project purpose
|
|
||||||
- setup steps
|
|
||||||
- available endpoints
|
|
||||||
- development notes
|
|
||||||
|
|
||||||
Why it matters:
|
|
||||||
This is the first file a new developer should read before touching the code.
|
|
||||||
|
|
||||||
### [docker-compose.yml](docker-compose.yml)
|
### [docker-compose.yml](docker-compose.yml)
|
||||||
|
|
||||||
Role:
|
Spins up `app`, `db`, and `phpmyadmin` containers with the right volumes and
|
||||||
Starts the full local development environment.
|
ports for local development.
|
||||||
|
|
||||||
What it contains:
|
### [.env.example](.env.example) / `.env`
|
||||||
- the application container
|
|
||||||
- the MariaDB container
|
`.env.example` documents the variables the backend expects. Copy it to `.env`
|
||||||
- the phpMyAdmin container
|
and fill in real values; [src/config/env.js](src/config/env.js) loads them at
|
||||||
- port mappings and volume configuration
|
startup and fails fast if a required one is missing.
|
||||||
|
|
||||||
Why it matters:
|
### [.devcontainer/](.devcontainer/)
|
||||||
Instead of installing and configuring everything manually, you can start the full stack with one command.
|
|
||||||
|
VS Code Dev Containers definition so the editor opens directly inside the
|
||||||
### [.env.example](.env.example)
|
`app` container.
|
||||||
|
|
||||||
Role:
|
### [docker/mariadb/init/](docker/mariadb/init/)
|
||||||
Example environment configuration.
|
|
||||||
|
Mount point for extra init scripts. `sql/schema.sql` and `sql/seed.sql` are
|
||||||
What it contains:
|
placed here by Docker Compose so they run the first time the DB volume is
|
||||||
- default port values
|
created.
|
||||||
- database host, name, user, and password placeholders
|
|
||||||
|
## public/ — what the browser gets
|
||||||
Why it matters:
|
|
||||||
It shows which environment variables the application expects.
|
### HTML shells
|
||||||
|
|
||||||
### [.env](.env)
|
- [public/portal.html](public/portal.html) — the neutral landing page at `/`.
|
||||||
|
Two links: one to the user login, one to the admin login.
|
||||||
Role:
|
- [public/login-admin.html](public/login-admin.html) and
|
||||||
Real environment file used on the current machine.
|
[public/login-user.html](public/login-user.html) — login forms. They POST
|
||||||
|
to `/api/v1/auth/admin/login` or `/api/v1/auth/user/login` and follow the
|
||||||
What it contains:
|
JSON `redirect` on success.
|
||||||
- actual local values for ports and database credentials
|
- [public/admin.html](public/admin.html) — Bootstrap sidebar + main area for
|
||||||
|
the admin console. Loads `admin-app.js`. The sidebar opens on the
|
||||||
Why it matters:
|
**Reports** panel by default; nav categories are ordered
|
||||||
The backend reads this file during startup through `dotenv`.
|
Reports → CheckLists → Sites → Users → Settings.
|
||||||
|
- [public/user.html](public/user.html) — Bootstrap shell for the operator
|
||||||
## Public Folder
|
task workflow. Loads `user-app.js`. Contains a **Sync** button in the
|
||||||
|
Assigned Tasks header that refreshes the task list from the server without
|
||||||
The [public](public) folder contains files that are sent directly to the browser.
|
a full page reload.
|
||||||
|
|
||||||
### [public/user.html](public/user.html)
|
### Top-level scripts
|
||||||
|
|
||||||
Role:
|
- [public/admin-app.js](public/admin-app.js) — opens the shared IndexedDB
|
||||||
Operator workspace HTML shell.
|
(`check-list-poc-db`), reads the cached image rules, then calls
|
||||||
|
`initAdmin()` from [public/js/admin.js](public/js/admin.js).
|
||||||
What it contains:
|
- [public/user-app.js](public/user-app.js) — opens the shared IndexedDB
|
||||||
- shared sidebar with template selector, sync button, report list, and search/filter
|
(`check-list-poc-db`) and calls `initUser()` from
|
||||||
- report editor main area with hero, summary cards, form, inspector panel
|
[public/js/user.js](public/js/user.js). `user.js` additionally opens a
|
||||||
- report list item `<template>` for runtime rendering
|
second database (`user-portal-db`) for per-task record data and images.
|
||||||
|
|
||||||
When you edit it:
|
### Static assets
|
||||||
- when you need to change the report editing layout
|
|
||||||
- when adding new user-facing controls or sections
|
- [public/styles.css](public/styles.css) — custom styles layered on top of
|
||||||
|
Bootstrap 5. Includes `.admin-table` (full-width, striped header, hover
|
||||||
### [public/admin.html](public/admin.html)
|
rows), `.admin-table-compact` (denser column padding for wide entity
|
||||||
|
tables), and `.admin-table-actions` (shrink-wraps the actions column so
|
||||||
Role:
|
data columns take all remaining space).
|
||||||
Administrator workspace HTML shell.
|
- [public/icon.svg](public/icon.svg) — PWA icon referenced by
|
||||||
|
[public/manifest.webmanifest](public/manifest.webmanifest).
|
||||||
What it contains:
|
|
||||||
- simplified sidebar with template selector and sync button (no report list)
|
## public/js/ — frontend modules
|
||||||
- admin main area with image policy editor form and admin summary panel
|
|
||||||
|
Each file has a single responsibility and imports only what it needs.
|
||||||
When you edit it:
|
|
||||||
- when adding new admin configuration panels
|
- [public/js/constants.js](public/js/constants.js) — store names, API base
|
||||||
- when changing the image policy form fields
|
path, timings.
|
||||||
|
- [public/js/state.js](public/js/state.js) — tiny shared object holding the
|
||||||
### [public/index.html](public/index.html)
|
current IndexedDB handle. Kept deliberately small; most feature-specific
|
||||||
|
state lives inside `admin.js` or `user.js`.
|
||||||
Role:
|
- [public/js/api.js](public/js/api.js) — `fetchJson(path, options)` wrapper
|
||||||
Legacy combined app shell (no longer served by routes).
|
that prepends the versioned base path and turns `{ message }` error bodies
|
||||||
|
into thrown errors.
|
||||||
Note:
|
- [public/js/db.js](public/js/db.js) — opens and wraps `check-list-poc-db`
|
||||||
This file has been superseded by [user.html](public/user.html) and [admin.html](public/admin.html).
|
(the shared IndexedDB used by both admin and user consoles) with generic
|
||||||
It remains in the repository for reference but is not routed to by the Express server.
|
CRUD helpers: get, put, delete, transaction.
|
||||||
|
- [public/js/user-db.js](public/js/user-db.js) — opens and wraps
|
||||||
### [public/portal.html](public/portal.html)
|
`user-portal-db` (a second, separate IndexedDB used only by the user
|
||||||
|
console). Stores per-task record answers, visit dates, and image
|
||||||
Role:
|
`dataUrl` blobs keyed by numeric task ID.
|
||||||
Simple chooser page that lets the user open either the user area or the admin area.
|
- [public/js/validation.js](public/js/validation.js) — pure validators for
|
||||||
|
reports and image-rule forms. No DOM or state dependencies, so the same
|
||||||
What it contains:
|
logic can be reused server-side.
|
||||||
- a short explanation of the two entry points
|
- [public/js/images.js](public/js/images.js) — image optimisation entry
|
||||||
- a link to `/user`
|
point. Detects Web Worker + `OffscreenCanvas` support and falls back to
|
||||||
- a link to `/admin`
|
main-thread canvas when not available. Preserves the EXIF block so the
|
||||||
|
backend gets the same metadata the user saw.
|
||||||
What it does:
|
- [public/js/image-worker.js](public/js/image-worker.js) — the worker
|
||||||
It is the landing page served at `/`. It does not contain complex logic. Its job is only to separate the entry points.
|
implementation used by `images.js`.
|
||||||
|
- [public/js/exif.js](public/js/exif.js) — lightweight JPEG EXIF parser
|
||||||
### [public/app.js](public/app.js)
|
(IFD0 + GPS tags) used by both the user flow and the admin lightbox.
|
||||||
|
- [public/js/admin.js](public/js/admin.js) — the admin console controller.
|
||||||
Role:
|
Navigation, render functions, and CRUD handlers for every admin category.
|
||||||
Thin orchestrator entry point for the frontend.
|
The Users table includes a **Tasks** column with a badge showing how many
|
||||||
|
tasks are assigned to each user (blue if > 0, grey if 0).
|
||||||
What it contains:
|
- [public/js/user.js](public/js/user.js) — the operator workflow controller.
|
||||||
- imports from all ES modules under `public/js/`
|
Key responsibilities:
|
||||||
- startup flow (`init()`, `cacheElements()`, `bindEvents()`)
|
- `loadFromServer()` — fetches `/api/v1/admin/all`; on `401` (stale
|
||||||
- report CRUD operations (create, open, delete, submit)
|
cookie after a server restart) it redirects to `/login-user`; on any
|
||||||
- sync logic with the API using batch template fetch
|
other failure it falls back to the IndexedDB cache.
|
||||||
- dirty-flag autosave loop
|
- `filterTasksByUser()` — keeps only tasks where `task.userId` matches
|
||||||
- admin image-policy save
|
the `?userId=` URL parameter.
|
||||||
- event delegation for the report list
|
- `showDetailView(id)` — chains `maybeHydrateFromServer()` (seeds
|
||||||
- search/filter event wiring
|
IndexedDB from the server if the task has no local data) →
|
||||||
- null guards for page-specific elements (user vs admin)
|
`maybeDownloadImages()` (fetches image blobs from the server if they
|
||||||
|
were previously uploaded) → `renderTaskDetail()`.
|
||||||
What changed:
|
- `forceSyncWithServer()` — called by the Sync button; re-fetches live
|
||||||
This file was originally a monolithic ~1700-line file. It has been split into modules (A1)
|
data without a page reload.
|
||||||
and now acts as a controller. It is shared between user.html and admin.html — it detects
|
- Record editing, image attachment, save-as-draft, and submit-as-final
|
||||||
which DOM elements exist and only binds behavior for the current page.
|
with image optimisation and validation.
|
||||||
|
|
||||||
### [public/styles.css](public/styles.css)
|
## src/ — backend
|
||||||
|
|
||||||
Role:
|
|
||||||
Main styling file for the frontend.
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- color variables
|
|
||||||
- layout rules
|
|
||||||
- sidebar and panel styles
|
|
||||||
- form field styles
|
|
||||||
- admin and user page styles
|
|
||||||
- responsive behavior for smaller screens
|
|
||||||
|
|
||||||
What it does:
|
|
||||||
It controls how the application looks on desktop and mobile.
|
|
||||||
|
|
||||||
When you edit it:
|
|
||||||
- when spacing or sizing is wrong
|
|
||||||
- when a section needs a new visual style
|
|
||||||
- when mobile layout needs improvement
|
|
||||||
|
|
||||||
### [public/sw.js](public/sw.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Service worker for offline behavior.
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- cache name
|
|
||||||
- app shell file list (including all new JS modules)
|
|
||||||
- install, activate, and fetch event handlers
|
|
||||||
- LRU cache trimming for dynamic entries (P5)
|
|
||||||
|
|
||||||
What it does:
|
|
||||||
It tells the browser how to cache frontend files and API responses.
|
|
||||||
|
|
||||||
Important behavior:
|
|
||||||
- static files use cache-first strategy
|
|
||||||
- API calls use network-first strategy
|
|
||||||
- dynamic cache is bounded to prevent unbounded storage growth
|
|
||||||
|
|
||||||
### [public/icon.svg](public/icon.svg)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
SVG icon used by the PWA manifest (F5).
|
|
||||||
|
|
||||||
### Frontend ES Modules (public/js/)
|
|
||||||
|
|
||||||
These modules were extracted from the original monolithic `app.js` (A1).
|
|
||||||
All render functions include null guards so the shared `app.js` can safely run on
|
|
||||||
both user.html (operator workspace) and admin.html (administrator workspace).
|
|
||||||
|
|
||||||
### [public/js/constants.js](public/js/constants.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Shared constants used across frontend modules.
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- IndexedDB name, version, and store names
|
|
||||||
- API base path (`/api/v1`)
|
|
||||||
- autosave interval, render debounce timing, cache limits
|
|
||||||
|
|
||||||
### [public/js/state.js](public/js/state.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Centralized state management for the frontend.
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- `state` object with all application state (including dirty flag, search query, filter)
|
|
||||||
- `elements` object for cached DOM references
|
|
||||||
- `getCurrentReport()` and `getTemplateRecord()` helpers
|
|
||||||
- `stateHelpers` bundle used by other modules to avoid circular imports
|
|
||||||
|
|
||||||
### [public/js/i18n.js](public/js/i18n.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Internationalization locale extraction (F7).
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- English locale with ~80 UI string keys
|
|
||||||
- `t(key, ...params)` translation function with placeholder replacement
|
|
||||||
- `setLocale()` for future language support
|
|
||||||
|
|
||||||
### [public/js/utils.js](public/js/utils.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Pure utility and formatting functions.
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- `prettifyStatus()`, `formatDateTime()`, `formatTime()`, `formatRelativeTime()`, `formatFileSize()`
|
|
||||||
- `sanitizeForFilename()`, `generateReportNumber()`, `buildGeneratedFilename()`
|
|
||||||
- `makeTemplateKey()`, `deriveTemplateCatalog()`
|
|
||||||
- `debounce()` helper for rate-limiting function calls (P2)
|
|
||||||
|
|
||||||
### [public/js/db.js](public/js/db.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
IndexedDB operations with multi-store transaction support.
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- `openDatabase()` with schema version 2
|
|
||||||
- CRUD helpers: `dbGetAll()`, `dbGet()`, `dbPut()`, `dbDelete()`, `dbGetAllByIndex()`
|
|
||||||
- `dbTransaction()` for atomic multi-store operations (A5)
|
|
||||||
- `saveSetting()` and `loadSetting()` for UI preferences
|
|
||||||
|
|
||||||
### [public/js/api.js](public/js/api.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
API communication layer with versioned base path (A3).
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- `fetchJson(path, options)` that prepends the API base path automatically
|
|
||||||
- `registerServiceWorker()`
|
|
||||||
|
|
||||||
### [public/js/validation.js](public/js/validation.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Shared validation logic for reports and admin forms (A7).
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- `validateReport()` — checks required fields, number ranges, attachment counts
|
|
||||||
- `validateImageRulesPayload()` — validates image policy form submissions
|
|
||||||
- `evaluateRequiredWhen()` — conditional field requirement logic
|
|
||||||
- `isBlankValue()` — blank check by field type
|
|
||||||
|
|
||||||
### [public/js/images.js](public/js/images.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Image optimization with Web Worker support (P1).
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- `optimizeImage(file, imageRules)` — automatically detects Web Worker support
|
|
||||||
- Worker path: delegates to `image-worker.js` via `OffscreenCanvas`
|
|
||||||
- Falls back to main-thread canvas when OffscreenCanvas is unavailable
|
|
||||||
|
|
||||||
### [public/js/image-worker.js](public/js/image-worker.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Web Worker for background image processing (P1).
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- `self.onmessage` handler using `createImageBitmap` and `OffscreenCanvas`
|
|
||||||
- Sends optimized blob back to main thread with matching message ID
|
|
||||||
|
|
||||||
### [public/js/forms.js](public/js/forms.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Dynamic form field creation from template JSON.
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- `createFieldNode(field, report, callbacks)` — renders fields with callback pattern
|
|
||||||
- `createAttachmentFieldNode()` with IntersectionObserver for lazy-loading thumbnails (P3)
|
|
||||||
- `escapeHtml()` for XSS prevention
|
|
||||||
|
|
||||||
### [public/js/renderer.js](public/js/renderer.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
All DOM render functions with search and filter support (F4).
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- `render()` orchestrator — conditionally calls user or admin renders
|
|
||||||
- `renderReportList()` — filters by search query and status
|
|
||||||
- `renderCurrentReport(fieldCallbacks)` — accepts callbacks for field changes
|
|
||||||
- `renderTemplateSelector()`, `renderTemplateSummary()`
|
|
||||||
- `renderSyncSummary()`, `renderImagePolicy()`, `renderAdminImageRules()`
|
|
||||||
- `updateConnectionBadge()`, `updateSaveBadge()`
|
|
||||||
- Uses `t()` for all user-visible strings
|
|
||||||
- Null guards on all page-specific functions for safe user/admin coexistence
|
|
||||||
|
|
||||||
### [public/js/export.js](public/js/export.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Report data export functionality (F2).
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- `exportReportCSV()` — generates CSV from template fields and report answers
|
|
||||||
- `exportReportAttachments()` — triggers download for each attachment blob
|
|
||||||
- `csvEscape()` and `downloadBlob()` helper functions
|
|
||||||
|
|
||||||
### [public/manifest.webmanifest](public/manifest.webmanifest)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Basic PWA metadata file.
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- app name
|
|
||||||
- short name
|
|
||||||
- theme color
|
|
||||||
- start URL
|
|
||||||
|
|
||||||
What it does:
|
|
||||||
Helps the browser treat the app more like an installable web application.
|
|
||||||
|
|
||||||
## Source Folder
|
|
||||||
|
|
||||||
The [src](src) folder contains backend code.
|
|
||||||
|
|
||||||
### [src/server.js](src/server.js)
|
### [src/server.js](src/server.js)
|
||||||
|
|
||||||
Role:
|
Boot file. Imports the Express app, confirms the DB connection, starts
|
||||||
Real server startup file.
|
listening, and wires up `SIGINT` / `SIGTERM` for a clean shutdown.
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- startup function
|
|
||||||
- database connection check
|
|
||||||
- Express app startup
|
|
||||||
- graceful shutdown logic
|
|
||||||
|
|
||||||
What it does:
|
|
||||||
This is the file Node runs when the backend starts.
|
|
||||||
|
|
||||||
Simple mental model:
|
|
||||||
- load the Express app
|
|
||||||
- make sure MariaDB is reachable
|
|
||||||
- start listening on a port
|
|
||||||
- close cleanly when the process is stopped
|
|
||||||
|
|
||||||
### [src/app.js](src/app.js)
|
### [src/app.js](src/app.js)
|
||||||
|
|
||||||
Role:
|
Express wiring. Middleware stack in mount order:
|
||||||
Express application configuration file.
|
|
||||||
|
1. `cors()` — wide-open CORS for PoC use
|
||||||
What it contains:
|
2. `cookieParser()` — parses the `auth_token` cookie
|
||||||
- middleware registration
|
3. `express.json({ limit: '50mb' })` — accepts large base64 image payloads
|
||||||
- API v1 route registration under `/api/v1/` prefix (A3)
|
4. `noCacheHtml` — sets `Cache-Control: no-cache` on every HTML response so
|
||||||
- static file serving
|
browsers revalidate page content after a Docker restart instead of
|
||||||
- frontend entry page routes
|
serving a stale cached copy
|
||||||
- global error handling
|
5. API routes under `/api/v1/`
|
||||||
|
6. HTML page routes (`/`, `/login-admin`, `/login-user`, `/admin`, `/user`)
|
||||||
Important routes:
|
7. `express.static(public/)` with `setHeaders` applying `no-cache` for
|
||||||
- `/api/v1/...` for versioned backend endpoints
|
`.html` files; JS/CSS use default ETag revalidation
|
||||||
- `/` for the chooser page
|
8. 404 + global error handlers
|
||||||
- `/user` and `/admin` for the frontend app shell
|
|
||||||
|
`requireAnyAuth` is applied to `/api/v1/admin/*` and `/api/v1/reports/*`;
|
||||||
## Source Subfolders
|
`requireUserAuth` / `requireAdminAuth` gate the HTML workspaces.
|
||||||
|
|
||||||
### [src/config/env.js](src/config/env.js)
|
### src/config/
|
||||||
|
|
||||||
Role:
|
- [src/config/env.js](src/config/env.js) — loads `.env` via `dotenv`, checks
|
||||||
Loads and validates environment variables.
|
required keys, exposes a normalised `env` object.
|
||||||
|
|
||||||
What it contains:
|
### src/db/
|
||||||
- `dotenv` setup
|
|
||||||
- required environment key checks
|
- [src/db/pool.js](src/db/pool.js) — creates the shared MariaDB pool and
|
||||||
- normalized `env` object export
|
exposes `query()` / `closePool()`.
|
||||||
|
|
||||||
What it does:
|
### src/middleware/
|
||||||
It reads raw values from `.env` and converts them into a safer object the rest of the code can use.
|
|
||||||
|
- [src/middleware/authMiddleware.js](src/middleware/authMiddleware.js) —
|
||||||
Why this is useful:
|
`requireAdminAuth`, `requireUserAuth`, `requireAnyAuth`. For API callers
|
||||||
Instead of reading `process.env` everywhere, the app reads from one clean place.
|
(detected via `req.originalUrl` / `Accept`) an unauthenticated response
|
||||||
|
returns `401 JSON`; browser navigation is redirected to the relevant login
|
||||||
### [src/db/pool.js](src/db/pool.js)
|
page.
|
||||||
|
- [src/middleware/errorHandler.js](src/middleware/errorHandler.js) —
|
||||||
Role:
|
`notFoundHandler` and global `errorHandler` that convert everything to a
|
||||||
Creates and manages the MariaDB connection pool.
|
consistent JSON shape.
|
||||||
|
- [src/middleware/validateParams.js](src/middleware/validateParams.js) —
|
||||||
What it contains:
|
regex / numeric URL-parameter guards used by route definitions.
|
||||||
- one shared database pool
|
|
||||||
- `query()` helper
|
### src/routes/
|
||||||
- `closePool()` helper
|
|
||||||
|
| File | Prefix | Notes |
|
||||||
What it does:
|
| --- | --- | --- |
|
||||||
It gives the service layer a standard way to run SQL queries.
|
| [healthRoutes.js](src/routes/healthRoutes.js) | `/api/v1/health` | Liveness + DB ping. |
|
||||||
|
| [authRoutes.js](src/routes/authRoutes.js) | `/api/v1/auth` | Admin/user login, logout, `me`. Issues the `auth_token` cookie. |
|
||||||
Why it matters:
|
| [adminRoutes.js](src/routes/adminRoutes.js) | `/api/v1/admin` | CRUD for every admin entity and `/all` bulk load. Gated by `requireAnyAuth`. |
|
||||||
Without this file, each route or service would have to manage its own connections, which would be messy and error-prone.
|
| [reportRoutes.js](src/routes/reportRoutes.js) | `/api/v1/reports` | Submit/get/list/delete reports and their images. Gated by `requireAnyAuth`. |
|
||||||
|
| [configRoutes.js](src/routes/configRoutes.js) | `/api/v1/config` | Image-rules read/write (audit-logged) and export profile. |
|
||||||
### [src/middleware/errorHandler.js](src/middleware/errorHandler.js)
|
| [lookupRoutes.js](src/routes/lookupRoutes.js) | `/api/v1/lookups` | Reference lookup sets and values. |
|
||||||
|
| [templateRoutes.js](src/routes/templateRoutes.js) | `/api/v1/templates` | Legacy template catalogue (kept for the seeded sample + smoke test). |
|
||||||
Role:
|
|
||||||
Handles not-found routes and server errors.
|
### src/services/
|
||||||
|
|
||||||
What it contains:
|
Thin layer between routes and SQL. Each file owns a domain.
|
||||||
- `notFoundHandler`
|
|
||||||
- `errorHandler`
|
- [adminService.js](src/services/adminService.js) — everything under
|
||||||
|
`admin_*` tables, plus the bulk `loadAllAdminData()` used by both the
|
||||||
What it does:
|
admin and user consoles.
|
||||||
When a route does not exist, or when code throws an error, this file creates a JSON response instead of letting the app fail in an uncontrolled way.
|
- [auditService.js](src/services/auditService.js) — writes to `audit_log`
|
||||||
|
whenever something sensitive changes (report submit/delete, image rules
|
||||||
### [src/middleware/validateParams.js](src/middleware/validateParams.js)
|
update, template publish).
|
||||||
|
- [authService.js](src/services/authService.js) — credential verification
|
||||||
Role:
|
and an in-memory session `Map` with 24-hour expiry. Plain-text password
|
||||||
URL parameter validation middleware (A7).
|
comparison — PoC only.
|
||||||
|
- [cacheService.js](src/services/cacheService.js) — generic LRU + TTL
|
||||||
What it contains:
|
factory, pre-configured `templateCache`, `lookupCache`, `configCache`.
|
||||||
- `validateParam(name)` — checks route params against safe patterns (code or UUID)
|
- [configService.js](src/services/configService.js) — image rules and
|
||||||
- `validateNumericParam(name)` — validates numeric route parameters
|
export profile queries. (Previous `app_config` helpers were removed along
|
||||||
|
with the table.)
|
||||||
What it does:
|
- [lookupService.js](src/services/lookupService.js) — `lookup_sets` /
|
||||||
It rejects malformed URL parameters at the middleware level before they reach route handlers.
|
`lookup_values`.
|
||||||
|
- [reportService.js](src/services/reportService.js) — `reports` and
|
||||||
### [src/routes/healthRoutes.js](src/routes/healthRoutes.js)
|
`report_images`. Images are stored as `LONGBLOB` rows; list helpers
|
||||||
|
group them by `record_id`.
|
||||||
Role:
|
- [templateService.js](src/services/templateService.js) — template +
|
||||||
Defines the health-check endpoint.
|
`template_versions` queries and publish flow.
|
||||||
|
|
||||||
What it contains:
|
### src/utils/
|
||||||
- `GET /api/health`
|
|
||||||
|
- [asyncHandler.js](src/utils/asyncHandler.js) — wraps async route handlers
|
||||||
What it does:
|
so rejected promises reach the Express error middleware.
|
||||||
Checks whether the server and database are working.
|
- [json.js](src/utils/json.js) — safely parses JSON columns returned by
|
||||||
|
MariaDB.
|
||||||
Why it matters:
|
|
||||||
This endpoint is used by tests and is also useful when debugging container problems.
|
## sql/
|
||||||
|
|
||||||
### [src/routes/templateRoutes.js](src/routes/templateRoutes.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Defines template-related API endpoints.
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- list endpoint for active templates
|
|
||||||
- batch endpoint with `?include=definitions` for single-request sync (A2)
|
|
||||||
- endpoint for one active template
|
|
||||||
- endpoint for a specific template version
|
|
||||||
- version listing for a template (F3)
|
|
||||||
- version publishing endpoint (F3)
|
|
||||||
- parameter validation middleware
|
|
||||||
- LRU cache integration (P4)
|
|
||||||
|
|
||||||
### [src/routes/lookupRoutes.js](src/routes/lookupRoutes.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Defines lookup-related API endpoints.
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- list endpoint for lookup sets
|
|
||||||
- endpoint for one lookup set
|
|
||||||
- parameter validation and LRU cache integration
|
|
||||||
|
|
||||||
### [src/routes/configRoutes.js](src/routes/configRoutes.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Defines configuration-related API endpoints.
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- image rule read and update endpoints
|
|
||||||
- export profile endpoint
|
|
||||||
- general app config endpoint
|
|
||||||
- LRU cache read-through and invalidation (P4)
|
|
||||||
- audit trail logging on image rules update (F6)
|
|
||||||
|
|
||||||
### [src/routes/reportRoutes.js](src/routes/reportRoutes.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Defines report submission API endpoints (F1).
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- `GET /` — list submitted reports with filters (status, templateCode, limit, offset)
|
|
||||||
- `GET /:reportId` — single report by UUID
|
|
||||||
- `POST /` — submit or update a report (UPSERT by UUID, audit-logged)
|
|
||||||
|
|
||||||
### [src/services/templateService.js](src/services/templateService.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Handles template-related database queries.
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- SQL for active template list
|
|
||||||
- `getAllActiveTemplates()` — batch query returning all templates with definitions (A2)
|
|
||||||
- `listTemplateVersions()` — all versions of a given template (F3)
|
|
||||||
- `publishTemplateVersion()` — retire current active, activate specified version (F3)
|
|
||||||
- template row mapping logic
|
|
||||||
|
|
||||||
### [src/services/lookupService.js](src/services/lookupService.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Handles lookup-related database queries.
|
|
||||||
|
|
||||||
### [src/services/configService.js](src/services/configService.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Handles configuration-related database queries.
|
|
||||||
|
|
||||||
### [src/services/reportService.js](src/services/reportService.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Handles report submission and retrieval (F1).
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- `submitReport(report)` — UPSERT by report UUID
|
|
||||||
- `getReport(uuid)` — fetch single report
|
|
||||||
- `listReports({ status, templateCode, limit, offset })` — filtered listing
|
|
||||||
|
|
||||||
### [src/services/auditService.js](src/services/auditService.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Audit trail logging for admin mutations (F6).
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- `logAuditEvent({ entityType, entityCode, action, oldValue, newValue })` — writes to audit_log table
|
|
||||||
- `getAuditLog()` — reads audit entries with optional filters
|
|
||||||
|
|
||||||
### [src/services/cacheService.js](src/services/cacheService.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
In-memory LRU cache with TTL for server-side data (P4).
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- `createCache({ maxEntries, ttlMs })` factory function
|
|
||||||
- Pre-configured singletons: `templateCache`, `lookupCache`, `configCache`
|
|
||||||
- Each cache instance has `get()`, `set()`, `delete()`, and `clear()` methods
|
|
||||||
|
|
||||||
### [src/utils/asyncHandler.js](src/utils/asyncHandler.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Small helper for async Express routes.
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- `asyncHandler()` wrapper
|
|
||||||
|
|
||||||
What it does:
|
|
||||||
It catches rejected async route errors and forwards them to Express error middleware.
|
|
||||||
|
|
||||||
Why it matters:
|
|
||||||
Without it, every async route would need repetitive `try/catch` blocks.
|
|
||||||
|
|
||||||
### [src/utils/json.js](src/utils/json.js)
|
|
||||||
|
|
||||||
Role:
|
|
||||||
Small helper for parsing JSON values coming from MariaDB.
|
|
||||||
|
|
||||||
What it contains:
|
|
||||||
- `parseJsonColumn()`
|
|
||||||
|
|
||||||
What it does:
|
|
||||||
It safely converts JSON strings into objects and arrays.
|
|
||||||
|
|
||||||
Why it matters:
|
|
||||||
Several database columns store JSON text, and this helper prevents the same parsing logic from being repeated in multiple files.
|
|
||||||
|
|
||||||
## SQL Folder
|
|
||||||
|
|
||||||
### [sql/schema.sql](sql/schema.sql)
|
### [sql/schema.sql](sql/schema.sql)
|
||||||
|
|
||||||
Role:
|
Defines every table the app touches:
|
||||||
Creates the database structure.
|
|
||||||
|
|
||||||
What it contains:
|
- Template catalogue: `templates`, `template_versions`.
|
||||||
- database creation
|
- Reference data: `lookup_sets`, `lookup_values`, `image_rules`,
|
||||||
- table definitions
|
`export_profiles`.
|
||||||
- keys and foreign keys
|
- Reports: `reports` (answers as JSON) and `report_images` (binary blobs).
|
||||||
|
- Audit: `audit_log`.
|
||||||
What it does:
|
- Admin entities: `admin_categories`, `admin_sub_categories`,
|
||||||
It defines how templates, lookups, image rules, export profiles, and app config are stored.
|
`admin_severities`, `admin_statuses`, `admin_handled_by`,
|
||||||
|
`admin_projects`, `admin_processes`, `admin_users`, `admin_sites`,
|
||||||
|
`admin_cl_records`, `admin_cl_templates`,
|
||||||
|
`admin_cl_template_records` (join), `admin_tasks`.
|
||||||
|
- Credentials: `admin_credentials`.
|
||||||
|
|
||||||
### [sql/seed.sql](sql/seed.sql)
|
### [sql/seed.sql](sql/seed.sql)
|
||||||
|
|
||||||
Role:
|
Inserts enough data for the frontend to boot: the sample
|
||||||
Inserts example data.
|
`incoming-inspection` template, `pass-fail` / `draft-status` lookups, the
|
||||||
|
default image-rules row, the default export profile, and seed admin/user
|
||||||
|
credentials.
|
||||||
|
|
||||||
What it contains:
|
## scripts/
|
||||||
- one example checklist template
|
|
||||||
- lookup values
|
|
||||||
- one image policy
|
|
||||||
- one export profile
|
|
||||||
- app config values
|
|
||||||
|
|
||||||
What it does:
|
|
||||||
It gives the frontend and API something real to work with immediately after startup.
|
|
||||||
|
|
||||||
## Scripts Folder
|
|
||||||
|
|
||||||
### [scripts/test-environment.js](scripts/test-environment.js)
|
### [scripts/test-environment.js](scripts/test-environment.js)
|
||||||
|
|
||||||
Role:
|
Connectivity smoke test — hits `/api/v1/health`, `/api/v1/templates`, the DB
|
||||||
Simple smoke test for the local environment.
|
directly, and phpMyAdmin. Useful after `docker compose up` and after any
|
||||||
|
infrastructure change.
|
||||||
|
|
||||||
What it contains:
|
## Recommended reading order
|
||||||
- API health checks
|
|
||||||
- template endpoint checks
|
|
||||||
- direct database checks
|
|
||||||
- phpMyAdmin checks
|
|
||||||
|
|
||||||
What it does:
|
|
||||||
It verifies that the main development services are working together.
|
|
||||||
|
|
||||||
When to use it:
|
|
||||||
- after starting Docker containers
|
|
||||||
- after changing infrastructure-related code
|
|
||||||
- after big refactors when you want a quick confidence check
|
|
||||||
|
|
||||||
## Recommended Reading Order For A Junior Developer
|
|
||||||
|
|
||||||
If you are new to this project, a good reading order is:
|
|
||||||
1. [README.md](README.md)
|
1. [README.md](README.md)
|
||||||
2. [PROJECT_FILES_GUIDE.md](PROJECT_FILES_GUIDE.md)
|
2. [docker-compose.yml](docker-compose.yml)
|
||||||
3. [src/server.js](src/server.js)
|
3. [sql/schema.sql](sql/schema.sql)
|
||||||
4. [src/app.js](src/app.js)
|
4. [src/server.js](src/server.js) → [src/app.js](src/app.js)
|
||||||
5. [src/routes/templateRoutes.js](src/routes/templateRoutes.js)
|
5. [src/middleware/authMiddleware.js](src/middleware/authMiddleware.js)
|
||||||
6. [src/services/templateService.js](src/services/templateService.js)
|
6. [src/routes/adminRoutes.js](src/routes/adminRoutes.js) and
|
||||||
7. [public/js/constants.js](public/js/constants.js) and [public/js/state.js](public/js/state.js)
|
[src/services/adminService.js](src/services/adminService.js)
|
||||||
8. [public/app.js](public/app.js) — the thin orchestrator that wires everything together
|
7. [public/admin-app.js](public/admin-app.js) →
|
||||||
9. [public/js/renderer.js](public/js/renderer.js) and [public/js/forms.js](public/js/forms.js)
|
[public/js/admin.js](public/js/admin.js)
|
||||||
7. [public/index.html](public/index.html)
|
8. [public/user-app.js](public/user-app.js) →
|
||||||
8. [public/app.js](public/app.js)
|
[public/js/user.js](public/js/user.js)
|
||||||
9. [sql/schema.sql](sql/schema.sql)
|
|
||||||
10. [sql/seed.sql](sql/seed.sql)
|
|
||||||
|
|
||||||
That order helps because it moves from the high-level entry points into the deeper details.
|
## Quick "where do I look" rule
|
||||||
|
|
||||||
## Practical Rule Of Thumb
|
- Page layout looks wrong → the relevant `.html` in [public/](public/) and
|
||||||
|
[public/styles.css](public/styles.css).
|
||||||
When you are trying to change something, use this shortcut:
|
- Browser behaviour is wrong → `public/js/admin.js` or `public/js/user.js`,
|
||||||
- if the browser layout looks wrong, check [public/index.html](public/index.html) and [public/styles.css](public/styles.css)
|
or the feature-specific module (`validation.js`, `images.js`, `exif.js`).
|
||||||
- if browser behavior is wrong, check [public/app.js](public/app.js)
|
- API endpoint is wrong → the matching file in [src/routes/](src/routes/),
|
||||||
- if an API endpoint is wrong, check [src/routes](src/routes) first and then [src/services](src/services)
|
then the service it delegates to.
|
||||||
- if database data is wrong, check [sql/schema.sql](sql/schema.sql), [sql/seed.sql](sql/seed.sql), and the service file that runs the query
|
- Data looks wrong → [sql/schema.sql](sql/schema.sql) and
|
||||||
- if the app does not start, check [src/server.js](src/server.js), [src/config/env.js](src/config/env.js), and [docker-compose.yml](docker-compose.yml)
|
[sql/seed.sql](sql/seed.sql), plus the service file that owns the query.
|
||||||
|
- App won't start → [src/server.js](src/server.js),
|
||||||
This simple rule is often enough to help you find the right file quickly.
|
[src/config/env.js](src/config/env.js), and
|
||||||
|
[docker-compose.yml](docker-compose.yml).
|
||||||
|
|||||||
@@ -1,241 +1,745 @@
|
|||||||
# Check List Proof of Concept
|
# Check List — Proof of Concept
|
||||||
|
|
||||||
This repository contains a proof-of-concept implementation for the Check List hybrid reporting solution described in the project documentation.
|
Hybrid offline-first quality-inspection reporting application. The backend is
|
||||||
|
Node.js + Express + MariaDB; the frontend is a Bootstrap 5 single-page
|
||||||
|
application split into two role-specific shells. Operators fill in inspection
|
||||||
|
records and attach images locally in IndexedDB; completed reports (with image
|
||||||
|
binaries) are uploaded to the server. Administrators manage all configuration
|
||||||
|
entities and review submitted reports through a separate console.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [What is included](#what-is-included)
|
||||||
|
- [Scope of this PoC](#scope-of-this-poc)
|
||||||
|
- [Project structure](#project-structure)
|
||||||
|
- [Data flow — detailed](#data-flow--detailed)
|
||||||
|
- [Application boot](#1-application-boot)
|
||||||
|
- [Authentication flow](#2-authentication-flow)
|
||||||
|
- [Admin console — configuration management](#3-admin-console--configuration-management)
|
||||||
|
- [User workspace — task processing](#4-user-workspace--task-processing)
|
||||||
|
- [Report submission flow](#5-report-submission-flow)
|
||||||
|
- [Admin report review](#6-admin-report-review)
|
||||||
|
- [Cache and offline strategy](#7-cache-and-offline-strategy)
|
||||||
|
- [Session lifecycle and browser restart behaviour](#8-session-lifecycle-and-browser-restart-behaviour)
|
||||||
|
- [Database schema overview](#database-schema-overview)
|
||||||
|
- [API endpoints](#api-endpoints)
|
||||||
|
- [Run with Docker and Dev Containers](#run-with-docker-and-dev-containers)
|
||||||
|
- [Database bootstrap](#database-bootstrap)
|
||||||
|
- [Validate the environment](#validate-the-environment)
|
||||||
|
- [Notes on security posture](#notes-on-security-posture)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## What is included
|
## What is included
|
||||||
|
|
||||||
- Node.js REST API (v1) for template, configuration, report, and audit delivery
|
- Node.js REST API (`/api/v1/…`) for admin entities, reports, image policy,
|
||||||
- static frontend PoC served by Express, split into focused ES modules
|
and authentication
|
||||||
- MariaDB schema for configuration data, submitted reports, and audit trail
|
- Two Bootstrap 5 frontend shells served directly by Express:
|
||||||
- seed data with one sample inspection checklist template
|
- `user.html` — operator workspace: open an assigned task, fill in
|
||||||
- lookup values, image policy, and export profile
|
inspection records per category, attach and optimize images, save as
|
||||||
|
draft or submit as final
|
||||||
|
- `admin.html` — administrator console: manage users, sites, check-list
|
||||||
|
records and templates, task assignments, image policy, and review
|
||||||
|
submitted reports with image viewer and map
|
||||||
|
- Cookie-based session authentication for both admin and user roles; sessions
|
||||||
|
survive page reload but are cleared on server restart (in-memory store)
|
||||||
|
- Cache-Control headers set to `no-cache` on all HTML responses so browsers
|
||||||
|
always revalidate page content after a Docker restart
|
||||||
|
- MariaDB schema covering admin entities, image rules, reports, report image
|
||||||
|
blobs, and an audit log
|
||||||
|
- Seed data with one sample inspection checklist template, lookup values,
|
||||||
|
image policy, and export profile
|
||||||
- Docker Compose and VS Code Dev Container setup for local development
|
- Docker Compose and VS Code Dev Container setup for local development
|
||||||
- service worker with bounded LRU cache for offline support
|
- Smoke test that verifies API, database, and phpMyAdmin connectivity
|
||||||
|
|
||||||
## Scope of this PoC
|
## Scope of this PoC
|
||||||
|
|
||||||
Included:
|
**Included:**
|
||||||
- all endpoints under versioned `/api/v1/` prefix
|
- Versioned REST API under `/api/v1/`
|
||||||
- batch template endpoint with `?include=definitions` for single-request sync
|
- Cookie-based session authentication (admin and user), login pages at
|
||||||
- template version listing and publishing management
|
`/login-admin` and `/login-user`
|
||||||
- lookup endpoints with parameter validation
|
- Full CRUD endpoints for admin-managed entities (categories, sub-categories,
|
||||||
- image rule endpoint with server-side LRU cache and audit trail
|
severities, statuses, handled-by options, projects, processes, users,
|
||||||
- export profile and generic application config endpoints
|
sites, check-list records, check-list templates, tasks)
|
||||||
- report submission endpoint (POST) with UPSERT
|
- Bulk data endpoint `GET /api/v1/admin/all` — one request loads the entire
|
||||||
- audit log recording for admin mutations
|
entity graph used by both admin and user consoles
|
||||||
- offline-capable frontend shell split into ES modules
|
- Image rules endpoint with server-side LRU cache; clients read and enforce
|
||||||
- IndexedDB-based local drafts with multi-store transactions
|
the active rule locally
|
||||||
- dynamic form rendering from template JSON
|
- Report submission and retrieval; image upload/download endpoints (images
|
||||||
- local attachment storage with Web Worker image optimization
|
stored as `LONGBLOB` rows in MariaDB)
|
||||||
- report search and status filter
|
- Audit logging for report submissions and deletions, image-rule updates
|
||||||
- CSV export for report data
|
- Offline-capable local drafts via IndexedDB with automatic server hydration
|
||||||
- i18n locale extraction for UI strings
|
on re-open
|
||||||
|
- Web Worker image optimisation (OffscreenCanvas with main-thread fallback)
|
||||||
|
- EXIF extraction preserved through the optimisation pipeline
|
||||||
- PWA manifest with SVG icon
|
- PWA manifest with SVG icon
|
||||||
- debounced renders and dirty-flag autosave
|
|
||||||
|
|
||||||
Not included:
|
**Not included:**
|
||||||
- authentication and authorization
|
- Password hashing (credentials are compared in plain text — PoC only)
|
||||||
- file attachment upload to server (binary upload requires multer)
|
- Rate limiting, CSRF tokens, or JWT-based authentication
|
||||||
- XLSX or ZIP generation (CSV is provided; advanced formats require library vendoring)
|
- XLSX/ZIP export generation
|
||||||
- production-grade frontend bundling
|
- Automated test suite (only a connectivity smoke test is provided)
|
||||||
- automated test suite
|
- Production-grade bundling or minification
|
||||||
|
|
||||||
The PoC keeps template content inside a JSON column to reduce initial complexity and speed up delivery. This is deliberate for phase 1 proof-of-concept work.
|
---
|
||||||
|
|
||||||
## Project structure
|
## Project structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
.
|
.
|
||||||
├── .devcontainer/
|
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── package.json
|
├── package.json
|
||||||
|
├── PROJECT_FILES_GUIDE.md ← per-file guided walk-through
|
||||||
|
├── README.md ← this file
|
||||||
├── public/
|
├── public/
|
||||||
│ ├── admin.html ← administrator workspace
|
│ ├── admin.html ← admin workspace shell
|
||||||
│ ├── app.js ← thin orchestrator entry point
|
│ ├── admin-app.js ← admin entry point
|
||||||
│ ├── icon.svg ← PWA icon
|
│ ├── user.html ← operator workspace shell
|
||||||
│ ├── index.html ← legacy combined shell (unused)
|
│ ├── user-app.js ← user entry point
|
||||||
|
│ ├── portal.html ← neutral landing page at /
|
||||||
|
│ ├── login-admin.html ← admin login form
|
||||||
|
│ ├── login-user.html ← user login form
|
||||||
|
│ ├── styles.css ← shared styles (Bootstrap 5 overrides)
|
||||||
│ ├── manifest.webmanifest
|
│ ├── manifest.webmanifest
|
||||||
│ ├── portal.html
|
|
||||||
│ ├── styles.css
|
|
||||||
│ ├── sw.js
|
|
||||||
│ ├── user.html ← operator workspace
|
|
||||||
│ └── js/
|
│ └── js/
|
||||||
│ ├── api.js ← API communication (versioned base path)
|
│ ├── admin.js ← admin console controller and renderers
|
||||||
│ ├── constants.js ← shared constants (DB, API, limits)
|
│ ├── user.js ← user task workflow controller
|
||||||
│ ├── db.js ← IndexedDB operations, multi-store tx
|
│ ├── api.js ← fetchJson helper (versioned base path)
|
||||||
│ ├── export.js ← CSV export and attachment download
|
│ ├── constants.js ← shared constants (store names, API base)
|
||||||
│ ├── forms.js ← dynamic form field creation
|
│ ├── state.js ← shared state container (IndexedDB handle)
|
||||||
│ ├── i18n.js ← English locale, t() translation
|
│ ├── db.js ← shared IndexedDB open + CRUD helpers
|
||||||
│ ├── image-worker.js ← OffscreenCanvas Web Worker
|
│ ├── user-db.js ← user-side IndexedDB task-data cache
|
||||||
│ ├── images.js ← image optimization with worker fallback
|
│ ├── validation.js ← image-rule and report validators
|
||||||
│ ├── renderer.js ← all render functions with search/filter
|
│ ├── images.js ← image optimisation (Worker + fallback)
|
||||||
│ ├── state.js ← centralized state management
|
│ ├── image-worker.js ← OffscreenCanvas worker implementation
|
||||||
│ ├── utils.js ← utility functions
|
│ └── exif.js ← lightweight JPEG EXIF parser
|
||||||
│ └── validation.js ← shared validation (client-side)
|
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ └── test-environment.js
|
│ └── test-environment.js ← connectivity smoke test
|
||||||
├── sql/
|
├── sql/
|
||||||
│ ├── schema.sql
|
│ ├── schema.sql ← full MariaDB DDL
|
||||||
│ └── seed.sql
|
│ └── seed.sql ← sample data + admin/user credentials
|
||||||
└── src/
|
└── src/
|
||||||
├── app.js
|
├── app.js ← Express wiring (routes, middleware, static)
|
||||||
├── server.js
|
├── server.js ← entry point (boot, DB ping, shutdown)
|
||||||
├── config/
|
├── config/
|
||||||
│ └── env.js
|
│ └── env.js ← dotenv loader + required-var validation
|
||||||
├── db/
|
├── db/
|
||||||
│ └── pool.js
|
│ └── pool.js ← shared MariaDB connection pool
|
||||||
├── middleware/
|
├── middleware/
|
||||||
│ ├── errorHandler.js
|
│ ├── authMiddleware.js ← requireAdminAuth / requireUserAuth / requireAnyAuth
|
||||||
│ └── validateParams.js ← URL parameter validation
|
│ ├── errorHandler.js ← 404 and global error JSON responders
|
||||||
|
│ └── validateParams.js ← URL-parameter regex safety guards
|
||||||
├── routes/
|
├── routes/
|
||||||
│ ├── configRoutes.js
|
│ ├── adminRoutes.js ← /api/v1/admin/*
|
||||||
│ ├── healthRoutes.js
|
│ ├── authRoutes.js ← /api/v1/auth/*
|
||||||
│ ├── lookupRoutes.js
|
│ ├── configRoutes.js ← /api/v1/config/*
|
||||||
│ ├── reportRoutes.js ← report submission endpoints
|
│ ├── healthRoutes.js ← /api/v1/health
|
||||||
│ └── templateRoutes.js
|
│ ├── lookupRoutes.js ← /api/v1/lookups/*
|
||||||
|
│ ├── reportRoutes.js ← /api/v1/reports/*
|
||||||
|
│ └── templateRoutes.js ← /api/v1/templates/*
|
||||||
├── services/
|
├── services/
|
||||||
│ ├── auditService.js ← audit trail logging
|
│ ├── adminService.js ← admin entity CRUD + bulk load
|
||||||
│ ├── cacheService.js ← in-memory LRU cache with TTL
|
│ ├── auditService.js ← audit_log writes
|
||||||
│ ├── configService.js
|
│ ├── authService.js ← in-memory session store
|
||||||
│ ├── lookupService.js
|
│ ├── cacheService.js ← generic LRU/TTL cache factory
|
||||||
│ ├── reportService.js ← report CRUD operations
|
│ ├── configService.js ← image rules + export profile queries
|
||||||
│ └── templateService.js
|
│ ├── lookupService.js ← lookup_sets / lookup_values queries
|
||||||
|
│ ├── reportService.js ← reports + report_images CRUD
|
||||||
|
│ └── templateService.js ← template catalogue queries
|
||||||
└── utils/
|
└── utils/
|
||||||
├── asyncHandler.js
|
├── asyncHandler.js ← wraps async route handlers for error propagation
|
||||||
└── json.js
|
└── json.js ← safe JSON column parser
|
||||||
```
|
```
|
||||||
|
|
||||||
## File guide
|
For a per-file guided walk-through see [PROJECT_FILES_GUIDE.md](PROJECT_FILES_GUIDE.md).
|
||||||
|
|
||||||
For a junior-friendly explanation of what each main file does, see [PROJECT_FILES_GUIDE.md](PROJECT_FILES_GUIDE.md).
|
---
|
||||||
|
|
||||||
|
## Data flow — detailed
|
||||||
|
|
||||||
|
This section traces how data moves through every layer of the application for
|
||||||
|
each major scenario.
|
||||||
|
|
||||||
|
### 1. Application boot
|
||||||
|
|
||||||
|
```
|
||||||
|
docker compose up
|
||||||
|
└─ app container
|
||||||
|
├─ npm install
|
||||||
|
└─ node src/server.js
|
||||||
|
├─ src/config/env.js — loads .env, validates required keys,
|
||||||
|
│ exports typed `env` object
|
||||||
|
├─ src/db/pool.js — creates shared MariaDB connection pool
|
||||||
|
│ (connectionLimit from DB_CONNECTION_LIMIT,
|
||||||
|
│ default 5)
|
||||||
|
├─ SELECT 1 AS ok — fails fast if DB is not reachable
|
||||||
|
└─ app.listen(port) — Express starts accepting connections
|
||||||
|
|
||||||
|
└─ db container (bitnami/mariadb)
|
||||||
|
└─ on a FRESH volume: runs sql/schema.sql then sql/seed.sql
|
||||||
|
(mounted at /docker-entrypoint-initdb.d)
|
||||||
|
|
||||||
|
└─ phpmyadmin container — web UI at :8080
|
||||||
|
```
|
||||||
|
|
||||||
|
**Express middleware stack** (`src/app.js`, top to bottom):
|
||||||
|
|
||||||
|
1. `cors()` — wide-open CORS for PoC use
|
||||||
|
2. `cookieParser()` — parses `auth_token` cookie used by auth middleware
|
||||||
|
3. `express.json({ limit: '50mb' })` — accepts large base64 image payloads
|
||||||
|
4. `noCacheHtml` middleware applied to every HTML route — sets
|
||||||
|
`Cache-Control: no-cache` so browsers always revalidate page content
|
||||||
|
with the server; this prevents stale HTML being served from cache after
|
||||||
|
a Docker restart
|
||||||
|
5. API routes under `/api/v1/`
|
||||||
|
6. HTML page routes (`/`, `/login-admin`, `/login-user`, `/admin`, `/user`)
|
||||||
|
7. `express.static(publicDir)` with `setHeaders` applying `no-cache` for
|
||||||
|
`.html` files; all other static assets (JS, CSS) use default ETag
|
||||||
|
revalidation
|
||||||
|
8. `notFoundHandler` → 404 JSON
|
||||||
|
9. Global `errorHandler` → structured JSON error with stack in development
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Authentication flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser Express MariaDB
|
||||||
|
│ │ │
|
||||||
|
├─ GET / ─────────────────────► │ │
|
||||||
|
│◄─ portal.html ─────────────── │ │
|
||||||
|
│ │ │
|
||||||
|
├─ GET /login-user ────────────► │ │
|
||||||
|
│◄─ login-user.html ──────────── │ │
|
||||||
|
│ │ │
|
||||||
|
├─ POST /api/v1/auth/user/login │ │
|
||||||
|
│ { email, password } ────────► │ │
|
||||||
|
│ ├─ verifyUserCredentials() ────►│
|
||||||
|
│ │ SELECT FROM admin_users │
|
||||||
|
│ │ WHERE email=? AND │
|
||||||
|
│ │ password_hash=? ◄────────────│
|
||||||
|
│ │ │
|
||||||
|
│ ├─ generateSessionToken() │
|
||||||
|
│ │ (random string + timestamp) │
|
||||||
|
│ │ │
|
||||||
|
│ ├─ createSession(token, data) │
|
||||||
|
│ │ stored in in-memory Map │
|
||||||
|
│ │ { type, id, email, role, … } │
|
||||||
|
│ │ TTL: 24 hours │
|
||||||
|
│ │ │
|
||||||
|
│◄─ 200 { success, token, user } │ │
|
||||||
|
│ Set-Cookie: auth_token=… │ │
|
||||||
|
│ sessionStorage.setItem( │ │
|
||||||
|
│ 'auth_token', token) │ │
|
||||||
|
│ │ │
|
||||||
|
├─ GET /user?userId=<id> ──────► │ │
|
||||||
|
│ Cookie: auth_token=… │ │
|
||||||
|
│ ├─ requireUserAuth middleware │
|
||||||
|
│ │ validateSession(token) │
|
||||||
|
│ │ checks in-memory Map │
|
||||||
|
│◄─ user.html ────────────────── │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
**Key points:**
|
||||||
|
- Sessions live in an **in-memory `Map`** in `authService.js`. A Docker
|
||||||
|
restart clears all sessions.
|
||||||
|
- The `auth_token` cookie has a 24-hour `Max-Age`. If the browser sends a
|
||||||
|
stale cookie after a server restart, auth middleware returns `401`. The
|
||||||
|
user app detects `401` from `loadFromServer()` and redirects to
|
||||||
|
`/login-user` automatically.
|
||||||
|
- The `userId` query parameter appended to the redirect URL (`/user?userId=N`)
|
||||||
|
is used by `filterTasksByUser()` on the client side to show only tasks
|
||||||
|
assigned to that user.
|
||||||
|
- Admin login follows the same pattern against the `admin_credentials` table
|
||||||
|
with `requireAdminAuth` middleware guarding `/admin`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Admin console — configuration management
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser (admin.html) Express / MariaDB
|
||||||
|
│ │
|
||||||
|
├─ DOMContentLoaded ───────────► admin-app.js
|
||||||
|
│ openDatabase() opens IndexedDB 'check-list-poc-db'
|
||||||
|
│ dbGet(STORE_CONFIG, reads cached image rules (may be null
|
||||||
|
│ 'imageRules') on first visit)
|
||||||
|
│ initAdmin() ─────────────► admin.js
|
||||||
|
│ │
|
||||||
|
│ loadFromServer() ─────►├─ GET /api/v1/admin/all
|
||||||
|
│ │ adminService.loadAllAdminData()
|
||||||
|
│ │ parallel queries to:
|
||||||
|
│ │ admin_categories
|
||||||
|
│ │ admin_sub_categories
|
||||||
|
│ │ admin_severities
|
||||||
|
│ │ admin_statuses
|
||||||
|
│ │ admin_handled_by
|
||||||
|
│ │ admin_projects
|
||||||
|
│ │ admin_processes
|
||||||
|
│ │ admin_users
|
||||||
|
│ │ admin_sites
|
||||||
|
│ │ admin_cl_records
|
||||||
|
│ │ admin_cl_templates
|
||||||
|
│ │ (+ admin_cl_template_records join)
|
||||||
|
│ │ admin_tasks
|
||||||
|
│ ◄─ full entity graph ──│
|
||||||
|
│ caches entire response │
|
||||||
|
│ in IndexedDB STORE_CONFIG
|
||||||
|
│ key 'admin_all' │
|
||||||
|
│ │
|
||||||
|
│ initNavigation() wires sidebar expand/collapse,
|
||||||
|
│ panel switching, opens Reports tab
|
||||||
|
│ renderTaskList() default panel
|
||||||
|
│ renderUserList() (task count badge per user)
|
||||||
|
│ renderSiteList() │
|
||||||
|
│ renderClRecordList() │
|
||||||
|
│ renderClTemplateList() │
|
||||||
|
│ renderImagePolicy() │
|
||||||
|
│ renderTemplateSettings() │
|
||||||
|
│ renderTaskSettings() │
|
||||||
|
```
|
||||||
|
|
||||||
|
**CRUD operations** (same pattern for every entity):
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks Save Express MariaDB
|
||||||
|
│ │ │
|
||||||
|
├─ POST /api/v1/admin/users ───► │ │
|
||||||
|
│ { email, name, … } ├─ adminService.createUser() ──►│
|
||||||
|
│ │ INSERT INTO admin_users │
|
||||||
|
│ │◄─ { id, … } ─────────────────│
|
||||||
|
│◄─ 201 { id, … } ──────────── │ │
|
||||||
|
│ admin.users.push(created) │ │
|
||||||
|
│ cacheState() ────────────── updates IndexedDB 'admin_all' │
|
||||||
|
│ renderUserList() re-renders the table │
|
||||||
|
```
|
||||||
|
|
||||||
|
PUT and DELETE follow the same pattern. All mutations also call `cacheState()`
|
||||||
|
to keep the IndexedDB snapshot current so the app works offline immediately
|
||||||
|
after any change.
|
||||||
|
|
||||||
|
**Task assignment** creates a row in `admin_tasks` linking a user, site, and
|
||||||
|
checklist template. That row is what the user console reads to know which
|
||||||
|
tasks exist for a given `userId`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. User workspace — task processing
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser (user.html) Express / MariaDB / IndexedDB
|
||||||
|
│ │
|
||||||
|
├─ DOMContentLoaded ───────────► user-app.js
|
||||||
|
│ openDatabase() opens 'check-list-poc-db' (shared IndexedDB)
|
||||||
|
│ initUser() ───────────────► user.js
|
||||||
|
│ │
|
||||||
|
│ loadFromServer() │
|
||||||
|
│ ├─ GET /api/v1/admin/all ►│ same bulk endpoint as admin console
|
||||||
|
│ │ │ returns tasks, templates, records,
|
||||||
|
│ │ │ sites, users, settings
|
||||||
|
│ │◄─ full entity graph ───│
|
||||||
|
│ │ saves to IndexedDB │
|
||||||
|
│ │ STORE_CONFIG 'admin_all'
|
||||||
|
│ │ │
|
||||||
|
│ └─ on 401: redirect ────►│ session gone (server restart) →
|
||||||
|
│ to /login-user user re-authenticates
|
||||||
|
│ on other error: falls back to IndexedDB cache
|
||||||
|
│ │
|
||||||
|
│ loadTaskData() ──────────── opens 'user-portal-db' (second IndexedDB)
|
||||||
|
│ reads all task-specific data:
|
||||||
|
│ visitDate, per-record answers, image blobs
|
||||||
|
│ keyed by taskId (numeric)
|
||||||
|
│ │
|
||||||
|
│ filterTasksByUser() keeps only tasks where task.userId ===
|
||||||
|
│ Number(URL ?userId=N)
|
||||||
|
│ │
|
||||||
|
│ renderTaskListView() table of assigned tasks
|
||||||
|
│ renderSidebarTasks() task count badge in sidebar
|
||||||
|
```
|
||||||
|
|
||||||
|
**Opening a task:**
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks Open user.js IndexedDB / Server
|
||||||
|
│ │ │
|
||||||
|
├─ showDetailView(taskId) ─────► │ │
|
||||||
|
│ │ id = Number(taskId) │
|
||||||
|
│ │ (normalizes string from DOM) │
|
||||||
|
│ │ │
|
||||||
|
│ maybeHydrateFromServer(id) ►│ │
|
||||||
|
│ │ checks 'user-portal-db' for │
|
||||||
|
│ │ existing record data │
|
||||||
|
│ │ if EMPTY (fresh browser/ │
|
||||||
|
│ │ cleared storage): │
|
||||||
|
│ ├─ GET /api/v1/reports/:id ────►│
|
||||||
|
│ │ fetches last submitted │
|
||||||
|
│ │ answers (visitDate, records) │
|
||||||
|
│ │ seeds local state from │
|
||||||
|
│ │ server response │
|
||||||
|
│ │ images marked │
|
||||||
|
│ │ uploadedToServer:true │
|
||||||
|
│ │ persists to IndexedDB │
|
||||||
|
│ │ │
|
||||||
|
│ maybeDownloadImages(id) ───►│ │
|
||||||
|
│ │ if any image has │
|
||||||
|
│ │ uploadedToServer:true AND │
|
||||||
|
│ │ no dataUrl: │
|
||||||
|
│ ├─ GET /api/v1/reports/:id/images►│
|
||||||
|
│ │ fetches LONGBLOB rows │
|
||||||
|
│ │ converts to base64 dataUrls │
|
||||||
|
│ │ restores into local state │
|
||||||
|
│ │ saves to IndexedDB │
|
||||||
|
│ │ │
|
||||||
|
│ renderTaskDetail() ────────►│ renders form with visit date, │
|
||||||
|
│ │ category tabs, per-record │
|
||||||
|
│ │ fields pre-populated from │
|
||||||
|
│ │ hydrated state │
|
||||||
|
```
|
||||||
|
|
||||||
|
**Record editing and auto-save:**
|
||||||
|
|
||||||
|
```
|
||||||
|
User changes a field user.js IndexedDB
|
||||||
|
│ │ │
|
||||||
|
├─ onRecordFieldChange() ──────► │ │
|
||||||
|
│ │ updates userState.taskData[id]│
|
||||||
|
│ │ .records[recId].status │
|
||||||
|
│ │ .records[recId].handledBy │
|
||||||
|
│ │ .records[recId].comment │
|
||||||
|
│ │ safeSave() │
|
||||||
|
│ ├─ saveOneTaskData(id, data) ──►│
|
||||||
|
│ │ stores { taskId, visitDate, │
|
||||||
|
│ │ records } in 'user-portal-db'│
|
||||||
|
│ │ keyPath: taskId │
|
||||||
|
```
|
||||||
|
|
||||||
|
**Image attachment flow:**
|
||||||
|
|
||||||
|
```
|
||||||
|
User drops / selects image user.js Worker / IndexedDB
|
||||||
|
│ │ │
|
||||||
|
├─ onImageAdd() / drop ────────► │ │
|
||||||
|
│ │ readFileAsDataUrl(file) │
|
||||||
|
│ │ parseExifFromDataUrl() │
|
||||||
|
│ optimizeImagesIfNeeded() ──│ │
|
||||||
|
│ │ if oversizeBehavior = │
|
||||||
|
│ │ 'auto_optimize': │
|
||||||
|
│ │ images.js → image-worker.js│
|
||||||
|
│ │ OffscreenCanvas resize │
|
||||||
|
│ │ (main-thread canvas fallback│
|
||||||
|
│ │ when Worker unavailable) │
|
||||||
|
│ │ EXIF block preserved │
|
||||||
|
│ │ result stored in: │
|
||||||
|
│ │ taskData[id].records[recId] │
|
||||||
|
│ │ .images[].dataUrl │
|
||||||
|
│ │ safeSave() → IndexedDB │
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Report submission flow
|
||||||
|
|
||||||
|
**Save as Draft:**
|
||||||
|
|
||||||
|
```
|
||||||
|
User clicks Save as Draft user.js Express / MariaDB
|
||||||
|
│ │ │
|
||||||
|
├─ saveDraft() ────────────────► │ │
|
||||||
|
│ │ persistCurrentFormData() │
|
||||||
|
│ │ optimizeImagesIfNeeded() │
|
||||||
|
│ │ updateTaskStatus('draft') │
|
||||||
|
│ ├─ PUT /api/v1/admin/tasks/:id │
|
||||||
|
│ │ { …, status:'draft' } ──────►│
|
||||||
|
│ │ UPDATE admin_tasks SET │
|
||||||
|
│ │ status='draft' │
|
||||||
|
│ │ uploadReport('draft') ────────►│
|
||||||
|
│ ├─ POST /api/v1/reports │
|
||||||
|
│ │ { id, answers: { records, │
|
||||||
|
│ │ visitDate, … } } │
|
||||||
|
│ │ reportService.submitReport() │
|
||||||
|
│ │ stripImagesFromAnswers() │
|
||||||
|
│ │ → answers_json (no blobs) │
|
||||||
|
│ │ storeReportImages() │
|
||||||
|
│ │ → LONGBLOB rows per image │
|
||||||
|
│ │ ON DUPLICATE KEY UPDATE │
|
||||||
|
│ │ (idempotent re-submit) │
|
||||||
|
│ │◄─ 201 / 200 ─────────────────│
|
||||||
|
│ │ Images NOT stripped from │
|
||||||
|
│ │ IndexedDB (kept for editing) │
|
||||||
|
```
|
||||||
|
|
||||||
|
**Save as Final:**
|
||||||
|
|
||||||
|
Same as draft with additional steps before upload:
|
||||||
|
|
||||||
|
1. `validateVisitDate()` — checks date is within template `validFrom`/`validTill`
|
||||||
|
2. `validateForFinal()` — every record must have a Status; records with
|
||||||
|
status requiring Handled By, Comment, or image attachment are checked
|
||||||
|
3. `processImagesForFinal()` — renames images to a standardised convention
|
||||||
|
and applies resize/quality optimisation per image policy
|
||||||
|
4. After successful upload: `stripImageDataFromStorage()` removes `dataUrl`
|
||||||
|
bytes from IndexedDB (only `name`, `size`, `uploadedToServer:true`
|
||||||
|
metadata kept) to free local storage
|
||||||
|
5. If upload fails: images are kept in IndexedDB; toast warns the user
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Admin report review
|
||||||
|
|
||||||
|
```
|
||||||
|
Admin clicks View (task row) admin.js Express / MariaDB
|
||||||
|
│ │ │
|
||||||
|
├─ viewTaskReport(taskId) ─────► │ │
|
||||||
|
│ ├─ GET /api/v1/reports/:id ────►│
|
||||||
|
│ │ reads reports.answers_json │
|
||||||
|
│ │◄─ { answers } ───────────────│
|
||||||
|
│ │ │
|
||||||
|
│ ├─ GET /api/v1/reports/:id/ │
|
||||||
|
│ │ images ───────────────────►│
|
||||||
|
│ │ reads LONGBLOB rows, │
|
||||||
|
│ │ converts to base64 dataUrls │
|
||||||
|
│ │ grouped by record_id │
|
||||||
|
│ │◄─ { [recordId]: [{dataUrl}] } │
|
||||||
|
│ │ │
|
||||||
|
│ buildAndShowReportModal() renders Bootstrap modal with │
|
||||||
|
│ all fields, images in lightbox, │
|
||||||
|
│ EXIF data, and map pins for │
|
||||||
|
│ images that have GPS coordinates│
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reopen task:**
|
||||||
|
```
|
||||||
|
Admin clicks Reopen admin.js / Express MariaDB
|
||||||
|
│ │ │
|
||||||
|
├─ reopenTask(id) ─────────────► │ │
|
||||||
|
│ ├─ PUT /api/v1/admin/tasks/:id │
|
||||||
|
│ │ { …, status:'pending' } ────►│
|
||||||
|
│ │ UPDATE admin_tasks │
|
||||||
|
│ │ SET status='pending' │
|
||||||
|
│ task.status = 'pending' enables editing on user side │
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. Cache and offline strategy
|
||||||
|
|
||||||
|
The application uses **two IndexedDB databases** running concurrently in the
|
||||||
|
browser:
|
||||||
|
|
||||||
|
| Database | Name | Usage |
|
||||||
|
|---|---|---|
|
||||||
|
| Shared admin cache | `check-list-poc-db` | Entity graph from `/api/v1/admin/all` stored under key `admin_all` in `STORE_CONFIG`. Also caches image rules. Used by both admin and user consoles. |
|
||||||
|
| User task data | `user-portal-db` | Per-task record answers, visit date, and image `dataUrl` blobs. Stored by task ID. Used only by the user console. |
|
||||||
|
|
||||||
|
**Load priority on startup:**
|
||||||
|
|
||||||
|
```
|
||||||
|
navigator.onLine?
|
||||||
|
YES → loadFromServer() fetch /api/v1/admin/all
|
||||||
|
→ success: save to IndexedDB, populate state
|
||||||
|
→ 401: redirect to login (session expired)
|
||||||
|
→ other: loadFromCache() (fall back to last snapshot)
|
||||||
|
NO → loadFromCache() read 'admin_all' from IndexedDB
|
||||||
|
```
|
||||||
|
|
||||||
|
**HTML caching:**
|
||||||
|
All HTML responses include `Cache-Control: no-cache`. The browser stores the
|
||||||
|
HTML but validates it with the server on every load using ETag conditional
|
||||||
|
GETs. A `304 Not Modified` (no body) confirms the page is current; a `200`
|
||||||
|
delivers the new version. JS and CSS files use Express's default ETag
|
||||||
|
validation without the `no-cache` directive.
|
||||||
|
|
||||||
|
**Sync button:**
|
||||||
|
The user task list view includes a manual **Sync** button (`#syncBtn`). It
|
||||||
|
calls `forceSyncWithServer()`, which re-fetches `/api/v1/admin/all`,
|
||||||
|
re-applies the user filter, and re-renders both the task list and sidebar
|
||||||
|
without a full page reload.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Session lifecycle and browser restart behaviour
|
||||||
|
|
||||||
|
Because sessions are kept in an **in-memory Map** on the server, a Docker
|
||||||
|
container restart clears all sessions while the browser's `auth_token` cookie
|
||||||
|
remains (24-hour `Max-Age`). The sequence that follows:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. Container restarts → sessions Map cleared
|
||||||
|
2. Browser sends request with old cookie
|
||||||
|
3. Server: validateSession(token) → null → 401
|
||||||
|
4. user.js loadFromServer() detects 401 → window.location = '/login-user'
|
||||||
|
5. User logs in → new token → new session created in Map
|
||||||
|
6. /api/v1/admin/all succeeds → tasks visible
|
||||||
|
```
|
||||||
|
|
||||||
|
If IndexedDB has a previous snapshot, the user will see their old tasks
|
||||||
|
immediately while the login redirect is pending — the fallback `loadFromCache()`
|
||||||
|
path handles that. Once re-logged in, the fresh server data overwrites the
|
||||||
|
cache.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Database schema overview
|
||||||
|
|
||||||
|
```
|
||||||
|
Templates (legacy catalogue, used by smoke test and lookups)
|
||||||
|
templates ──< template_versions
|
||||||
|
lookup_sets ──< lookup_values
|
||||||
|
|
||||||
|
Configuration
|
||||||
|
image_rules (1 active row; LRU-cached by configService)
|
||||||
|
export_profiles (1 active row)
|
||||||
|
|
||||||
|
Admin entities (managed through admin console)
|
||||||
|
admin_categories ──< admin_sub_categories
|
||||||
|
admin_severities
|
||||||
|
admin_statuses (requireHandledBy, requireComment flags)
|
||||||
|
admin_handled_by
|
||||||
|
admin_projects ──< admin_processes
|
||||||
|
admin_sites
|
||||||
|
admin_users (also used for login via password_hash column)
|
||||||
|
admin_cl_records (sort_order, category, sub_category, descriptions EN/FR/NL,
|
||||||
|
severity, imageRequired)
|
||||||
|
admin_cl_templates ──< admin_cl_template_records (join)
|
||||||
|
admin_tasks (links user + site + template; status: pending/draft/final)
|
||||||
|
|
||||||
|
Reports
|
||||||
|
reports (answers stored as JSON; report_uuid is the client-side task ID)
|
||||||
|
report_images (LONGBLOB per image, indexed by report_uuid + record_id)
|
||||||
|
|
||||||
|
Audit
|
||||||
|
audit_log (entity_type, entity_code, action, old/new JSON)
|
||||||
|
|
||||||
|
Credentials
|
||||||
|
admin_credentials (single admin account; plain-text password — PoC only)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API endpoints
|
||||||
|
|
||||||
|
All endpoints live under `/api/v1/`. Routes that read or modify sensitive data
|
||||||
|
require an authenticated session cookie (`auth_token`).
|
||||||
|
|
||||||
|
### Public (no authentication)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/v1/health` | Liveness + DB ping |
|
||||||
|
| GET | `/api/v1/templates` | List templates |
|
||||||
|
| GET | `/api/v1/templates/:code` | Get one template |
|
||||||
|
| GET | `/api/v1/templates/:code/versions` | List versions |
|
||||||
|
| PUT | `/api/v1/templates/:code/versions/:v/publish` | Publish version |
|
||||||
|
| GET | `/api/v1/lookups` | List lookup sets |
|
||||||
|
| GET | `/api/v1/lookups/:code` | Get lookup values |
|
||||||
|
| GET | `/api/v1/config/image-rules` | Active image rule |
|
||||||
|
| PUT | `/api/v1/config/image-rules` | Update image rule (audit-logged) |
|
||||||
|
| GET | `/api/v1/config/export` | Active export profile |
|
||||||
|
| POST | `/api/v1/auth/admin/login` | Admin login → sets cookie |
|
||||||
|
| POST | `/api/v1/auth/user/login` | User login → sets cookie |
|
||||||
|
| POST | `/api/v1/auth/logout` | Clears cookie and session |
|
||||||
|
| GET | `/api/v1/auth/check` | Validate current session |
|
||||||
|
|
||||||
|
### Requires authentication (`requireAnyAuth`)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| GET | `/api/v1/admin/all` | Bulk load every admin entity |
|
||||||
|
| CRUD | `/api/v1/admin/categories` | Categories |
|
||||||
|
| CRUD | `/api/v1/admin/sub-categories` | Sub-categories (parent: category) |
|
||||||
|
| CRUD | `/api/v1/admin/severities` | Severity levels |
|
||||||
|
| CRUD | `/api/v1/admin/statuses` | Statuses (with requiredHandledBy/Comment flags) |
|
||||||
|
| CRUD | `/api/v1/admin/handled-by` | Handled-by options |
|
||||||
|
| CRUD | `/api/v1/admin/projects` | Projects |
|
||||||
|
| CRUD | `/api/v1/admin/processes` | Processes (parent: project) |
|
||||||
|
| CRUD | `/api/v1/admin/users` | Users (operators) |
|
||||||
|
| CRUD | `/api/v1/admin/sites` | Sites |
|
||||||
|
| CRUD | `/api/v1/admin/cl-records` | Check-list records |
|
||||||
|
| CRUD | `/api/v1/admin/cl-templates` | Check-list templates (with record ID list) |
|
||||||
|
| CRUD | `/api/v1/admin/tasks` | Task assignments |
|
||||||
|
| GET/POST | `/api/v1/reports` | List / submit reports |
|
||||||
|
| GET/DELETE | `/api/v1/reports/:id` | Get / delete one report |
|
||||||
|
| GET | `/api/v1/reports/:id/images` | All images for a report (base64 dataUrls) |
|
||||||
|
| DELETE | `/api/v1/reports/:id/images/:recId/:fileName` | Delete one image |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Run with Docker and Dev Containers
|
## Run with Docker and Dev Containers
|
||||||
|
|
||||||
1. Copy `.env.example` to `.env` if you want custom local credentials.
|
1. Copy `.env.example` to `.env` (or leave defaults — the Compose file
|
||||||
2. In VS Code, run `Dev Containers: Reopen in Container`.
|
supplies fallback values for all variables).
|
||||||
3. Or start the stack directly with `docker compose up -d --build`.
|
2. **VS Code Dev Containers:** run `Dev Containers: Reopen in Container`.
|
||||||
|
**Or** start directly: `docker compose up -d --build`.
|
||||||
|
|
||||||
Services:
|
Services after startup:
|
||||||
- API: `http://localhost:3000`
|
|
||||||
- phpMyAdmin: `http://localhost:8080`
|
|
||||||
- MariaDB: `localhost:3306`
|
|
||||||
|
|
||||||
The workspace is bind-mounted into the `app` container, so project files remain editable from VS Code.
|
| Service | URL |
|
||||||
|
|---|---|
|
||||||
|
| API + web app | `http://localhost:3000` |
|
||||||
|
| phpMyAdmin | `http://localhost:8080` |
|
||||||
|
| MariaDB | `localhost:3306` |
|
||||||
|
|
||||||
|
The workspace is bind-mounted into the `app` container. `node --watch` picks
|
||||||
|
up source edits without a restart. HTML changes are delivered immediately
|
||||||
|
because `Cache-Control: no-cache` forces browser revalidation on every load.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Database bootstrap
|
## Database bootstrap
|
||||||
|
|
||||||
On a fresh MariaDB volume, Docker loads `sql/schema.sql` and `sql/seed.sql` automatically.
|
On a fresh MariaDB volume, Docker runs `sql/schema.sql` then `sql/seed.sql`
|
||||||
|
automatically (files are mounted at `/docker-entrypoint-initdb.d`).
|
||||||
|
|
||||||
If you already have an older local database volume, either recreate it with `docker compose down -v` or import the SQL files manually:
|
If you already have an older local volume, either recreate it:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec -T db sh -lc 'mariadb -uroot -p"$MARIADB_ROOT_PASSWORD" < /docker-entrypoint-initdb.d/schema.sql && mariadb -uroot -p"$MARIADB_ROOT_PASSWORD" < /docker-entrypoint-initdb.d/seed.sql'
|
docker compose down -v
|
||||||
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
or re-import manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T db sh -lc \
|
||||||
|
'mariadb -uroot -p"$MARIADB_ROOT_PASSWORD" < /docker-entrypoint-initdb.d/schema.sql && \
|
||||||
|
mariadb -uroot -p"$MARIADB_ROOT_PASSWORD" < /docker-entrypoint-initdb.d/seed.sql'
|
||||||
|
```
|
||||||
|
|
||||||
|
Seeded admin and user credentials are in `sql/seed.sql` — search for
|
||||||
|
`admin_credentials` and `admin_users`. **Change them before any non-local
|
||||||
|
use.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Validate the environment
|
## Validate the environment
|
||||||
|
|
||||||
Run the smoke test after the containers are up:
|
Once containers are running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run test:environment
|
npm run test:environment
|
||||||
```
|
```
|
||||||
|
|
||||||
The test verifies:
|
The smoke test checks:
|
||||||
- the API health endpoint at `/api/v1/health`
|
- API health endpoint (`/api/v1/health`)
|
||||||
- seeded template data via `/api/v1/templates`
|
- Seeded templates queryable via `/api/v1/templates`
|
||||||
- direct MariaDB connectivity
|
- Direct MariaDB connectivity
|
||||||
- phpMyAdmin availability
|
- phpMyAdmin availability
|
||||||
|
|
||||||
## Frontend PoC
|
---
|
||||||
|
|
||||||
Open `http://localhost:3000` after the stack is running.
|
## Notes on security posture
|
||||||
|
|
||||||
Entry points:
|
This is a proof of concept. The following are known gaps that must be
|
||||||
- `http://localhost:3000/` opens the chooser portal
|
addressed before any shared or production deployment:
|
||||||
- `http://localhost:3000/user` opens the user workspace directly
|
|
||||||
- `http://localhost:3000/admin` opens the administrator workspace directly
|
|
||||||
|
|
||||||
The frontend demonstrates:
|
| Gap | Risk | Mitigation |
|
||||||
- template sync from the API via batch endpoint
|
|---|---|---|
|
||||||
- offline cache via IndexedDB and service worker (LRU-bounded)
|
| Plain-text passwords | Credential theft via DB dump | Replace with bcrypt or argon2 |
|
||||||
- local report creation, switching, search, and filtering
|
| In-memory session store | All sessions lost on restart | Move to Redis or DB-backed sessions |
|
||||||
- dynamic form rendering based on the seeded template
|
| No CSRF protection | Cross-site request forgery | Add CSRF tokens or SameSite=Strict cookies |
|
||||||
- local attachment storage and preview with lazy-loading
|
| No rate limiting | Brute-force login attacks | Add express-rate-limit |
|
||||||
- Web Worker image optimization (OffscreenCanvas) with main-thread fallback
|
| Wide-open CORS | Any origin can call the API | Restrict `cors()` to known origins |
|
||||||
- debounced re-renders and dirty-flag autosave
|
| No account lockout | Unlimited login attempts | Add failed-attempt counter |
|
||||||
- report submission to backend
|
| No HTTPS enforcement | Token interception on plain HTTP | Terminate TLS at reverse proxy |
|
||||||
- CSV export for report data and individual attachment downloads
|
|
||||||
- administrator mode for editing image requirements with audit trail
|
|
||||||
- i18n-ready UI strings extracted to locale file
|
|
||||||
|
|
||||||
This frontend is intentionally lightweight and framework-free so the proof of concept stays easy to inspect and adapt. The monolithic app.js has been split into focused ES modules under `public/js/`, and the UI is served as two separate HTML shells (`user.html` for operators, `admin.html` for administrators) sharing a single `app.js` orchestrator with null-guarded element access.
|
|
||||||
|
|
||||||
## Administrator mode
|
|
||||||
|
|
||||||
Open `http://localhost:3000/admin` or use the chooser page at `http://localhost:3000/`.
|
|
||||||
|
|
||||||
The current PoC administrator flow supports:
|
|
||||||
- policy name changes
|
|
||||||
- allowed MIME types
|
|
||||||
- maximum file size
|
|
||||||
- maximum width and height
|
|
||||||
- JPEG quality
|
|
||||||
- oversize behavior
|
|
||||||
- maximum attachments per field
|
|
||||||
|
|
||||||
The frontend saves these values to the backend through `PUT /api/v1/config/image-rules` and refreshes the local cache after save. All admin mutations are recorded in the audit log.
|
|
||||||
|
|
||||||
## API endpoints
|
|
||||||
|
|
||||||
All endpoints are under the `/api/v1/` prefix.
|
|
||||||
|
|
||||||
### Service health
|
|
||||||
|
|
||||||
`GET /api/v1/health`
|
|
||||||
|
|
||||||
### Templates
|
|
||||||
|
|
||||||
- `GET /api/v1/templates` — list active templates
|
|
||||||
- `GET /api/v1/templates?include=definitions` — batch: all templates with definitions in one request
|
|
||||||
- `GET /api/v1/templates/:code` — single active template with definition
|
|
||||||
- `GET /api/v1/templates/:code/versions` — list all versions of a template
|
|
||||||
- `PUT /api/v1/templates/:code/versions/:versionNumber/publish` — publish a specific template version
|
|
||||||
|
|
||||||
### Lookups
|
|
||||||
|
|
||||||
- `GET /api/v1/lookups` — list all lookup sets
|
|
||||||
- `GET /api/v1/lookups/:code` — single lookup set with values
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
- `GET /api/v1/config/image-rules` — active image policy
|
|
||||||
- `PUT /api/v1/config/image-rules` — update image policy (audit-logged)
|
|
||||||
- `GET /api/v1/config/export` — export profile
|
|
||||||
- `GET /api/v1/config/app-config` — generic app configuration
|
|
||||||
|
|
||||||
### Reports
|
|
||||||
|
|
||||||
- `GET /api/v1/reports` — list submitted reports (supports `?status=`, `?templateCode=`, `?limit=`, `?offset=`)
|
|
||||||
- `GET /api/v1/reports/:reportId` — single submitted report
|
|
||||||
- `POST /api/v1/reports` — submit or update a report (UPSERT by UUID)
|
|
||||||
|
|
||||||
## Example response
|
|
||||||
|
|
||||||
`GET /api/v1/templates/incoming-inspection`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": "incoming-inspection",
|
|
||||||
"name": "Incoming Inspection Checklist",
|
|
||||||
"description": "PoC template for supplier or incoming goods quality inspection.",
|
|
||||||
"version": 1,
|
|
||||||
"status": "active",
|
|
||||||
"publishedAt": "2026-04-09T10:00:00.000Z",
|
|
||||||
"definition": {
|
|
||||||
"templateId": "incoming-inspection",
|
|
||||||
"templateName": "Incoming Inspection Checklist",
|
|
||||||
"version": 1,
|
|
||||||
"sections": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
# Netscape HTTP Cookie File
|
|
||||||
# https://curl.se/docs/http-cookies.html
|
|
||||||
# This file was generated by libcurl! Edit at your own risk.
|
|
||||||
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
PORT=3000
|
|
||||||
DB_HOST=127.0.0.1
|
|
||||||
DB_PORT=3306
|
|
||||||
DB_NAME=check_list
|
|
||||||
DB_USER=check_list_user
|
|
||||||
DB_PASSWORD=check_list_password
|
|
||||||
DB_CONNECTION_LIMIT=5
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
.env
|
|
||||||
dist/
|
|
||||||
@@ -1,724 +0,0 @@
|
|||||||
Project Document
|
|
||||||
Check List – Hybrid Initial Solution for Quality Check Reports
|
|
||||||
|
|
||||||
1. Document Information
|
|
||||||
|
|
||||||
Project Name: Check List
|
|
||||||
Document Type: Initial Project Documentation / Concept Specification
|
|
||||||
Version: 1.0
|
|
||||||
Date: 09 April 2026
|
|
||||||
Project Phase: Initial Implementation (Hybrid model between Option 2 and Option 3)
|
|
||||||
|
|
||||||
2. Executive Summary
|
|
||||||
|
|
||||||
The purpose of the Check List project is to replace the current Excel-based quality check reporting process with a modern digital solution that supports structured report creation, image attachments, offline work, and standardized export.
|
|
||||||
|
|
||||||
The proposed initial implementation will follow a hybrid approach between a fully local web solution and a server-connected application.
|
|
||||||
|
|
||||||
In this phase:
|
|
||||||
- Node.js and MariaDB available on the server side will be used to centrally manage:
|
|
||||||
- checklist templates,
|
|
||||||
- report structure,
|
|
||||||
- required fields,
|
|
||||||
- validation rules,
|
|
||||||
- lookup values,
|
|
||||||
- image processing rules,
|
|
||||||
- template versions.
|
|
||||||
- The client-side web application will be responsible for:
|
|
||||||
- loading and caching templates,
|
|
||||||
- creating reports offline,
|
|
||||||
- saving reports locally as drafts,
|
|
||||||
- switching between multiple reports,
|
|
||||||
- attaching and resizing images directly in the browser,
|
|
||||||
- renaming images automatically,
|
|
||||||
- generating the final output as a ZIP file containing:
|
|
||||||
- an Excel report file,
|
|
||||||
- a folder with attached images.
|
|
||||||
|
|
||||||
At this stage, the final reports will not yet be stored centrally in the database.
|
|
||||||
Instead, the database will serve as a template and configuration repository, while the final report remains a file-based deliverable.
|
|
||||||
|
|
||||||
This approach provides:
|
|
||||||
- a practical and low-risk first implementation,
|
|
||||||
- offline usability,
|
|
||||||
- centralized management of checklist definitions,
|
|
||||||
- and a clear migration path toward future server-side report synchronization.
|
|
||||||
|
|
||||||
3. Project Background
|
|
||||||
|
|
||||||
The current reporting process is based on Excel files used to create quality check lists and reports. This creates a number of operational and technical limitations, including:
|
|
||||||
- manual preparation of reports,
|
|
||||||
- inconsistent structure between reports,
|
|
||||||
- difficulty enforcing required fields,
|
|
||||||
- inefficient handling of images,
|
|
||||||
- lack of standardized image naming,
|
|
||||||
- dependency on Excel files and manual file handling,
|
|
||||||
- limited support for mobile devices,
|
|
||||||
- no controlled offline workflow,
|
|
||||||
- difficult future integration with central systems.
|
|
||||||
|
|
||||||
The new solution is intended to improve data consistency, user convenience, and long-term maintainability by introducing a controlled digital workflow.
|
|
||||||
|
|
||||||
4. Project Objective
|
|
||||||
|
|
||||||
The objective of the project is to implement a digital reporting solution that replaces Excel-based checklist reporting with a structured web application that works across platforms, supports offline work, and produces a standardized ZIP-based report package.
|
|
||||||
|
|
||||||
The initial version of the solution must:
|
|
||||||
1. allow users to create quality check reports in a web application,
|
|
||||||
2. support Windows, Android, and iOS devices,
|
|
||||||
3. allow use without permanent internet access,
|
|
||||||
4. provide centrally managed checklist templates from the server,
|
|
||||||
5. allow users to save reports as drafts and continue them later,
|
|
||||||
6. allow switching between multiple reports,
|
|
||||||
7. support image attachment with automatic resizing/compression in the browser,
|
|
||||||
8. automatically rename images based on report naming rules,
|
|
||||||
9. generate the final report as a ZIP package with Excel file and images,
|
|
||||||
10. prepare the solution architecture for future server-side report synchronization.
|
|
||||||
|
|
||||||
5. Scope of the Initial Implementation
|
|
||||||
|
|
||||||
5.1 In Scope
|
|
||||||
|
|
||||||
The following functions are included in the initial project scope:
|
|
||||||
|
|
||||||
Server-side scope
|
|
||||||
- central storage of checklist templates,
|
|
||||||
- central storage of required field definitions,
|
|
||||||
- central storage of validation rules,
|
|
||||||
- central storage of lookup values / dropdown lists,
|
|
||||||
- central storage of image handling rules,
|
|
||||||
- template version management,
|
|
||||||
- API for providing templates and configuration to the web application.
|
|
||||||
|
|
||||||
Client-side scope
|
|
||||||
- responsive web application,
|
|
||||||
- dynamic rendering of checklists based on templates,
|
|
||||||
- local storage of report drafts,
|
|
||||||
- local storage of image attachments,
|
|
||||||
- report editing in offline mode,
|
|
||||||
- report auto-save,
|
|
||||||
- support for multiple local reports,
|
|
||||||
- switching between reports within the application,
|
|
||||||
- browser-based image resizing/compression,
|
|
||||||
- automatic image renaming,
|
|
||||||
- local ZIP file generation,
|
|
||||||
- local Excel file generation,
|
|
||||||
- local export of finished reports.
|
|
||||||
|
|
||||||
5.2 Out of Scope (Initial Phase)
|
|
||||||
|
|
||||||
The following items are intentionally excluded from the first implementation phase:
|
|
||||||
- central storage of completed reports in the database,
|
|
||||||
- full backend synchronization of report data,
|
|
||||||
- server-side image upload for completed reports,
|
|
||||||
- conflict handling between multiple users/devices,
|
|
||||||
- server-side draft storage,
|
|
||||||
- advanced reporting/analytics dashboards,
|
|
||||||
- workflow approvals,
|
|
||||||
- ERP/QMS integrations,
|
|
||||||
- embedding images directly inside Excel (unless later required),
|
|
||||||
- native Android/iOS applications.
|
|
||||||
|
|
||||||
These items can be considered in later project phases.
|
|
||||||
|
|
||||||
6. Proposed Solution Overview
|
|
||||||
|
|
||||||
The recommended initial solution is a hybrid offline-first web application.
|
|
||||||
|
|
||||||
Core principle:
|
|
||||||
- Templates and configuration are centrally managed on the server
|
|
||||||
- Reports are created and stored locally in the browser
|
|
||||||
- Final output is exported as ZIP
|
|
||||||
|
|
||||||
This model combines the most important business and technical benefits:
|
|
||||||
- centralized control over checklist content,
|
|
||||||
- simpler initial implementation than full synchronization,
|
|
||||||
- full support for offline report creation,
|
|
||||||
- broad device compatibility,
|
|
||||||
- structured handling of images,
|
|
||||||
- future readiness for backend expansion.
|
|
||||||
|
|
||||||
7. Business Benefits
|
|
||||||
|
|
||||||
The proposed solution provides the following business benefits:
|
|
||||||
|
|
||||||
7.1 Standardization
|
|
||||||
- consistent report structure,
|
|
||||||
- consistent validation rules,
|
|
||||||
- controlled mandatory fields,
|
|
||||||
- unified image naming convention.
|
|
||||||
|
|
||||||
7.2 Improved usability
|
|
||||||
- easier report creation compared to manual Excel editing,
|
|
||||||
- better experience on mobile devices,
|
|
||||||
- faster attachment handling,
|
|
||||||
- draft-based workflow.
|
|
||||||
|
|
||||||
7.3 Offline capability
|
|
||||||
- users can continue work even without internet access,
|
|
||||||
- templates can be loaded earlier and used later offline.
|
|
||||||
|
|
||||||
7.4 Central governance
|
|
||||||
- checklist templates are managed from one place,
|
|
||||||
- changes in required fields do not require front-end redesign,
|
|
||||||
- image limitations are centrally configurable.
|
|
||||||
|
|
||||||
7.5 Lower implementation risk
|
|
||||||
- final reports remain file-based in the first phase,
|
|
||||||
- backend complexity is limited,
|
|
||||||
- architecture remains ready for later expansion.
|
|
||||||
|
|
||||||
8. User Groups
|
|
||||||
|
|
||||||
The initial solution is intended for users involved in quality check and inspection reporting, for example:
|
|
||||||
- quality inspectors,
|
|
||||||
- production or warehouse staff performing checks,
|
|
||||||
- incoming inspection personnel,
|
|
||||||
- mobile users working on shop floor or at supplier/customer location,
|
|
||||||
- administrators responsible for checklist configuration.
|
|
||||||
|
|
||||||
9. Functional Requirements
|
|
||||||
|
|
||||||
9.1 Template Management
|
|
||||||
|
|
||||||
Checklist templates must be centrally stored on the server and provided to the web application through an API.
|
|
||||||
|
|
||||||
Each template should be able to define:
|
|
||||||
- template identifier,
|
|
||||||
- template name,
|
|
||||||
- version,
|
|
||||||
- sections/groups,
|
|
||||||
- field labels,
|
|
||||||
- field types,
|
|
||||||
- required fields,
|
|
||||||
- default values,
|
|
||||||
- validation rules,
|
|
||||||
- optional comments,
|
|
||||||
- image requirements,
|
|
||||||
- export metadata.
|
|
||||||
|
|
||||||
The application must support the use of different template versions.
|
|
||||||
|
|
||||||
Template versioning rule
|
|
||||||
- Existing draft reports must remain bound to the template version under which they were created.
|
|
||||||
- New reports should use the newest active version of the selected template.
|
|
||||||
|
|
||||||
9.2 Report Creation
|
|
||||||
|
|
||||||
The user must be able to create a new report based on a selected checklist template.
|
|
||||||
|
|
||||||
A report should include:
|
|
||||||
- report header information,
|
|
||||||
- checklist items,
|
|
||||||
- comments,
|
|
||||||
- attachments,
|
|
||||||
- report status,
|
|
||||||
- metadata (created date, updated date, template version, etc.).
|
|
||||||
|
|
||||||
Possible field types include:
|
|
||||||
- text,
|
|
||||||
- number,
|
|
||||||
- date,
|
|
||||||
- checkbox / boolean,
|
|
||||||
- dropdown / lookup list,
|
|
||||||
- pass/fail choice,
|
|
||||||
- comments,
|
|
||||||
- attachment-required items.
|
|
||||||
|
|
||||||
9.3 Draft Save and Continue Later
|
|
||||||
|
|
||||||
The application must allow the user to save incomplete reports locally as drafts.
|
|
||||||
|
|
||||||
The user must be able to:
|
|
||||||
- save a report as draft,
|
|
||||||
- leave the report,
|
|
||||||
- return later,
|
|
||||||
- continue editing from the last saved state.
|
|
||||||
|
|
||||||
Draft saving should occur:
|
|
||||||
- manually via a save function,
|
|
||||||
- automatically during editing,
|
|
||||||
- when switching between reports,
|
|
||||||
- when adding/removing attachments.
|
|
||||||
|
|
||||||
Draft reports must remain accessible from a report list/dashboard.
|
|
||||||
|
|
||||||
9.4 Switching Between Reports
|
|
||||||
|
|
||||||
The user must be able to work with multiple reports within the same application.
|
|
||||||
|
|
||||||
The application must provide a report dashboard or report manager where the user can:
|
|
||||||
- see all locally stored reports,
|
|
||||||
- filter reports by status,
|
|
||||||
- open a selected report,
|
|
||||||
- continue editing,
|
|
||||||
- duplicate a report,
|
|
||||||
- archive or delete a report,
|
|
||||||
- export a completed report.
|
|
||||||
|
|
||||||
When switching from one report to another:
|
|
||||||
- the current report must be saved automatically,
|
|
||||||
- the selected report must be loaded from local storage.
|
|
||||||
|
|
||||||
9.5 Report Statuses
|
|
||||||
|
|
||||||
The system should support at least the following statuses:
|
|
||||||
- Draft
|
|
||||||
- In Progress
|
|
||||||
- Ready for Export
|
|
||||||
- Exported
|
|
||||||
- Archived
|
|
||||||
|
|
||||||
These statuses should be visible in the report list.
|
|
||||||
|
|
||||||
Future phases may introduce additional synchronization-related statuses, but they are not required in the initial version.
|
|
||||||
|
|
||||||
9.6 Offline Mode
|
|
||||||
|
|
||||||
The application must support offline work after templates and configuration have been loaded.
|
|
||||||
|
|
||||||
Offline mode must allow:
|
|
||||||
- opening cached templates,
|
|
||||||
- creating new reports,
|
|
||||||
- editing existing drafts,
|
|
||||||
- attaching images,
|
|
||||||
- saving report progress locally,
|
|
||||||
- generating ZIP output locally.
|
|
||||||
|
|
||||||
If internet access is unavailable, the application should continue using the latest cached template data.
|
|
||||||
|
|
||||||
9.7 Image Attachment Handling
|
|
||||||
|
|
||||||
The system must support attaching images to reports.
|
|
||||||
|
|
||||||
Images may be added from:
|
|
||||||
- file picker,
|
|
||||||
- mobile device gallery,
|
|
||||||
- mobile camera (if browser/device supports direct capture).
|
|
||||||
|
|
||||||
The application must validate image files against centrally defined rules such as:
|
|
||||||
- allowed file types,
|
|
||||||
- maximum file size,
|
|
||||||
- maximum dimensions,
|
|
||||||
- output quality settings.
|
|
||||||
|
|
||||||
9.8 Browser-Based Image Resizing
|
|
||||||
|
|
||||||
Image optimization must be performed directly in the web browser on the client side.
|
|
||||||
|
|
||||||
When an image is attached, the application must:
|
|
||||||
1. read the image locally,
|
|
||||||
2. validate file type, size, and dimensions,
|
|
||||||
3. resize/compress the image if it exceeds configured limits,
|
|
||||||
4. store the optimized version locally,
|
|
||||||
5. show a preview,
|
|
||||||
6. associate the optimized image with the current report.
|
|
||||||
|
|
||||||
This functionality is required to:
|
|
||||||
- reduce storage usage,
|
|
||||||
- standardize attachment size,
|
|
||||||
- improve performance,
|
|
||||||
- maintain offline capability,
|
|
||||||
- prepare for future upload optimization.
|
|
||||||
|
|
||||||
The system should support either:
|
|
||||||
- automatic resizing,
|
|
||||||
- warning + resizing,
|
|
||||||
- or blocking oversized files,
|
|
||||||
|
|
||||||
depending on centrally configured rules.
|
|
||||||
|
|
||||||
For the initial implementation, the recommended behavior is:
|
|
||||||
- automatic optimization with user notification.
|
|
||||||
|
|
||||||
9.9 Automatic Image Renaming
|
|
||||||
|
|
||||||
The application must automatically rename attached image files according to a structured naming convention.
|
|
||||||
|
|
||||||
The naming convention should be centrally configurable and may include:
|
|
||||||
- report number,
|
|
||||||
- section code,
|
|
||||||
- sequence number,
|
|
||||||
- date or other metadata if required.
|
|
||||||
|
|
||||||
Example naming structure:
|
|
||||||
<ReportNumber>_<SectionCode>_<Sequence>.jpg
|
|
||||||
|
|
||||||
Both the original filename and generated filename should be tracked in metadata.
|
|
||||||
|
|
||||||
9.10 Validation Rules
|
|
||||||
|
|
||||||
The application must support validation based on centrally managed rules.
|
|
||||||
|
|
||||||
Validation may include:
|
|
||||||
- required fields,
|
|
||||||
- allowed values,
|
|
||||||
- numeric ranges,
|
|
||||||
- image required for selected checklist item,
|
|
||||||
- minimum/maximum number of attachments,
|
|
||||||
- checklist completeness.
|
|
||||||
|
|
||||||
Validation must distinguish between:
|
|
||||||
- draft save (allowed even if incomplete),
|
|
||||||
- ready for export (only allowed if validation passes).
|
|
||||||
|
|
||||||
9.11 Export to ZIP
|
|
||||||
|
|
||||||
The final report must be generated locally as a ZIP archive.
|
|
||||||
|
|
||||||
The ZIP file should contain:
|
|
||||||
- one Excel file,
|
|
||||||
- one image folder containing all attached images.
|
|
||||||
|
|
||||||
Recommended ZIP structure
|
|
||||||
<ReportNumber>.zip
|
|
||||||
├── <ReportNumber>.xlsx
|
|
||||||
└── images/
|
|
||||||
├── <image1>.jpg
|
|
||||||
├── <image2>.jpg
|
|
||||||
└── ...
|
|
||||||
|
|
||||||
The Excel file must include:
|
|
||||||
- report header,
|
|
||||||
- checklist answers,
|
|
||||||
- comments,
|
|
||||||
- image file references,
|
|
||||||
- template version,
|
|
||||||
- export timestamp.
|
|
||||||
|
|
||||||
For the first version, it is recommended that:
|
|
||||||
- images remain as separate files in the ZIP package,
|
|
||||||
- images are not embedded into the Excel file unless explicitly required later.
|
|
||||||
|
|
||||||
10. Non-Functional Requirements
|
|
||||||
|
|
||||||
10.1 Platform Compatibility
|
|
||||||
The application must operate on:
|
|
||||||
- Windows browsers,
|
|
||||||
- Android browsers,
|
|
||||||
- iOS browsers.
|
|
||||||
|
|
||||||
A responsive layout is required.
|
|
||||||
|
|
||||||
10.2 Performance
|
|
||||||
The solution should perform adequately on standard office devices and mobile devices.
|
|
||||||
|
|
||||||
Special attention should be given to:
|
|
||||||
- image processing time,
|
|
||||||
- local storage performance,
|
|
||||||
- report loading speed,
|
|
||||||
- ZIP generation performance.
|
|
||||||
|
|
||||||
10.3 Usability
|
|
||||||
The interface should be simple and optimized for operational use.
|
|
||||||
|
|
||||||
The system should:
|
|
||||||
- minimize manual steps,
|
|
||||||
- provide visible save status,
|
|
||||||
- provide clear validation messages,
|
|
||||||
- support touch-based interaction,
|
|
||||||
- reduce risk of data loss.
|
|
||||||
|
|
||||||
10.4 Maintainability
|
|
||||||
The solution should be modular and designed for future expansion.
|
|
||||||
|
|
||||||
The frontend should separate:
|
|
||||||
- UI logic,
|
|
||||||
- template rendering,
|
|
||||||
- local data storage,
|
|
||||||
- image processing,
|
|
||||||
- export logic,
|
|
||||||
- future synchronization logic.
|
|
||||||
|
|
||||||
10.5 Data Integrity
|
|
||||||
The system must:
|
|
||||||
- auto-save regularly,
|
|
||||||
- preserve locally stored reports,
|
|
||||||
- bind reports to template versions,
|
|
||||||
- avoid corruption caused by later template changes.
|
|
||||||
|
|
||||||
10.6 Security
|
|
||||||
In the initial phase, security requirements may be lighter than in a fully centralized solution, but should still include:
|
|
||||||
- secure API communication when online,
|
|
||||||
- controlled access to template management,
|
|
||||||
- safe handling of local report data,
|
|
||||||
- user/session identification if introduced.
|
|
||||||
|
|
||||||
11. Technical Architecture
|
|
||||||
|
|
||||||
11.1 General Architecture
|
|
||||||
|
|
||||||
The recommended architecture for the initial implementation is:
|
|
||||||
|
|
||||||
Server Side
|
|
||||||
- Node.js application
|
|
||||||
- MariaDB database
|
|
||||||
- REST API for templates and configuration
|
|
||||||
|
|
||||||
Client Side
|
|
||||||
- browser-based web application
|
|
||||||
- local storage for reports and attachments
|
|
||||||
- local report generation and export
|
|
||||||
|
|
||||||
11.2 Server Responsibilities
|
|
||||||
|
|
||||||
The server will manage:
|
|
||||||
- checklist templates,
|
|
||||||
- template versions,
|
|
||||||
- field definitions,
|
|
||||||
- required field rules,
|
|
||||||
- lookup data,
|
|
||||||
- image processing rules,
|
|
||||||
- configuration data.
|
|
||||||
|
|
||||||
At this stage, the server will not store final report data.
|
|
||||||
|
|
||||||
11.3 Client Responsibilities
|
|
||||||
|
|
||||||
The client application will manage:
|
|
||||||
- template download and caching,
|
|
||||||
- form rendering,
|
|
||||||
- local report editing,
|
|
||||||
- draft storage,
|
|
||||||
- image resizing,
|
|
||||||
- image previews,
|
|
||||||
- automatic renaming,
|
|
||||||
- Excel generation,
|
|
||||||
- ZIP generation.
|
|
||||||
|
|
||||||
12. Recommended Technology Stack
|
|
||||||
|
|
||||||
12.1 Frontend
|
|
||||||
Recommended:
|
|
||||||
- React for the web application,
|
|
||||||
- responsive UI framework or custom responsive design,
|
|
||||||
- IndexedDB for local offline storage,
|
|
||||||
- Excel generation library,
|
|
||||||
- ZIP generation library,
|
|
||||||
- browser Canvas API (or equivalent browser-side image processing approach).
|
|
||||||
|
|
||||||
12.2 Backend
|
|
||||||
Recommended:
|
|
||||||
- Node.js
|
|
||||||
- REST API architecture
|
|
||||||
|
|
||||||
12.3 Database
|
|
||||||
Recommended:
|
|
||||||
- MariaDB
|
|
||||||
|
|
||||||
13. Data Storage Concept
|
|
||||||
|
|
||||||
13.1 Server-side Data
|
|
||||||
Stored in MariaDB:
|
|
||||||
- templates,
|
|
||||||
- template sections,
|
|
||||||
- template fields,
|
|
||||||
- validation rules,
|
|
||||||
- lookups,
|
|
||||||
- image settings,
|
|
||||||
- export settings,
|
|
||||||
- template version data.
|
|
||||||
|
|
||||||
13.2 Client-side Data
|
|
||||||
Stored locally in the browser:
|
|
||||||
- downloaded templates (cached),
|
|
||||||
- draft reports,
|
|
||||||
- report metadata,
|
|
||||||
- checklist answers,
|
|
||||||
- attachment metadata,
|
|
||||||
- optimized image files.
|
|
||||||
|
|
||||||
Recommended local storage technology
|
|
||||||
Use IndexedDB rather than basic browser local storage.
|
|
||||||
|
|
||||||
Reason:
|
|
||||||
- better support for structured data,
|
|
||||||
- support for binary image data,
|
|
||||||
- better capacity and performance,
|
|
||||||
- better fit for multiple drafts and attachments.
|
|
||||||
|
|
||||||
14. Suggested Logical Data Model
|
|
||||||
|
|
||||||
14.1 Template-related entities (server side)
|
|
||||||
Possible logical entities include:
|
|
||||||
- Templates
|
|
||||||
- Template Sections
|
|
||||||
- Template Fields
|
|
||||||
- Lookup Values
|
|
||||||
- Template Settings
|
|
||||||
- Template Versions
|
|
||||||
|
|
||||||
These should support dynamic form rendering.
|
|
||||||
|
|
||||||
14.2 Report-related entities (client side)
|
|
||||||
Each local report should contain at least:
|
|
||||||
- local report ID,
|
|
||||||
- report number or temporary identifier,
|
|
||||||
- template ID,
|
|
||||||
- template version,
|
|
||||||
- report status,
|
|
||||||
- header data,
|
|
||||||
- checklist values,
|
|
||||||
- comments,
|
|
||||||
- attachment metadata,
|
|
||||||
- created date,
|
|
||||||
- last update date.
|
|
||||||
|
|
||||||
Each attachment should contain:
|
|
||||||
- internal attachment ID,
|
|
||||||
- original filename,
|
|
||||||
- generated filename,
|
|
||||||
- MIME type,
|
|
||||||
- file size,
|
|
||||||
- dimensions,
|
|
||||||
- relation to report and field/section.
|
|
||||||
|
|
||||||
15. API Scope for Initial Phase
|
|
||||||
|
|
||||||
The initial backend API can be intentionally limited.
|
|
||||||
|
|
||||||
Suggested API scope:
|
|
||||||
- get available templates,
|
|
||||||
- get template details,
|
|
||||||
- get template version,
|
|
||||||
- get lookup data,
|
|
||||||
- get image/configuration settings,
|
|
||||||
- optional application version/configuration endpoint,
|
|
||||||
- optional authentication endpoints.
|
|
||||||
|
|
||||||
Report upload API is not required in the initial phase.
|
|
||||||
|
|
||||||
16. User Workflow
|
|
||||||
|
|
||||||
16.1 Online startup scenario
|
|
||||||
1. User opens the application.
|
|
||||||
2. Application loads latest templates and configuration from the server.
|
|
||||||
3. Templates are cached locally.
|
|
||||||
4. User can start creating reports.
|
|
||||||
|
|
||||||
16.2 Offline work scenario
|
|
||||||
1. User opens the application without internet access.
|
|
||||||
2. Application uses last cached templates.
|
|
||||||
3. User creates or continues reports.
|
|
||||||
4. Images are processed locally.
|
|
||||||
5. Drafts are saved locally.
|
|
||||||
6. User exports a ZIP file when the report is finished.
|
|
||||||
|
|
||||||
16.3 Report editing scenario
|
|
||||||
1. User creates or opens a report.
|
|
||||||
2. User fills checklist fields.
|
|
||||||
3. User adds comments and images.
|
|
||||||
4. Application auto-saves progress.
|
|
||||||
5. User may leave report as draft or continue later.
|
|
||||||
6. User marks report as ready.
|
|
||||||
7. Application validates data.
|
|
||||||
8. User exports ZIP file.
|
|
||||||
|
|
||||||
17. Risks and Constraints
|
|
||||||
|
|
||||||
17.1 Local Storage Dependency
|
|
||||||
Because reports are stored locally in the first phase, there is a dependency on browser/device storage.
|
|
||||||
|
|
||||||
Risk:
|
|
||||||
- local drafts may be lost if storage is cleared.
|
|
||||||
|
|
||||||
Mitigation:
|
|
||||||
- auto-save,
|
|
||||||
- visible draft management,
|
|
||||||
- encourage timely export/archiving,
|
|
||||||
- consider backup/import in later phase.
|
|
||||||
|
|
||||||
17.2 Browser Storage Limits
|
|
||||||
Images and multiple drafts can consume significant space.
|
|
||||||
|
|
||||||
Mitigation:
|
|
||||||
- automatic image compression,
|
|
||||||
- optimized image limits,
|
|
||||||
- archive/delete completed reports,
|
|
||||||
- monitor practical storage capacity during pilot.
|
|
||||||
|
|
||||||
17.3 Template Change During Active Reports
|
|
||||||
Changing a template could affect reports already started.
|
|
||||||
|
|
||||||
Mitigation:
|
|
||||||
- enforce template versioning,
|
|
||||||
- bind each report to the template version it started with.
|
|
||||||
|
|
||||||
17.4 No Central Report Repository Yet
|
|
||||||
The initial phase does not yet provide central report storage or reporting history.
|
|
||||||
|
|
||||||
Mitigation:
|
|
||||||
- keep ZIP export as the official output,
|
|
||||||
- plan future extension to server-side synchronization.
|
|
||||||
|
|
||||||
18. Future Expansion Path
|
|
||||||
|
|
||||||
The proposed architecture is intentionally designed to support future transition toward a more advanced server-connected model.
|
|
||||||
|
|
||||||
Future phases may include:
|
|
||||||
- upload of completed reports to backend,
|
|
||||||
- server-side report repository,
|
|
||||||
- attachment upload,
|
|
||||||
- synchronization of offline-created reports,
|
|
||||||
- user roles and permissions,
|
|
||||||
- dashboards and analytics,
|
|
||||||
- integration with other systems,
|
|
||||||
- central audit trail.
|
|
||||||
|
|
||||||
Because templates are already server-managed in the initial phase, future expansion should require fewer architectural changes.
|
|
||||||
|
|
||||||
19. Recommended Implementation Phases
|
|
||||||
|
|
||||||
Phase 1 – Hybrid Initial Solution
|
|
||||||
- template/configuration management on server,
|
|
||||||
- local report creation,
|
|
||||||
- offline-capable report editing,
|
|
||||||
- draft save and continue later,
|
|
||||||
- switching between multiple reports,
|
|
||||||
- browser-side image resizing,
|
|
||||||
- automatic image renaming,
|
|
||||||
- ZIP export.
|
|
||||||
|
|
||||||
Phase 2 – Extended Hybrid
|
|
||||||
- optional user authentication,
|
|
||||||
- optional upload of ZIP packages,
|
|
||||||
- central administration UI for templates,
|
|
||||||
- export history,
|
|
||||||
- advanced template/version control.
|
|
||||||
|
|
||||||
Phase 3 – Full Server-Based Reporting
|
|
||||||
- full report synchronization,
|
|
||||||
- central report database,
|
|
||||||
- attachment upload,
|
|
||||||
- online/offline sync handling,
|
|
||||||
- reporting dashboard and analytics.
|
|
||||||
|
|
||||||
20. Final Recommendation
|
|
||||||
|
|
||||||
The recommended starting point for the Check List project is a hybrid initial solution in which:
|
|
||||||
- Node.js + MariaDB are used from the beginning for central checklist/template management,
|
|
||||||
- the web application works across Windows, Android, and iOS,
|
|
||||||
- the application supports offline work after templates are loaded,
|
|
||||||
- reports are stored locally as drafts,
|
|
||||||
- the user can switch between reports and continue later,
|
|
||||||
- images are processed directly in the browser,
|
|
||||||
- and the final report is exported as a ZIP package with Excel and images.
|
|
||||||
|
|
||||||
This approach provides the best balance between:
|
|
||||||
- implementation speed,
|
|
||||||
- operational usefulness,
|
|
||||||
- centralized control,
|
|
||||||
- offline support,
|
|
||||||
- and future scalability.
|
|
||||||
|
|
||||||
21. Proposed Next Step
|
|
||||||
|
|
||||||
The next recommended activity is to prepare a detailed implementation specification covering:
|
|
||||||
1. screen-by-screen user flow,
|
|
||||||
2. template JSON structure,
|
|
||||||
3. local report data model,
|
|
||||||
4. API endpoint specification,
|
|
||||||
5. MariaDB schema proposal,
|
|
||||||
6. image resizing rules,
|
|
||||||
7. ZIP/Excel export format,
|
|
||||||
8. development backlog / user stories.
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
# Check List Initial Solution Proposal
|
|
||||||
|
|
||||||
## 1. Recommended Starting Point
|
|
||||||
|
|
||||||
The strongest initial implementation is an offline-first web application with a thin server-backed configuration layer.
|
|
||||||
|
|
||||||
This matches the source document's core constraint set:
|
|
||||||
- reports must work without permanent internet access,
|
|
||||||
- templates must be centrally controlled,
|
|
||||||
- final output remains file-based,
|
|
||||||
- the architecture must be extendable toward future synchronization.
|
|
||||||
|
|
||||||
## 2. Proposed Architecture
|
|
||||||
|
|
||||||
### Client application
|
|
||||||
|
|
||||||
Build a responsive Progressive Web App using:
|
|
||||||
- React
|
|
||||||
- TypeScript
|
|
||||||
- IndexedDB for offline persistence
|
|
||||||
- a service worker for application asset caching
|
|
||||||
- browser-based image processing using Canvas or createImageBitmap
|
|
||||||
- XLSX generation library
|
|
||||||
- ZIP generation library
|
|
||||||
|
|
||||||
Main client modules:
|
|
||||||
- template cache module
|
|
||||||
- dynamic form renderer
|
|
||||||
- report draft repository
|
|
||||||
- image processing pipeline
|
|
||||||
- validation engine
|
|
||||||
- Excel export mapper
|
|
||||||
- ZIP packaging module
|
|
||||||
- sync-ready integration layer placeholder
|
|
||||||
|
|
||||||
### Server application
|
|
||||||
|
|
||||||
Build a small REST API using:
|
|
||||||
- Node.js
|
|
||||||
- Express or Fastify
|
|
||||||
- MariaDB
|
|
||||||
|
|
||||||
Main server responsibilities:
|
|
||||||
- template CRUD
|
|
||||||
- template versioning
|
|
||||||
- lookup management
|
|
||||||
- validation rule delivery
|
|
||||||
- image rule delivery
|
|
||||||
- export configuration delivery
|
|
||||||
- optional authentication later
|
|
||||||
|
|
||||||
The server should not accept completed reports in phase 1.
|
|
||||||
|
|
||||||
## 3. Why This Design Fits
|
|
||||||
|
|
||||||
This design solves the main business problems without introducing early complexity:
|
|
||||||
- central governance is handled by the backend,
|
|
||||||
- offline draft work is handled entirely on the client,
|
|
||||||
- images are optimized before storage/export,
|
|
||||||
- ZIP export preserves compatibility with the current file-based process,
|
|
||||||
- future synchronization can be added without replacing the client-side report model.
|
|
||||||
|
|
||||||
## 4. Initial Technical Blueprint
|
|
||||||
|
|
||||||
### Frontend structure
|
|
||||||
|
|
||||||
Suggested feature-oriented structure:
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/
|
|
||||||
app/
|
|
||||||
features/templates/
|
|
||||||
features/reports/
|
|
||||||
features/images/
|
|
||||||
features/export/
|
|
||||||
features/validation/
|
|
||||||
features/dashboard/
|
|
||||||
shared/ui/
|
|
||||||
shared/lib/
|
|
||||||
shared/storage/
|
|
||||||
shared/api/
|
|
||||||
```
|
|
||||||
|
|
||||||
Key implementation decisions:
|
|
||||||
- use template-driven rendering instead of hardcoded forms,
|
|
||||||
- keep report state normalized by report ID,
|
|
||||||
- store attachments separately from answer objects in IndexedDB,
|
|
||||||
- version templates immutably once published,
|
|
||||||
- separate draft validation from export validation.
|
|
||||||
|
|
||||||
### Backend structure
|
|
||||||
|
|
||||||
Suggested backend domains:
|
|
||||||
- templates
|
|
||||||
- templateVersions
|
|
||||||
- fields
|
|
||||||
- lookups
|
|
||||||
- validationRules
|
|
||||||
- imageRules
|
|
||||||
- exportSettings
|
|
||||||
|
|
||||||
Recommended principle:
|
|
||||||
the API should deliver a fully materialized template definition for a chosen version so the client can render forms without additional server joins during report editing.
|
|
||||||
|
|
||||||
## 5. Minimal Phase 1 Data Model
|
|
||||||
|
|
||||||
### Server-side entities
|
|
||||||
|
|
||||||
Recommended MariaDB tables:
|
|
||||||
- templates
|
|
||||||
- template_versions
|
|
||||||
- template_sections
|
|
||||||
- template_fields
|
|
||||||
- field_validation_rules
|
|
||||||
- lookup_sets
|
|
||||||
- lookup_values
|
|
||||||
- image_rules
|
|
||||||
- export_profiles
|
|
||||||
|
|
||||||
Important rules:
|
|
||||||
- only one active version per template for new report creation,
|
|
||||||
- old versions remain readable for existing drafts,
|
|
||||||
- template JSON snapshots can be generated and cached for fast client download.
|
|
||||||
|
|
||||||
### Client-side entities
|
|
||||||
|
|
||||||
Recommended IndexedDB stores:
|
|
||||||
- templatesCache
|
|
||||||
- reports
|
|
||||||
- reportAnswers
|
|
||||||
- attachments
|
|
||||||
- appConfig
|
|
||||||
|
|
||||||
Example report aggregate:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "local-report-uuid",
|
|
||||||
"reportNumber": "QC-2026-0001",
|
|
||||||
"templateId": "incoming-inspection",
|
|
||||||
"templateVersion": 3,
|
|
||||||
"status": "in_progress",
|
|
||||||
"header": {
|
|
||||||
"supplier": "ACME",
|
|
||||||
"inspectionDate": "2026-04-09"
|
|
||||||
},
|
|
||||||
"createdAt": "2026-04-09T09:00:00Z",
|
|
||||||
"updatedAt": "2026-04-09T10:15:00Z"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Example attachment metadata:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "attachment-uuid",
|
|
||||||
"reportId": "local-report-uuid",
|
|
||||||
"fieldId": "damage-photo",
|
|
||||||
"originalFilename": "IMG_0412.jpg",
|
|
||||||
"generatedFilename": "QC-2026-0001_SEC01_001.jpg",
|
|
||||||
"mimeType": "image/jpeg",
|
|
||||||
"width": 1600,
|
|
||||||
"height": 1200,
|
|
||||||
"sizeBytes": 245312
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Recommended API Surface
|
|
||||||
|
|
||||||
Initial REST endpoints:
|
|
||||||
- `GET /api/templates`
|
|
||||||
- `GET /api/templates/:templateId`
|
|
||||||
- `GET /api/templates/:templateId/versions/:version`
|
|
||||||
- `GET /api/lookups`
|
|
||||||
- `GET /api/config/image-rules`
|
|
||||||
- `GET /api/config/export`
|
|
||||||
- `GET /api/app-config`
|
|
||||||
|
|
||||||
Optional later endpoints:
|
|
||||||
- `POST /api/auth/login`
|
|
||||||
- `POST /api/report-uploads`
|
|
||||||
|
|
||||||
API response strategy:
|
|
||||||
- return explicit version metadata with every template,
|
|
||||||
- include cache timestamps or ETags,
|
|
||||||
- support incremental refresh later if template volume grows.
|
|
||||||
|
|
||||||
## 7. Core User Flow
|
|
||||||
|
|
||||||
Phase 1 flow should be:
|
|
||||||
1. User opens the app online.
|
|
||||||
2. App downloads active templates and configuration.
|
|
||||||
3. Data is cached locally.
|
|
||||||
4. User creates or resumes a report.
|
|
||||||
5. Report is auto-saved into IndexedDB.
|
|
||||||
6. Images are validated, optimized, renamed, and stored locally.
|
|
||||||
7. User marks the report ready for export.
|
|
||||||
8. Full validation runs.
|
|
||||||
9. App generates XLSX.
|
|
||||||
10. App packages XLSX and images into a ZIP and downloads it locally.
|
|
||||||
|
|
||||||
## 8. Delivery Plan
|
|
||||||
|
|
||||||
### Iteration 1
|
|
||||||
- define template JSON contract,
|
|
||||||
- define MariaDB schema,
|
|
||||||
- implement template read API,
|
|
||||||
- build application shell and offline cache,
|
|
||||||
- build report dashboard and draft persistence.
|
|
||||||
|
|
||||||
### Iteration 2
|
|
||||||
- implement dynamic checklist renderer,
|
|
||||||
- implement draft save and resume,
|
|
||||||
- implement report switching and status management,
|
|
||||||
- implement export-readiness validation.
|
|
||||||
|
|
||||||
### Iteration 3
|
|
||||||
- implement image attach, preview, compression, and renaming,
|
|
||||||
- implement XLSX mapping,
|
|
||||||
- implement ZIP export,
|
|
||||||
- run pilot tests on Windows, Android, and iOS.
|
|
||||||
|
|
||||||
### Iteration 4
|
|
||||||
- harden error handling,
|
|
||||||
- tune storage usage,
|
|
||||||
- add admin-facing template maintenance workflow,
|
|
||||||
- prepare phase 2 synchronization extension points.
|
|
||||||
|
|
||||||
## 9. Open Gaps That Still Need Definition
|
|
||||||
|
|
||||||
The source document is strong at the concept level, but these items still need explicit specification before implementation starts:
|
|
||||||
- exact template JSON schema,
|
|
||||||
- exact report numbering strategy,
|
|
||||||
- exact Excel layout and formatting rules,
|
|
||||||
- rule language for conditional validation,
|
|
||||||
- whether PWA installation is required or optional,
|
|
||||||
- retention and deletion rules for local drafts,
|
|
||||||
- browser support baseline and test matrix,
|
|
||||||
- authentication requirements for template administration.
|
|
||||||
|
|
||||||
## 10. Main Risks and Mitigations
|
|
||||||
|
|
||||||
### Risk: local draft loss
|
|
||||||
Mitigation: auto-save, visible save state, export reminders, later import/export backup feature.
|
|
||||||
|
|
||||||
### Risk: storage pressure from images
|
|
||||||
Mitigation: enforce compression rules, cap attachment count where needed, show storage usage warnings.
|
|
||||||
|
|
||||||
### Risk: template drift
|
|
||||||
Mitigation: immutable template versions and version-bound drafts.
|
|
||||||
|
|
||||||
### Risk: export mismatch with current Excel expectations
|
|
||||||
Mitigation: validate the XLSX output format early using sample reports before full UI completion.
|
|
||||||
|
|
||||||
## 11. Final Recommendation
|
|
||||||
|
|
||||||
Proceed with a phase 1 implementation based on:
|
|
||||||
- React + TypeScript PWA frontend,
|
|
||||||
- IndexedDB local persistence,
|
|
||||||
- Node.js REST backend,
|
|
||||||
- MariaDB template repository,
|
|
||||||
- local XLSX and ZIP export.
|
|
||||||
|
|
||||||
This is the lowest-risk path that still satisfies the documented requirements and preserves a clean path to later server-side synchronization.
|
|
||||||
|
|
||||||
## 12. Best Immediate Next Steps
|
|
||||||
|
|
||||||
The next concrete deliverables should be:
|
|
||||||
1. template JSON schema,
|
|
||||||
2. MariaDB schema draft,
|
|
||||||
3. API contract draft,
|
|
||||||
4. report and attachment IndexedDB schema,
|
|
||||||
5. one sample export template in XLSX format,
|
|
||||||
6. a first implementation backlog split into frontend and backend work items.
|
|
||||||
-137
@@ -1,137 +0,0 @@
|
|||||||
# Check List Proof of Concept
|
|
||||||
|
|
||||||
This repository contains a minimal proof-of-concept backend for the Check List hybrid reporting solution described in the project documentation.
|
|
||||||
|
|
||||||
## What is included
|
|
||||||
|
|
||||||
- Node.js REST API for template and configuration delivery
|
|
||||||
- MariaDB schema for phase 1 configuration data
|
|
||||||
- seed data with one sample inspection checklist template
|
|
||||||
- lookup values, image policy, and export profile
|
|
||||||
- setup instructions for local development
|
|
||||||
|
|
||||||
## Scope of this PoC
|
|
||||||
|
|
||||||
Included:
|
|
||||||
- template list endpoint
|
|
||||||
- active template endpoint
|
|
||||||
- specific template version endpoint
|
|
||||||
- lookup endpoints
|
|
||||||
- image rule endpoint
|
|
||||||
- export profile endpoint
|
|
||||||
- generic application config endpoint
|
|
||||||
- MariaDB schema and seed data
|
|
||||||
|
|
||||||
Not included:
|
|
||||||
- report upload
|
|
||||||
- authentication
|
|
||||||
- admin UI
|
|
||||||
- report draft storage backend
|
|
||||||
- XLSX or ZIP generation
|
|
||||||
- client-side offline application
|
|
||||||
|
|
||||||
The PoC keeps template content inside a JSON column to reduce initial complexity and speed up delivery. This is deliberate for phase 1 proof-of-concept work.
|
|
||||||
|
|
||||||
## Project structure
|
|
||||||
|
|
||||||
```text
|
|
||||||
.
|
|
||||||
├── package.json
|
|
||||||
├── sql/
|
|
||||||
│ ├── schema.sql
|
|
||||||
│ └── seed.sql
|
|
||||||
└── src/
|
|
||||||
├── app.js
|
|
||||||
├── server.js
|
|
||||||
├── config/
|
|
||||||
├── db/
|
|
||||||
├── middleware/
|
|
||||||
├── routes/
|
|
||||||
├── services/
|
|
||||||
└── utils/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- Node.js 20+
|
|
||||||
- MariaDB 10.6+
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
1. Copy `.env.example` to `.env` and adjust the database credentials.
|
|
||||||
2. Create the schema:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SOURCE sql/schema.sql;
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Seed the sample data:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SOURCE sql/seed.sql;
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Install dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Start the API:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
## API endpoints
|
|
||||||
|
|
||||||
### Service health
|
|
||||||
|
|
||||||
`GET /api/health`
|
|
||||||
|
|
||||||
### Templates
|
|
||||||
|
|
||||||
- `GET /api/templates`
|
|
||||||
- `GET /api/templates/incoming-inspection`
|
|
||||||
- `GET /api/templates/incoming-inspection/versions/1`
|
|
||||||
|
|
||||||
### Lookups
|
|
||||||
|
|
||||||
- `GET /api/lookups`
|
|
||||||
- `GET /api/lookups/pass-fail`
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
- `GET /api/config/image-rules`
|
|
||||||
- `GET /api/config/export`
|
|
||||||
- `GET /api/config/app-config`
|
|
||||||
|
|
||||||
## Example response
|
|
||||||
|
|
||||||
`GET /api/templates/incoming-inspection`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"code": "incoming-inspection",
|
|
||||||
"name": "Incoming Inspection Checklist",
|
|
||||||
"description": "PoC template for supplier or incoming goods quality inspection.",
|
|
||||||
"version": 1,
|
|
||||||
"status": "active",
|
|
||||||
"publishedAt": "2026-04-09T10:00:00.000Z",
|
|
||||||
"definition": {
|
|
||||||
"templateId": "incoming-inspection",
|
|
||||||
"templateName": "Incoming Inspection Checklist",
|
|
||||||
"version": 1,
|
|
||||||
"sections": []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Recommended next step after this PoC
|
|
||||||
|
|
||||||
The next logical implementation layer is the client application that:
|
|
||||||
- caches templates in IndexedDB,
|
|
||||||
- renders forms dynamically from `definition`,
|
|
||||||
- stores local drafts and image metadata,
|
|
||||||
- applies validation rules before export,
|
|
||||||
- generates XLSX and ZIP locally.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "check-list-poc-api",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"description": "Proof-of-concept backend for the Check List hybrid quality reporting solution.",
|
|
||||||
"type": "module",
|
|
||||||
"main": "src/server.js",
|
|
||||||
"scripts": {
|
|
||||||
"start": "node src/server.js",
|
|
||||||
"dev": "node --watch src/server.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.0.0"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"cors": "^2.8.5",
|
|
||||||
"dotenv": "^16.4.5",
|
|
||||||
"express": "^4.21.2",
|
|
||||||
"mariadb": "^3.4.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
CREATE DATABASE IF NOT EXISTS check_list CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
|
||||||
|
|
||||||
USE check_list;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS templates (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
code VARCHAR(100) NOT NULL,
|
|
||||||
name VARCHAR(200) NOT NULL,
|
|
||||||
description TEXT 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_templates_code (code)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS template_versions (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
template_id BIGINT UNSIGNED NOT NULL,
|
|
||||||
version_number INT NOT NULL,
|
|
||||||
status ENUM('draft', 'active', 'retired') NOT NULL DEFAULT 'draft',
|
|
||||||
definition_json JSON NOT NULL,
|
|
||||||
published_at DATETIME NULL,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY uq_template_version (template_id, version_number),
|
|
||||||
KEY idx_template_versions_template_status (template_id, status),
|
|
||||||
CONSTRAINT fk_template_versions_template
|
|
||||||
FOREIGN KEY (template_id) REFERENCES templates (id)
|
|
||||||
ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS lookup_sets (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
code VARCHAR(100) NOT NULL,
|
|
||||||
name VARCHAR(200) NOT NULL,
|
|
||||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY uq_lookup_sets_code (code)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS lookup_values (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
lookup_set_id BIGINT UNSIGNED NOT NULL,
|
|
||||||
value VARCHAR(100) NOT NULL,
|
|
||||||
label VARCHAR(200) NOT NULL,
|
|
||||||
sort_order INT NOT NULL DEFAULT 0,
|
|
||||||
is_default TINYINT(1) NOT NULL DEFAULT 0,
|
|
||||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY uq_lookup_value (lookup_set_id, value),
|
|
||||||
KEY idx_lookup_values_lookup_set (lookup_set_id),
|
|
||||||
CONSTRAINT fk_lookup_values_lookup_set
|
|
||||||
FOREIGN KEY (lookup_set_id) REFERENCES lookup_sets (id)
|
|
||||||
ON DELETE CASCADE
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS image_rules (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
code VARCHAR(100) NOT NULL,
|
|
||||||
name VARCHAR(200) NOT NULL,
|
|
||||||
allowed_mime_types_json JSON NOT NULL,
|
|
||||||
max_file_size_bytes INT UNSIGNED NOT NULL,
|
|
||||||
max_width_px INT UNSIGNED NOT NULL,
|
|
||||||
max_height_px INT UNSIGNED NOT NULL,
|
|
||||||
jpeg_quality INT UNSIGNED NOT NULL,
|
|
||||||
oversize_behavior ENUM('auto_optimize', 'warn_then_optimize', 'block') NOT NULL DEFAULT 'auto_optimize',
|
|
||||||
max_attachments_per_field INT UNSIGNED NOT NULL DEFAULT 5,
|
|
||||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY uq_image_rules_code (code)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS export_profiles (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
code VARCHAR(100) NOT NULL,
|
|
||||||
name VARCHAR(200) NOT NULL,
|
|
||||||
zip_image_dir VARCHAR(100) NOT NULL DEFAULT 'images',
|
|
||||||
excel_sheet_name VARCHAR(100) NOT NULL DEFAULT 'Checklist',
|
|
||||||
include_template_version TINYINT(1) NOT NULL DEFAULT 1,
|
|
||||||
include_export_timestamp TINYINT(1) NOT NULL DEFAULT 1,
|
|
||||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
UNIQUE KEY uq_export_profiles_code (code)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS app_config (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
config_key VARCHAR(100) NOT NULL,
|
|
||||||
config_value_json JSON 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_app_config_key (config_key)
|
|
||||||
);
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
USE check_list;
|
|
||||||
|
|
||||||
INSERT INTO templates (code, name, description)
|
|
||||||
VALUES (
|
|
||||||
'incoming-inspection',
|
|
||||||
'Incoming Inspection Checklist',
|
|
||||||
'PoC template for supplier or incoming goods quality inspection.'
|
|
||||||
)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
name = VALUES(name),
|
|
||||||
description = VALUES(description);
|
|
||||||
|
|
||||||
SET @template_id = (SELECT id FROM templates WHERE code = 'incoming-inspection');
|
|
||||||
|
|
||||||
INSERT INTO template_versions (
|
|
||||||
template_id,
|
|
||||||
version_number,
|
|
||||||
status,
|
|
||||||
definition_json,
|
|
||||||
published_at
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
@template_id,
|
|
||||||
1,
|
|
||||||
'active',
|
|
||||||
'{
|
|
||||||
"templateId": "incoming-inspection",
|
|
||||||
"templateName": "Incoming Inspection Checklist",
|
|
||||||
"version": 1,
|
|
||||||
"reportNumberPattern": "QC-{yyyy}-{seq:4}",
|
|
||||||
"exportProfileCode": "default-report-export",
|
|
||||||
"imageRuleCode": "standard-mobile-images",
|
|
||||||
"sections": [
|
|
||||||
{
|
|
||||||
"id": "header",
|
|
||||||
"title": "Report Header",
|
|
||||||
"type": "group",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"id": "reportNumber",
|
|
||||||
"label": "Report Number",
|
|
||||||
"type": "text",
|
|
||||||
"required": true,
|
|
||||||
"readOnly": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "inspectionDate",
|
|
||||||
"label": "Inspection Date",
|
|
||||||
"type": "date",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "supplierName",
|
|
||||||
"label": "Supplier",
|
|
||||||
"type": "text",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "batchNumber",
|
|
||||||
"label": "Batch Number",
|
|
||||||
"type": "text",
|
|
||||||
"required": false
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "check-items",
|
|
||||||
"title": "Inspection Items",
|
|
||||||
"type": "group",
|
|
||||||
"fields": [
|
|
||||||
{
|
|
||||||
"id": "packagingCondition",
|
|
||||||
"label": "Packaging Condition",
|
|
||||||
"type": "lookup",
|
|
||||||
"lookupCode": "pass-fail",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "labelCheck",
|
|
||||||
"label": "Label Verification",
|
|
||||||
"type": "lookup",
|
|
||||||
"lookupCode": "pass-fail",
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "quantityVerified",
|
|
||||||
"label": "Quantity Verified",
|
|
||||||
"type": "number",
|
|
||||||
"required": true,
|
|
||||||
"validation": {
|
|
||||||
"min": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "damageFound",
|
|
||||||
"label": "Visible Damage Found",
|
|
||||||
"type": "checkbox",
|
|
||||||
"required": false,
|
|
||||||
"defaultValue": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "damagePhoto",
|
|
||||||
"label": "Damage Photo",
|
|
||||||
"type": "attachment",
|
|
||||||
"requiredWhen": {
|
|
||||||
"field": "damageFound",
|
|
||||||
"equals": true
|
|
||||||
},
|
|
||||||
"maxAttachments": 3
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "inspectorComment",
|
|
||||||
"label": "Inspector Comment",
|
|
||||||
"type": "comment",
|
|
||||||
"required": false,
|
|
||||||
"maxLength": 1000
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}',
|
|
||||||
NOW()
|
|
||||||
)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
status = VALUES(status),
|
|
||||||
definition_json = VALUES(definition_json),
|
|
||||||
published_at = VALUES(published_at);
|
|
||||||
|
|
||||||
INSERT INTO lookup_sets (code, name)
|
|
||||||
VALUES
|
|
||||||
('pass-fail', 'Pass/Fail'),
|
|
||||||
('draft-status', 'Draft Status')
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
name = VALUES(name);
|
|
||||||
|
|
||||||
SET @pass_fail_id = (SELECT id FROM lookup_sets WHERE code = 'pass-fail');
|
|
||||||
SET @draft_status_id = (SELECT id FROM lookup_sets WHERE code = 'draft-status');
|
|
||||||
|
|
||||||
INSERT INTO lookup_values (lookup_set_id, value, label, sort_order, is_default)
|
|
||||||
VALUES
|
|
||||||
(@pass_fail_id, 'pass', 'Pass', 1, 1),
|
|
||||||
(@pass_fail_id, 'fail', 'Fail', 2, 0),
|
|
||||||
(@draft_status_id, 'draft', 'Draft', 1, 1),
|
|
||||||
(@draft_status_id, 'in_progress', 'In Progress', 2, 0),
|
|
||||||
(@draft_status_id, 'ready_for_export', 'Ready for Export', 3, 0),
|
|
||||||
(@draft_status_id, 'exported', 'Exported', 4, 0),
|
|
||||||
(@draft_status_id, 'archived', 'Archived', 5, 0)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
label = VALUES(label),
|
|
||||||
sort_order = VALUES(sort_order),
|
|
||||||
is_default = VALUES(is_default);
|
|
||||||
|
|
||||||
INSERT INTO image_rules (
|
|
||||||
code,
|
|
||||||
name,
|
|
||||||
allowed_mime_types_json,
|
|
||||||
max_file_size_bytes,
|
|
||||||
max_width_px,
|
|
||||||
max_height_px,
|
|
||||||
jpeg_quality,
|
|
||||||
oversize_behavior,
|
|
||||||
max_attachments_per_field,
|
|
||||||
is_active
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
'standard-mobile-images',
|
|
||||||
'Standard Mobile Image Policy',
|
|
||||||
'["image/jpeg", "image/png", "image/webp"]',
|
|
||||||
5242880,
|
|
||||||
1920,
|
|
||||||
1920,
|
|
||||||
82,
|
|
||||||
'auto_optimize',
|
|
||||||
5,
|
|
||||||
1
|
|
||||||
)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
name = VALUES(name),
|
|
||||||
allowed_mime_types_json = VALUES(allowed_mime_types_json),
|
|
||||||
max_file_size_bytes = VALUES(max_file_size_bytes),
|
|
||||||
max_width_px = VALUES(max_width_px),
|
|
||||||
max_height_px = VALUES(max_height_px),
|
|
||||||
jpeg_quality = VALUES(jpeg_quality),
|
|
||||||
oversize_behavior = VALUES(oversize_behavior),
|
|
||||||
max_attachments_per_field = VALUES(max_attachments_per_field),
|
|
||||||
is_active = VALUES(is_active);
|
|
||||||
|
|
||||||
INSERT INTO export_profiles (
|
|
||||||
code,
|
|
||||||
name,
|
|
||||||
zip_image_dir,
|
|
||||||
excel_sheet_name,
|
|
||||||
include_template_version,
|
|
||||||
include_export_timestamp,
|
|
||||||
is_active
|
|
||||||
)
|
|
||||||
VALUES (
|
|
||||||
'default-report-export',
|
|
||||||
'Default Report Export',
|
|
||||||
'images',
|
|
||||||
'Checklist',
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
1
|
|
||||||
)
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
name = VALUES(name),
|
|
||||||
zip_image_dir = VALUES(zip_image_dir),
|
|
||||||
excel_sheet_name = VALUES(excel_sheet_name),
|
|
||||||
include_template_version = VALUES(include_template_version),
|
|
||||||
include_export_timestamp = VALUES(include_export_timestamp),
|
|
||||||
is_active = VALUES(is_active);
|
|
||||||
|
|
||||||
INSERT INTO app_config (config_key, config_value_json)
|
|
||||||
VALUES
|
|
||||||
('autosave', '{"enabled": true, "intervalSeconds": 20}'),
|
|
||||||
('offlineCache', '{"templateTtlHours": 24, "refreshOnStartup": true}'),
|
|
||||||
('reportStatuses', '["draft", "in_progress", "ready_for_export", "exported", "archived"]')
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
config_value_json = VALUES(config_value_json);
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import cors from 'cors';
|
|
||||||
import express from 'express';
|
|
||||||
|
|
||||||
import { errorHandler, notFoundHandler } from './middleware/errorHandler.js';
|
|
||||||
import configRoutes from './routes/configRoutes.js';
|
|
||||||
import healthRoutes from './routes/healthRoutes.js';
|
|
||||||
import lookupRoutes from './routes/lookupRoutes.js';
|
|
||||||
import templateRoutes from './routes/templateRoutes.js';
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
|
|
||||||
app.use(cors());
|
|
||||||
app.use(express.json({ limit: '10mb' }));
|
|
||||||
|
|
||||||
app.get('/', (_req, res) => {
|
|
||||||
res.json({
|
|
||||||
service: 'check-list-poc-api',
|
|
||||||
version: '0.1.0',
|
|
||||||
description: 'PoC API for template and configuration delivery.'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use('/api/health', healthRoutes);
|
|
||||||
app.use('/api/templates', templateRoutes);
|
|
||||||
app.use('/api/lookups', lookupRoutes);
|
|
||||||
app.use('/api/config', configRoutes);
|
|
||||||
|
|
||||||
app.use(notFoundHandler);
|
|
||||||
app.use(errorHandler);
|
|
||||||
|
|
||||||
export default app;
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import dotenv from 'dotenv';
|
|
||||||
|
|
||||||
dotenv.config();
|
|
||||||
|
|
||||||
const requiredKeys = ['DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD'];
|
|
||||||
|
|
||||||
for (const key of requiredKeys) {
|
|
||||||
if (!process.env[key]) {
|
|
||||||
throw new Error(`Missing required environment variable: ${key}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const env = {
|
|
||||||
port: Number(process.env.PORT || 3000),
|
|
||||||
db: {
|
|
||||||
host: process.env.DB_HOST,
|
|
||||||
port: Number(process.env.DB_PORT),
|
|
||||||
database: process.env.DB_NAME,
|
|
||||||
user: process.env.DB_USER,
|
|
||||||
password: process.env.DB_PASSWORD,
|
|
||||||
connectionLimit: Number(process.env.DB_CONNECTION_LIMIT || 5)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import mariadb from 'mariadb';
|
|
||||||
|
|
||||||
import { env } from '../config/env.js';
|
|
||||||
|
|
||||||
const pool = mariadb.createPool({
|
|
||||||
host: env.db.host,
|
|
||||||
port: env.db.port,
|
|
||||||
database: env.db.database,
|
|
||||||
user: env.db.user,
|
|
||||||
password: env.db.password,
|
|
||||||
connectionLimit: env.db.connectionLimit,
|
|
||||||
bigIntAsNumber: true
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function query(sql, params = []) {
|
|
||||||
let connection;
|
|
||||||
|
|
||||||
try {
|
|
||||||
connection = await pool.getConnection();
|
|
||||||
return await connection.query(sql, params);
|
|
||||||
} finally {
|
|
||||||
if (connection) {
|
|
||||||
connection.release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closePool() {
|
|
||||||
await pool.end();
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export function notFoundHandler(_req, res) {
|
|
||||||
res.status(404).json({ message: 'Route not found.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function errorHandler(error, _req, res, _next) {
|
|
||||||
const statusCode = error.statusCode || 500;
|
|
||||||
|
|
||||||
if (statusCode >= 500) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(statusCode).json({
|
|
||||||
message: error.message || 'Unexpected server error.'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { Router } from 'express';
|
|
||||||
|
|
||||||
import {
|
|
||||||
getAppConfig,
|
|
||||||
getExportProfile,
|
|
||||||
getImageRules
|
|
||||||
} from '../services/configService.js';
|
|
||||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/image-rules',
|
|
||||||
asyncHandler(async (_req, res) => {
|
|
||||||
const imageRules = await getImageRules();
|
|
||||||
|
|
||||||
if (!imageRules) {
|
|
||||||
return res.status(404).json({ message: 'Image rules not found.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(imageRules);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/export',
|
|
||||||
asyncHandler(async (_req, res) => {
|
|
||||||
const exportProfile = await getExportProfile();
|
|
||||||
|
|
||||||
if (!exportProfile) {
|
|
||||||
return res.status(404).json({ message: 'Export profile not found.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(exportProfile);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/app-config',
|
|
||||||
asyncHandler(async (_req, res) => {
|
|
||||||
const config = await getAppConfig();
|
|
||||||
res.json({ items: config });
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { Router } from 'express';
|
|
||||||
|
|
||||||
import { query } from '../db/pool.js';
|
|
||||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/',
|
|
||||||
asyncHandler(async (_req, res) => {
|
|
||||||
await query('SELECT 1 AS ok');
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
status: 'ok',
|
|
||||||
service: 'check-list-poc-api',
|
|
||||||
database: 'connected'
|
|
||||||
});
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { Router } from 'express';
|
|
||||||
|
|
||||||
import { getLookup, listLookups } from '../services/lookupService.js';
|
|
||||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/',
|
|
||||||
asyncHandler(async (_req, res) => {
|
|
||||||
const lookups = await listLookups();
|
|
||||||
res.json({ items: lookups });
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/:lookupCode',
|
|
||||||
asyncHandler(async (req, res) => {
|
|
||||||
const lookup = await getLookup(req.params.lookupCode);
|
|
||||||
|
|
||||||
if (!lookup) {
|
|
||||||
return res.status(404).json({ message: 'Lookup not found.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json(lookup);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { Router } from 'express';
|
|
||||||
|
|
||||||
import {
|
|
||||||
getActiveTemplate,
|
|
||||||
getTemplateVersion,
|
|
||||||
listTemplates
|
|
||||||
} from '../services/templateService.js';
|
|
||||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
|
||||||
|
|
||||||
const router = Router();
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/',
|
|
||||||
asyncHandler(async (_req, res) => {
|
|
||||||
const templates = await listTemplates();
|
|
||||||
res.json({ items: templates });
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/:templateCode',
|
|
||||||
asyncHandler(async (req, res) => {
|
|
||||||
const template = await getActiveTemplate(req.params.templateCode);
|
|
||||||
|
|
||||||
if (!template) {
|
|
||||||
return res.status(404).json({ message: 'Template not found.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json(template);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/:templateCode/versions/:versionNumber',
|
|
||||||
asyncHandler(async (req, res) => {
|
|
||||||
const template = await getTemplateVersion(
|
|
||||||
req.params.templateCode,
|
|
||||||
req.params.versionNumber
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!template) {
|
|
||||||
return res.status(404).json({ message: 'Template version not found.' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.json(template);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import app from './app.js';
|
|
||||||
import { env } from './config/env.js';
|
|
||||||
import { closePool, query } from './db/pool.js';
|
|
||||||
|
|
||||||
async function startServer() {
|
|
||||||
await query('SELECT 1 AS ok');
|
|
||||||
|
|
||||||
const server = app.listen(env.port, () => {
|
|
||||||
console.log(`Check List PoC API listening on port ${env.port}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function shutdown(signal) {
|
|
||||||
console.log(`Received ${signal}, shutting down...`);
|
|
||||||
server.close(async () => {
|
|
||||||
await closePool();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
process.on('SIGINT', () => void shutdown('SIGINT'));
|
|
||||||
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
||||||
}
|
|
||||||
|
|
||||||
startServer().catch(async (error) => {
|
|
||||||
console.error('Failed to start server');
|
|
||||||
console.error(error);
|
|
||||||
await closePool();
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { query } from '../db/pool.js';
|
|
||||||
import { parseJsonColumn } from '../utils/json.js';
|
|
||||||
|
|
||||||
export async function getImageRules() {
|
|
||||||
const rows = await query(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
code,
|
|
||||||
name,
|
|
||||||
allowed_mime_types_json AS allowedMimeTypes,
|
|
||||||
max_file_size_bytes AS maxFileSizeBytes,
|
|
||||||
max_width_px AS maxWidthPx,
|
|
||||||
max_height_px AS maxHeightPx,
|
|
||||||
jpeg_quality AS jpegQuality,
|
|
||||||
oversize_behavior AS oversizeBehavior,
|
|
||||||
max_attachments_per_field AS maxAttachmentsPerField
|
|
||||||
FROM image_rules
|
|
||||||
WHERE is_active = 1
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT 1
|
|
||||||
`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!rows.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...rows[0],
|
|
||||||
allowedMimeTypes: parseJsonColumn(rows[0].allowedMimeTypes, [])
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getExportProfile() {
|
|
||||||
const rows = await query(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
code,
|
|
||||||
name,
|
|
||||||
zip_image_dir AS zipImageDir,
|
|
||||||
excel_sheet_name AS excelSheetName,
|
|
||||||
include_template_version AS includeTemplateVersion,
|
|
||||||
include_export_timestamp AS includeExportTimestamp
|
|
||||||
FROM export_profiles
|
|
||||||
WHERE is_active = 1
|
|
||||||
ORDER BY id DESC
|
|
||||||
LIMIT 1
|
|
||||||
`
|
|
||||||
);
|
|
||||||
|
|
||||||
return rows.length ? rows[0] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAppConfig() {
|
|
||||||
const rows = await query(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
config_key AS configKey,
|
|
||||||
config_value_json AS configValue
|
|
||||||
FROM app_config
|
|
||||||
ORDER BY config_key ASC
|
|
||||||
`
|
|
||||||
);
|
|
||||||
|
|
||||||
return rows.map((row) => ({
|
|
||||||
key: row.configKey,
|
|
||||||
value: parseJsonColumn(row.configValue)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { query } from '../db/pool.js';
|
|
||||||
|
|
||||||
function groupLookups(rows) {
|
|
||||||
const lookups = new Map();
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
if (!lookups.has(row.lookupCode)) {
|
|
||||||
lookups.set(row.lookupCode, {
|
|
||||||
code: row.lookupCode,
|
|
||||||
name: row.lookupName,
|
|
||||||
values: []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.value !== null) {
|
|
||||||
lookups.get(row.lookupCode).values.push({
|
|
||||||
value: row.value,
|
|
||||||
label: row.label,
|
|
||||||
sortOrder: row.sortOrder,
|
|
||||||
isDefault: Boolean(row.isDefault)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(lookups.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listLookups() {
|
|
||||||
const rows = await query(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
ls.code AS lookupCode,
|
|
||||||
ls.name AS lookupName,
|
|
||||||
lv.value,
|
|
||||||
lv.label,
|
|
||||||
lv.sort_order AS sortOrder,
|
|
||||||
lv.is_default AS isDefault
|
|
||||||
FROM lookup_sets ls
|
|
||||||
LEFT JOIN lookup_values lv
|
|
||||||
ON lv.lookup_set_id = ls.id
|
|
||||||
AND lv.is_active = 1
|
|
||||||
WHERE ls.is_active = 1
|
|
||||||
ORDER BY ls.code ASC, lv.sort_order ASC, lv.id ASC
|
|
||||||
`
|
|
||||||
);
|
|
||||||
|
|
||||||
return groupLookups(rows);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getLookup(lookupCode) {
|
|
||||||
const rows = await query(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
ls.code AS lookupCode,
|
|
||||||
ls.name AS lookupName,
|
|
||||||
lv.value,
|
|
||||||
lv.label,
|
|
||||||
lv.sort_order AS sortOrder,
|
|
||||||
lv.is_default AS isDefault
|
|
||||||
FROM lookup_sets ls
|
|
||||||
LEFT JOIN lookup_values lv
|
|
||||||
ON lv.lookup_set_id = ls.id
|
|
||||||
AND lv.is_active = 1
|
|
||||||
WHERE ls.code = ?
|
|
||||||
AND ls.is_active = 1
|
|
||||||
ORDER BY lv.sort_order ASC, lv.id ASC
|
|
||||||
`,
|
|
||||||
[lookupCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!rows.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return groupLookups(rows)[0];
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import { query } from '../db/pool.js';
|
|
||||||
import { parseJsonColumn } from '../utils/json.js';
|
|
||||||
|
|
||||||
function mapTemplateRow(row) {
|
|
||||||
return {
|
|
||||||
code: row.code,
|
|
||||||
name: row.name,
|
|
||||||
description: row.description,
|
|
||||||
version: row.versionNumber,
|
|
||||||
status: row.status,
|
|
||||||
publishedAt: row.publishedAt,
|
|
||||||
definition: parseJsonColumn(row.definitionJson)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listTemplates() {
|
|
||||||
const rows = await query(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
t.code,
|
|
||||||
t.name,
|
|
||||||
t.description,
|
|
||||||
tv.version_number AS versionNumber,
|
|
||||||
tv.status,
|
|
||||||
tv.published_at AS publishedAt
|
|
||||||
FROM templates t
|
|
||||||
INNER JOIN template_versions tv
|
|
||||||
ON tv.template_id = t.id
|
|
||||||
AND tv.status = 'active'
|
|
||||||
ORDER BY t.name ASC
|
|
||||||
`
|
|
||||||
);
|
|
||||||
|
|
||||||
return rows.map((row) => ({
|
|
||||||
code: row.code,
|
|
||||||
name: row.name,
|
|
||||||
description: row.description,
|
|
||||||
activeVersion: row.versionNumber,
|
|
||||||
publishedAt: row.publishedAt
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getActiveTemplate(templateCode) {
|
|
||||||
const rows = await query(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
t.code,
|
|
||||||
t.name,
|
|
||||||
t.description,
|
|
||||||
tv.version_number AS versionNumber,
|
|
||||||
tv.status,
|
|
||||||
tv.published_at AS publishedAt,
|
|
||||||
tv.definition_json AS definitionJson
|
|
||||||
FROM templates t
|
|
||||||
INNER JOIN template_versions tv
|
|
||||||
ON tv.template_id = t.id
|
|
||||||
AND tv.status = 'active'
|
|
||||||
WHERE t.code = ?
|
|
||||||
LIMIT 1
|
|
||||||
`,
|
|
||||||
[templateCode]
|
|
||||||
);
|
|
||||||
|
|
||||||
return rows.length ? mapTemplateRow(rows[0]) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTemplateVersion(templateCode, versionNumber) {
|
|
||||||
const rows = await query(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
t.code,
|
|
||||||
t.name,
|
|
||||||
t.description,
|
|
||||||
tv.version_number AS versionNumber,
|
|
||||||
tv.status,
|
|
||||||
tv.published_at AS publishedAt,
|
|
||||||
tv.definition_json AS definitionJson
|
|
||||||
FROM templates t
|
|
||||||
INNER JOIN template_versions tv
|
|
||||||
ON tv.template_id = t.id
|
|
||||||
WHERE t.code = ?
|
|
||||||
AND tv.version_number = ?
|
|
||||||
LIMIT 1
|
|
||||||
`,
|
|
||||||
[templateCode, Number(versionNumber)]
|
|
||||||
);
|
|
||||||
|
|
||||||
return rows.length ? mapTemplateRow(rows[0]) : null;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export function asyncHandler(handler) {
|
|
||||||
return async function wrappedHandler(req, res, next) {
|
|
||||||
try {
|
|
||||||
await handler(req, res, next);
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export function parseJsonColumn(value, fallback = null) {
|
|
||||||
if (value == null) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'object') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(value);
|
|
||||||
} catch {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
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).
|
|
||||||
+28
-28
@@ -30,33 +30,13 @@
|
|||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<nav id="adminNav" class="flex-grow-1 overflow-auto p-2">
|
<nav id="adminNav" class="flex-grow-1 overflow-auto p-2">
|
||||||
<!-- Settings -->
|
<!-- Reports -->
|
||||||
<div class="admin-nav-cat is-open mb-1">
|
<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">
|
<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>
|
<span><i class="bi bi-file-earmark-text me-1"></i>Reports</span><span class="nav-arrow">▾</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="admin-nav-sub ms-3 mt-1">
|
<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 is-active" type="button" data-panel="reports">Tasks</button>
|
||||||
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="settings-template">Template</button>
|
|
||||||
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="settings-task">Task</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Users -->
|
|
||||||
<div class="admin-nav-cat mb-1">
|
|
||||||
<button class="admin-nav-cat-btn btn btn-sm btn-light w-100 text-start d-flex justify-content-between align-items-center" type="button">
|
|
||||||
<span><i class="bi bi-people me-1"></i>Users</span><span class="nav-arrow">▸</span>
|
|
||||||
</button>
|
|
||||||
<div class="admin-nav-sub ms-3 mt-1">
|
|
||||||
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="users">Users</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Sites -->
|
|
||||||
<div class="admin-nav-cat mb-1">
|
|
||||||
<button class="admin-nav-cat-btn btn btn-sm btn-light w-100 text-start d-flex justify-content-between align-items-center" type="button">
|
|
||||||
<span><i class="bi bi-building me-1"></i>Sites</span><span class="nav-arrow">▸</span>
|
|
||||||
</button>
|
|
||||||
<div class="admin-nav-sub ms-3 mt-1">
|
|
||||||
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="sites">Sites</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Check Lists -->
|
<!-- Check Lists -->
|
||||||
@@ -69,13 +49,33 @@
|
|||||||
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="cl-records">Records</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>
|
||||||
</div>
|
</div>
|
||||||
<!-- Reports -->
|
<!-- Sites -->
|
||||||
<div class="admin-nav-cat mb-1">
|
<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">
|
<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>
|
<span><i class="bi bi-building me-1"></i>Sites</span><span class="nav-arrow">▸</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="admin-nav-sub ms-3 mt-1">
|
<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>
|
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="sites">Sites</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Users -->
|
||||||
|
<div class="admin-nav-cat mb-1">
|
||||||
|
<button class="admin-nav-cat-btn btn btn-sm btn-light w-100 text-start d-flex justify-content-between align-items-center" type="button">
|
||||||
|
<span><i class="bi bi-people me-1"></i>Users</span><span class="nav-arrow">▸</span>
|
||||||
|
</button>
|
||||||
|
<div class="admin-nav-sub ms-3 mt-1">
|
||||||
|
<button class="admin-nav-item btn btn-sm btn-link text-start w-100" type="button" data-panel="users">Users</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Settings -->
|
||||||
|
<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-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" 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>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
<main class="flex-grow-1 overflow-auto p-4 bg-white">
|
<main class="flex-grow-1 overflow-auto p-4 bg-white">
|
||||||
|
|
||||||
<!-- SETTINGS > IMAGE POLICY -->
|
<!-- SETTINGS > IMAGE POLICY -->
|
||||||
<section id="panel-settings-policies" class="admin-panel admin-panel-active">
|
<section id="panel-settings-policies" class="admin-panel">
|
||||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-muted small mb-0">Settings › Image Policy</p>
|
<p class="text-muted small mb-0">Settings › Image Policy</p>
|
||||||
@@ -532,7 +532,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- REPORTS -->
|
<!-- REPORTS -->
|
||||||
<section id="panel-reports" class="admin-panel">
|
<section id="panel-reports" class="admin-panel admin-panel-active">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-muted small mb-0">Reports</p>
|
<p class="text-muted small mb-0">Reports</p>
|
||||||
|
|||||||
-780
@@ -1,780 +0,0 @@
|
|||||||
/*
|
|
||||||
* Check List PoC — Main entry point (orchestrator).
|
|
||||||
*
|
|
||||||
* This file wires together the split ES modules, binds DOM events, and manages
|
|
||||||
* the application lifecycle (init, sync, autosave, etc.). Business logic for
|
|
||||||
* rendering, validation, database, image optimization, and exports lives in
|
|
||||||
* dedicated modules under /js/.
|
|
||||||
*
|
|
||||||
* Architecture improvements implemented here:
|
|
||||||
* - A1: monolithic app.js split into focused ES modules
|
|
||||||
* - A2: single-request batch template fetch via ?include=definitions
|
|
||||||
* - A3: all API calls go through /api/v1/ (versioned endpoints)
|
|
||||||
* - A4: per-resource resilient sync (each resource saved independently)
|
|
||||||
* - A5: multi-store IndexedDB transactions for atomic deletes
|
|
||||||
* - A6: stale template cleanup after sync
|
|
||||||
* - A7: validation extracted to shared module (js/validation.js)
|
|
||||||
* - P2: debounced form re-render after field changes
|
|
||||||
* - P6: dirty-flag autosave — skips write when no changes occurred
|
|
||||||
* - F1: report submission to server
|
|
||||||
* - F4: report search & status filter
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { state, elements, getCurrentReport, getTemplateRecord } from './js/state.js';
|
|
||||||
import {
|
|
||||||
STORE_TEMPLATES, STORE_LOOKUPS, STORE_CONFIG,
|
|
||||||
STORE_REPORTS, STORE_ATTACHMENTS, STORE_SETTINGS,
|
|
||||||
DEFAULT_AUTOSAVE_SECONDS, RENDER_DEBOUNCE_MS
|
|
||||||
} from './js/constants.js';
|
|
||||||
import {
|
|
||||||
openDatabase, dbGetAll, dbGet, dbPut, dbDelete,
|
|
||||||
dbGetAllByIndex, dbTransaction, saveSetting, loadSetting
|
|
||||||
} from './js/db.js';
|
|
||||||
import { fetchJson, registerServiceWorker } from './js/api.js';
|
|
||||||
import { optimizeImage } from './js/images.js';
|
|
||||||
import { validateImageRulesPayload } from './js/validation.js';
|
|
||||||
import { exportReportCSV, exportReportAttachments } from './js/export.js';
|
|
||||||
import {
|
|
||||||
render, renderReportList, renderCurrentReport, renderMeta,
|
|
||||||
renderValidation, renderImagePolicy, renderAdminImageRules,
|
|
||||||
renderTemplateSummary, populateAdminImageRulesForm,
|
|
||||||
updateConnectionBadge, updateSaveBadge
|
|
||||||
} from './js/renderer.js';
|
|
||||||
import {
|
|
||||||
makeTemplateKey, deriveTemplateCatalog, generateReportNumber,
|
|
||||||
buildGeneratedFilename, formatTime, debounce
|
|
||||||
} from './js/utils.js';
|
|
||||||
import { t } from './js/i18n.js';
|
|
||||||
|
|
||||||
/* ── Initialization ─────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
void init();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
/*
|
|
||||||
* Initialization restores local state first so the app remains useful offline.
|
|
||||||
* Only after the cache is available do we try to refresh from the server. That
|
|
||||||
* ordering is intentional because a blank UI during a failed sync would defeat
|
|
||||||
* the main offline-first goal of the PoC.
|
|
||||||
*/
|
|
||||||
cacheElements();
|
|
||||||
bindEvents();
|
|
||||||
state.db = await openDatabase();
|
|
||||||
await hydrateFromLocalCache();
|
|
||||||
registerServiceWorker();
|
|
||||||
startAutosaveLoop();
|
|
||||||
updateConnectionBadge();
|
|
||||||
|
|
||||||
if (navigator.onLine) {
|
|
||||||
await syncTemplatesAndConfig();
|
|
||||||
}
|
|
||||||
|
|
||||||
render(fieldCallbacks);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── DOM caching ────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function cacheElements() {
|
|
||||||
elements.connectionBadge = document.querySelector('#connectionBadge');
|
|
||||||
elements.saveBadge = document.querySelector('#saveBadge');
|
|
||||||
elements.syncTemplatesButton = document.querySelector('#syncTemplatesButton');
|
|
||||||
elements.templateSelect = document.querySelector('#templateSelect');
|
|
||||||
elements.createReportButton = document.querySelector('#createReportButton');
|
|
||||||
elements.userAreaLink = document.querySelector('#userAreaLink');
|
|
||||||
elements.adminAreaLink = document.querySelector('#adminAreaLink');
|
|
||||||
elements.reportList = document.querySelector('#reportList');
|
|
||||||
elements.reportListItemTemplate = document.querySelector('#reportListItemTemplate');
|
|
||||||
elements.reportCount = document.querySelector('#reportCount');
|
|
||||||
/* User workspace elements — null on the admin page. */
|
|
||||||
elements.heroTitle = document.querySelector('#heroTitle');
|
|
||||||
elements.heroSubtitle = document.querySelector('#heroSubtitle');
|
|
||||||
elements.reportStatusSelect = document.querySelector('#reportStatusSelect');
|
|
||||||
elements.deleteReportButton = document.querySelector('#deleteReportButton');
|
|
||||||
elements.submitReportButton = document.querySelector('#submitReportButton');
|
|
||||||
elements.exportReportButton = document.querySelector('#exportReportButton');
|
|
||||||
elements.summaryTemplate = document.querySelector('#summaryTemplate');
|
|
||||||
elements.summaryVersion = document.querySelector('#summaryVersion');
|
|
||||||
elements.validationHeadline = document.querySelector('#validationHeadline');
|
|
||||||
elements.validationDetail = document.querySelector('#validationDetail');
|
|
||||||
elements.syncHeadline = document.querySelector('#syncHeadline');
|
|
||||||
elements.syncDetail = document.querySelector('#syncDetail');
|
|
||||||
elements.reportForm = document.querySelector('#reportForm');
|
|
||||||
elements.editorHint = document.querySelector('#editorHint');
|
|
||||||
elements.reportMeta = document.querySelector('#reportMeta');
|
|
||||||
elements.validationList = document.querySelector('#validationList');
|
|
||||||
elements.imagePolicyText = document.querySelector('#imagePolicyText');
|
|
||||||
/* Admin workspace elements — null on the user page. */
|
|
||||||
elements.adminSyncState = document.querySelector('#adminSyncState');
|
|
||||||
elements.adminImageRulesForm = document.querySelector('#adminImageRulesForm');
|
|
||||||
elements.saveImageRulesButton = document.querySelector('#saveImageRulesButton');
|
|
||||||
elements.resetImageRulesButton = document.querySelector('#resetImageRulesButton');
|
|
||||||
elements.adminPolicyName = document.querySelector('#adminPolicyName');
|
|
||||||
elements.adminAllowedMimeTypes = document.querySelector('#adminAllowedMimeTypes');
|
|
||||||
elements.adminMaxFileSizeMb = document.querySelector('#adminMaxFileSizeMb');
|
|
||||||
elements.adminMaxAttachmentsPerField = document.querySelector('#adminMaxAttachmentsPerField');
|
|
||||||
elements.adminMaxWidthPx = document.querySelector('#adminMaxWidthPx');
|
|
||||||
elements.adminMaxHeightPx = document.querySelector('#adminMaxHeightPx');
|
|
||||||
elements.adminJpegQuality = document.querySelector('#adminJpegQuality');
|
|
||||||
elements.adminOversizeBehavior = document.querySelector('#adminOversizeBehavior');
|
|
||||||
elements.adminPolicyCode = document.querySelector('#adminPolicyCode');
|
|
||||||
elements.adminPolicyMimeTypes = document.querySelector('#adminPolicyMimeTypes');
|
|
||||||
elements.adminPolicyOptimization = document.querySelector('#adminPolicyOptimization');
|
|
||||||
elements.adminPolicyLimits = document.querySelector('#adminPolicyLimits');
|
|
||||||
/* F4 — search and filter controls (user page only). */
|
|
||||||
elements.reportSearchInput = document.querySelector('#reportSearchInput');
|
|
||||||
elements.reportFilterSelect = document.querySelector('#reportFilterSelect');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Event binding ──────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function bindEvents() {
|
|
||||||
elements.syncTemplatesButton.addEventListener('click', () => {
|
|
||||||
void syncTemplatesAndConfig();
|
|
||||||
});
|
|
||||||
|
|
||||||
elements.templateSelect.addEventListener('change', (event) => {
|
|
||||||
state.selectedTemplateCode = event.target.value || null;
|
|
||||||
void saveSetting('selectedTemplateCode', state.selectedTemplateCode);
|
|
||||||
if (elements.reportForm) {
|
|
||||||
renderTemplateSummary();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (elements.createReportButton) {
|
|
||||||
elements.createReportButton.addEventListener('click', () => {
|
|
||||||
void createReport();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* User workspace events — only bound when the report editor exists. */
|
|
||||||
if (elements.reportStatusSelect) {
|
|
||||||
elements.reportStatusSelect.addEventListener('change', (event) => {
|
|
||||||
const report = getCurrentReport();
|
|
||||||
|
|
||||||
if (!report) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
report.status = event.target.value;
|
|
||||||
markDirty(t('statusChanged'));
|
|
||||||
renderReportList();
|
|
||||||
renderValidation();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elements.deleteReportButton) {
|
|
||||||
elements.deleteReportButton.addEventListener('click', () => {
|
|
||||||
void deleteCurrentReport();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* F1 — submit report to server */
|
|
||||||
if (elements.submitReportButton) {
|
|
||||||
elements.submitReportButton.addEventListener('click', () => {
|
|
||||||
void submitCurrentReport();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* F2 — CSV export */
|
|
||||||
if (elements.exportReportButton) {
|
|
||||||
elements.exportReportButton.addEventListener('click', () => {
|
|
||||||
void handleExport();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* F4 — report search */
|
|
||||||
if (elements.reportSearchInput) {
|
|
||||||
elements.reportSearchInput.addEventListener('input', debounce((event) => {
|
|
||||||
state.reportSearchQuery = event.target.value.trim();
|
|
||||||
renderReportList();
|
|
||||||
}, 250));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* F4 — report status filter */
|
|
||||||
if (elements.reportFilterSelect) {
|
|
||||||
elements.reportFilterSelect.addEventListener('change', (event) => {
|
|
||||||
state.reportFilterStatus = event.target.value;
|
|
||||||
renderReportList();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Admin workspace events — only bound when the admin form exists. */
|
|
||||||
if (elements.adminImageRulesForm) {
|
|
||||||
elements.adminImageRulesForm.addEventListener('submit', (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
void saveImageRules();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elements.resetImageRulesButton) {
|
|
||||||
elements.resetImageRulesButton.addEventListener('click', () => {
|
|
||||||
populateAdminImageRulesForm(state.imageRules);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Use event delegation on the report list to avoid per-item listeners. */
|
|
||||||
if (elements.reportList) {
|
|
||||||
elements.reportList.addEventListener('click', (event) => {
|
|
||||||
const button = event.target.closest('.report-list-item');
|
|
||||||
|
|
||||||
if (button?.dataset.reportId) {
|
|
||||||
void openReport(button.dataset.reportId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('online', () => {
|
|
||||||
updateConnectionBadge();
|
|
||||||
void syncTemplatesAndConfig();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener('offline', () => {
|
|
||||||
updateConnectionBadge();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Local cache hydration ──────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
async function hydrateFromLocalCache() {
|
|
||||||
const [templateRows, lookupRows, configRows, reports, selectedTemplateCode, lastReportId, lastSyncAt] =
|
|
||||||
await Promise.all([
|
|
||||||
dbGetAll(STORE_TEMPLATES),
|
|
||||||
dbGetAll(STORE_LOOKUPS),
|
|
||||||
dbGetAll(STORE_CONFIG),
|
|
||||||
dbGetAll(STORE_REPORTS),
|
|
||||||
loadSetting('selectedTemplateCode'),
|
|
||||||
loadSetting('currentReportId'),
|
|
||||||
loadSetting('lastSyncAt')
|
|
||||||
]);
|
|
||||||
|
|
||||||
state.templateDefinitions = new Map(
|
|
||||||
templateRows.map((item) => [makeTemplateKey(item.code, item.version), item])
|
|
||||||
);
|
|
||||||
state.templatesCatalog = deriveTemplateCatalog(templateRows);
|
|
||||||
state.lookups = new Map(lookupRows.map((item) => [item.code, item]));
|
|
||||||
state.appConfig = new Map(configRows.map((item) => [item.key, item.value]));
|
|
||||||
state.imageRules = state.appConfig.get('imageRules') || null;
|
|
||||||
state.reports = reports.sort((left, right) => {
|
|
||||||
return new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime();
|
|
||||||
});
|
|
||||||
state.selectedTemplateCode = selectedTemplateCode || state.templatesCatalog[0]?.code || null;
|
|
||||||
state.lastSyncAt = lastSyncAt || null;
|
|
||||||
|
|
||||||
if (lastReportId && state.reports.some((report) => report.id === lastReportId)) {
|
|
||||||
await openReport(lastReportId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Sync (A2 batch, A4 resilient, A6 stale cleanup) ───────────────────── */
|
|
||||||
|
|
||||||
async function syncTemplatesAndConfig() {
|
|
||||||
if (!navigator.onLine) {
|
|
||||||
updateSaveBadge(t('offlineMode'), 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSaveBadge(t('syncingTemplates'), 'neutral');
|
|
||||||
|
|
||||||
/*
|
|
||||||
* A4 — Each resource type is fetched and persisted independently. If one
|
|
||||||
* resource fails (e.g. lookups), the others still save successfully so the
|
|
||||||
* local cache stays as fresh as possible.
|
|
||||||
*/
|
|
||||||
const errors = [];
|
|
||||||
|
|
||||||
/* A2 — Batch template fetch: single request returns all active definitions. */
|
|
||||||
try {
|
|
||||||
const templatesResponse = await fetchJson('/templates?include=definitions');
|
|
||||||
const templateRecords = templatesResponse.items.map((item) => ({
|
|
||||||
cacheKey: makeTemplateKey(item.code, item.version),
|
|
||||||
code: item.code,
|
|
||||||
name: item.name,
|
|
||||||
description: item.description,
|
|
||||||
version: item.version,
|
|
||||||
publishedAt: item.publishedAt,
|
|
||||||
definition: item.definition,
|
|
||||||
isActive: true
|
|
||||||
}));
|
|
||||||
|
|
||||||
await Promise.all(templateRecords.map((item) => dbPut(STORE_TEMPLATES, item)));
|
|
||||||
|
|
||||||
/* A6 — Remove stale templates no longer in the server's active set. */
|
|
||||||
const activeKeys = new Set(templateRecords.map((r) => r.cacheKey));
|
|
||||||
const cachedTemplates = await dbGetAll(STORE_TEMPLATES);
|
|
||||||
|
|
||||||
for (const cached of cachedTemplates) {
|
|
||||||
if (cached.isActive && !activeKeys.has(cached.cacheKey)) {
|
|
||||||
await dbDelete(STORE_TEMPLATES, cached.cacheKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const freshTemplateRows = await dbGetAll(STORE_TEMPLATES);
|
|
||||||
state.templateDefinitions = new Map(
|
|
||||||
freshTemplateRows.map((item) => [makeTemplateKey(item.code, item.version), item])
|
|
||||||
);
|
|
||||||
state.templatesCatalog = templatesResponse.items.map((item) => ({
|
|
||||||
code: item.code,
|
|
||||||
name: item.name,
|
|
||||||
description: item.description,
|
|
||||||
activeVersion: item.version,
|
|
||||||
publishedAt: item.publishedAt
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Template sync failed', error);
|
|
||||||
errors.push('templates');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const lookupsResponse = await fetchJson('/lookups');
|
|
||||||
await Promise.all(lookupsResponse.items.map((item) => dbPut(STORE_LOOKUPS, item)));
|
|
||||||
state.lookups = new Map(lookupsResponse.items.map((item) => [item.code, item]));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Lookup sync failed', error);
|
|
||||||
errors.push('lookups');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [imageRules, exportProfile, appConfigResponse] = await Promise.all([
|
|
||||||
fetchJson('/config/image-rules'),
|
|
||||||
fetchJson('/config/export'),
|
|
||||||
fetchJson('/config/app-config')
|
|
||||||
]);
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
dbPut(STORE_CONFIG, { key: 'imageRules', value: imageRules }),
|
|
||||||
dbPut(STORE_CONFIG, { key: 'exportProfile', value: exportProfile }),
|
|
||||||
...appConfigResponse.items.map((item) => dbPut(STORE_CONFIG, { key: item.key, value: item.value }))
|
|
||||||
]);
|
|
||||||
|
|
||||||
state.imageRules = imageRules;
|
|
||||||
state.appConfig = new Map([
|
|
||||||
...appConfigResponse.items.map((item) => [item.key, item.value]),
|
|
||||||
['imageRules', imageRules],
|
|
||||||
['exportProfile', exportProfile]
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Config sync failed', error);
|
|
||||||
errors.push('config');
|
|
||||||
}
|
|
||||||
|
|
||||||
state.lastSyncAt = new Date().toISOString();
|
|
||||||
await saveSetting('lastSyncAt', state.lastSyncAt);
|
|
||||||
|
|
||||||
if (!state.selectedTemplateCode && state.templatesCatalog.length) {
|
|
||||||
state.selectedTemplateCode = state.templatesCatalog[0].code;
|
|
||||||
await saveSetting('selectedTemplateCode', state.selectedTemplateCode);
|
|
||||||
}
|
|
||||||
|
|
||||||
startAutosaveLoop();
|
|
||||||
|
|
||||||
if (errors.length) {
|
|
||||||
updateSaveBadge(`Partial sync (${errors.join(', ')} failed)`, 'warning');
|
|
||||||
} else {
|
|
||||||
updateSaveBadge(t('templatesSynced'), 'success');
|
|
||||||
}
|
|
||||||
|
|
||||||
render(fieldCallbacks);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Report CRUD ────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
async function createReport() {
|
|
||||||
const templateCode = state.selectedTemplateCode || elements.templateSelect.value;
|
|
||||||
|
|
||||||
if (!templateCode) {
|
|
||||||
updateSaveBadge(t('selectTemplate'), 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const catalogEntry = state.templatesCatalog.find((item) => item.code === templateCode);
|
|
||||||
const versionNumber = catalogEntry?.activeVersion;
|
|
||||||
const template = getTemplateRecord(templateCode, versionNumber);
|
|
||||||
|
|
||||||
if (!template) {
|
|
||||||
updateSaveBadge(t('templateNotAvailable'), 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const report = buildNewReport(template);
|
|
||||||
state.reports.unshift(report);
|
|
||||||
state.currentReportId = report.id;
|
|
||||||
state.currentAttachments = [];
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
dbPut(STORE_REPORTS, report),
|
|
||||||
saveSetting('currentReportId', report.id)
|
|
||||||
]);
|
|
||||||
|
|
||||||
updateSaveBadge(t('newReportCreated'), 'success');
|
|
||||||
render(fieldCallbacks);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildNewReport(template) {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const reportNumber = generateReportNumber();
|
|
||||||
const answers = {};
|
|
||||||
|
|
||||||
for (const section of template.definition.sections || []) {
|
|
||||||
for (const field of section.fields || []) {
|
|
||||||
if (field.defaultValue !== undefined) {
|
|
||||||
answers[field.id] = field.defaultValue;
|
|
||||||
} else if (field.id === 'reportNumber') {
|
|
||||||
answers[field.id] = reportNumber;
|
|
||||||
} else if (field.type === 'date') {
|
|
||||||
answers[field.id] = now.slice(0, 10);
|
|
||||||
} else if (field.type === 'checkbox') {
|
|
||||||
answers[field.id] = false;
|
|
||||||
} else {
|
|
||||||
answers[field.id] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
reportNumber,
|
|
||||||
title: `${template.name} ${new Date().toLocaleDateString()}`,
|
|
||||||
templateCode: template.code,
|
|
||||||
templateVersion: template.version,
|
|
||||||
status: 'draft',
|
|
||||||
answers,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openReport(reportId) {
|
|
||||||
const report = state.reports.find((item) => item.id === reportId);
|
|
||||||
|
|
||||||
if (!report) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await flushDirtyReport();
|
|
||||||
state.currentReportId = reportId;
|
|
||||||
state.currentAttachments = await dbGetAllByIndex(STORE_ATTACHMENTS, 'byReportId', reportId);
|
|
||||||
state.selectedTemplateCode = report.templateCode;
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
saveSetting('currentReportId', reportId),
|
|
||||||
saveSetting('selectedTemplateCode', report.templateCode)
|
|
||||||
]);
|
|
||||||
|
|
||||||
render(fieldCallbacks);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteCurrentReport() {
|
|
||||||
const report = getCurrentReport();
|
|
||||||
|
|
||||||
if (!report) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmed = window.confirm(t('deleteReportConfirm', report.reportNumber));
|
|
||||||
|
|
||||||
if (!confirmed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A5 — Atomic multi-store delete: report + all its attachments in one tx. */
|
|
||||||
const attachments = await dbGetAllByIndex(STORE_ATTACHMENTS, 'byReportId', report.id);
|
|
||||||
|
|
||||||
await dbTransaction([STORE_REPORTS, STORE_ATTACHMENTS], 'readwrite', (getStore) => {
|
|
||||||
getStore(STORE_REPORTS).delete(report.id);
|
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
|
||||||
getStore(STORE_ATTACHMENTS).delete(attachment.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
state.reports = state.reports.filter((item) => item.id !== report.id);
|
|
||||||
state.currentReportId = state.reports[0]?.id || null;
|
|
||||||
state.currentAttachments = state.currentReportId
|
|
||||||
? await dbGetAllByIndex(STORE_ATTACHMENTS, 'byReportId', state.currentReportId)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
await saveSetting('currentReportId', state.currentReportId);
|
|
||||||
updateSaveBadge(t('reportDeleted'), 'success');
|
|
||||||
render(fieldCallbacks);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Report submission to server (F1) ───────────────────────────────────── */
|
|
||||||
|
|
||||||
async function submitCurrentReport() {
|
|
||||||
const report = getCurrentReport();
|
|
||||||
|
|
||||||
if (!report) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!navigator.onLine) {
|
|
||||||
updateSaveBadge(t('goOnlineToSubmit'), 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await flushDirtyReport();
|
|
||||||
updateSaveBadge(t('submitting'), 'neutral');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetchJson('/reports', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
id: report.id,
|
|
||||||
reportNumber: report.reportNumber,
|
|
||||||
templateCode: report.templateCode,
|
|
||||||
templateVersion: report.templateVersion,
|
|
||||||
status: report.status,
|
|
||||||
answers: report.answers
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
report.status = 'exported';
|
|
||||||
report.updatedAt = new Date().toISOString();
|
|
||||||
await dbPut(STORE_REPORTS, report);
|
|
||||||
updateSaveBadge(t('submitted'), 'success');
|
|
||||||
renderReportList();
|
|
||||||
renderCurrentReport(fieldCallbacks);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
updateSaveBadge(t('submitFailed'), 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Export (F2) ────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
async function handleExport() {
|
|
||||||
const report = getCurrentReport();
|
|
||||||
|
|
||||||
if (!report) {
|
|
||||||
updateSaveBadge(t('noReportToExport'), 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSaveBadge(t('exportStarted'), 'neutral');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await exportReportCSV();
|
|
||||||
await exportReportAttachments();
|
|
||||||
updateSaveBadge(t('exportComplete'), 'success');
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
updateSaveBadge(t('exportFailed'), 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Attachment operations ──────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
async function attachFiles(field, report, files) {
|
|
||||||
if (!files.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentFieldAttachments = state.currentAttachments.filter((item) => item.fieldId === field.id);
|
|
||||||
const maxAttachments = field.maxAttachments || state.imageRules?.maxAttachmentsPerField || 5;
|
|
||||||
|
|
||||||
if (currentFieldAttachments.length + files.length > maxAttachments) {
|
|
||||||
updateSaveBadge(t('maxAttachments', maxAttachments), 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let addedCount = 0;
|
|
||||||
|
|
||||||
for (let index = 0; index < files.length; index += 1) {
|
|
||||||
const file = files[index];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const optimized = await optimizeImage(file, state.imageRules);
|
|
||||||
const sequence = currentFieldAttachments.length + addedCount + 1;
|
|
||||||
const generatedFilename = buildGeneratedFilename(report, field, sequence, optimized.extension);
|
|
||||||
|
|
||||||
const attachment = {
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
reportId: report.id,
|
|
||||||
fieldId: field.id,
|
|
||||||
originalFilename: file.name,
|
|
||||||
generatedFilename,
|
|
||||||
mimeType: optimized.blob.type,
|
|
||||||
sizeBytes: optimized.blob.size,
|
|
||||||
width: optimized.width,
|
|
||||||
height: optimized.height,
|
|
||||||
blob: optimized.blob,
|
|
||||||
createdAt: new Date().toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
state.currentAttachments.push(attachment);
|
|
||||||
await dbPut(STORE_ATTACHMENTS, attachment);
|
|
||||||
addedCount += 1;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
updateSaveBadge(t('imageSkipped', file.name), 'error');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addedCount === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
report.updatedAt = new Date().toISOString();
|
|
||||||
markDirty(t('imagesUpdated'));
|
|
||||||
renderCurrentReport(fieldCallbacks);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeAttachment(attachmentId) {
|
|
||||||
const report = getCurrentReport();
|
|
||||||
|
|
||||||
if (!report) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await dbDelete(STORE_ATTACHMENTS, attachmentId);
|
|
||||||
state.currentAttachments = state.currentAttachments.filter((item) => item.id !== attachmentId);
|
|
||||||
report.updatedAt = new Date().toISOString();
|
|
||||||
markDirty(t('attachmentRemoved'));
|
|
||||||
renderCurrentReport(fieldCallbacks);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Field change handler (P2 debounced) ────────────────────────────────── */
|
|
||||||
|
|
||||||
const debouncedRenderAfterFieldChange = debounce(() => {
|
|
||||||
renderMeta(getCurrentReport());
|
|
||||||
renderValidation();
|
|
||||||
renderReportList();
|
|
||||||
}, RENDER_DEBOUNCE_MS);
|
|
||||||
|
|
||||||
function updateFieldValue(field, nextValue) {
|
|
||||||
const report = getCurrentReport();
|
|
||||||
|
|
||||||
if (!report) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
report.answers[field.id] = field.type === 'number' && nextValue !== '' ? Number(nextValue) : nextValue;
|
|
||||||
report.updatedAt = new Date().toISOString();
|
|
||||||
|
|
||||||
if (report.status === 'draft') {
|
|
||||||
report.status = 'in_progress';
|
|
||||||
if (elements.reportStatusSelect) {
|
|
||||||
elements.reportStatusSelect.value = report.status;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
markDirty(t('draftUpdated'));
|
|
||||||
|
|
||||||
/* P2 — Debounce DOM updates so rapid typing does not trigger full re-renders. */
|
|
||||||
debouncedRenderAfterFieldChange();
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Callback bundle passed into the renderer so form nodes can trigger state
|
|
||||||
* mutations without circular imports.
|
|
||||||
*/
|
|
||||||
const fieldCallbacks = {
|
|
||||||
onFieldChange: updateFieldValue,
|
|
||||||
onAttachFiles: attachFiles,
|
|
||||||
onRemoveAttachment: removeAttachment
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ── Dirty-state tracking & autosave (P6) ───────────────────────────────── */
|
|
||||||
|
|
||||||
function markDirty(label) {
|
|
||||||
state.dirty = true;
|
|
||||||
state.saveState = 'dirty';
|
|
||||||
updateSaveBadge(label, 'warning');
|
|
||||||
clearTimeout(state.saveTimer);
|
|
||||||
state.saveTimer = window.setTimeout(() => {
|
|
||||||
void flushDirtyReport();
|
|
||||||
}, 700);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function flushDirtyReport() {
|
|
||||||
/* P6 — Skip write when no changes have been made since the last save. */
|
|
||||||
if (!state.dirty && state.saveState !== 'dirty') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const report = getCurrentReport();
|
|
||||||
|
|
||||||
if (!report) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
report.updatedAt = new Date().toISOString();
|
|
||||||
await dbPut(STORE_REPORTS, report);
|
|
||||||
state.reports = state.reports
|
|
||||||
.map((item) => (item.id === report.id ? structuredClone(report) : item))
|
|
||||||
.sort((left, right) => new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime());
|
|
||||||
state.dirty = false;
|
|
||||||
state.saveState = 'idle';
|
|
||||||
updateSaveBadge(t('saved', formatTime(report.updatedAt)), 'success');
|
|
||||||
renderReportList();
|
|
||||||
renderMeta(report);
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAutosaveLoop() {
|
|
||||||
clearInterval(state.autosaveIntervalId);
|
|
||||||
const autosaveConfig = state.appConfig.get('autosave') || { intervalSeconds: DEFAULT_AUTOSAVE_SECONDS };
|
|
||||||
const intervalSeconds = Number(autosaveConfig.intervalSeconds || DEFAULT_AUTOSAVE_SECONDS);
|
|
||||||
|
|
||||||
state.autosaveIntervalId = window.setInterval(() => {
|
|
||||||
void flushDirtyReport();
|
|
||||||
}, intervalSeconds * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Admin: image rules ─────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
async function saveImageRules() {
|
|
||||||
if (!navigator.onLine) {
|
|
||||||
updateSaveBadge(t('goOnlineToSave'), 'warning');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = collectImageRulesPayload();
|
|
||||||
const validationMessage = validateImageRulesPayload(payload);
|
|
||||||
|
|
||||||
if (validationMessage) {
|
|
||||||
updateSaveBadge(validationMessage, 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.saveImageRulesButton.disabled = true;
|
|
||||||
updateSaveBadge(t('savingImagePolicy'), 'neutral');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const nextImageRules = await fetchJson('/config/image-rules', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
|
|
||||||
state.imageRules = nextImageRules;
|
|
||||||
state.appConfig.set('imageRules', nextImageRules);
|
|
||||||
await dbPut(STORE_CONFIG, { key: 'imageRules', value: nextImageRules });
|
|
||||||
renderImagePolicy();
|
|
||||||
renderAdminImageRules();
|
|
||||||
updateSaveBadge(t('imagePolicySaved'), 'success');
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
updateSaveBadge(error.message || t('submitFailed'), 'error');
|
|
||||||
} finally {
|
|
||||||
elements.saveImageRulesButton.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectImageRulesPayload() {
|
|
||||||
return {
|
|
||||||
name: elements.adminPolicyName.value.trim(),
|
|
||||||
allowedMimeTypes: elements.adminAllowedMimeTypes.value
|
|
||||||
.split(',')
|
|
||||||
.map((value) => value.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
maxFileSizeBytes: Math.round(Number(elements.adminMaxFileSizeMb.value) * 1024 * 1024),
|
|
||||||
maxWidthPx: Number(elements.adminMaxWidthPx.value),
|
|
||||||
maxHeightPx: Number(elements.adminMaxHeightPx.value),
|
|
||||||
jpegQuality: Number(elements.adminJpegQuality.value),
|
|
||||||
oversizeBehavior: elements.adminOversizeBehavior.value,
|
|
||||||
maxAttachmentsPerField: Number(elements.adminMaxAttachmentsPerField.value)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
<!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</title>
|
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet" />
|
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="d-flex vh-100">
|
|
||||||
<!-- Sidebar -->
|
|
||||||
<aside class="sidebar-bs d-flex flex-column border-end bg-light" style="width:280px;min-width:280px;">
|
|
||||||
<div class="p-3 border-bottom">
|
|
||||||
<p class="text-uppercase text-muted small fw-semibold mb-0">Hybrid Inspection Reporting</p>
|
|
||||||
<h5 class="fw-bold mb-0">Check List</h5>
|
|
||||||
<small class="text-muted">Offline-first proof of concept for template-driven quality reports.</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-3 border-bottom">
|
|
||||||
<div class="d-flex gap-2 mb-2">
|
|
||||||
<span id="connectionBadge" class="badge bg-secondary">Checking connection</span>
|
|
||||||
<span id="saveBadge" class="badge bg-secondary">No changes</span>
|
|
||||||
</div>
|
|
||||||
<button id="syncTemplatesButton" class="btn btn-outline-secondary btn-sm w-100" type="button">
|
|
||||||
<i class="bi bi-arrow-repeat me-1"></i>Sync templates
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-3 border-bottom">
|
|
||||||
<label class="form-label small fw-semibold" for="templateSelect">Template</label>
|
|
||||||
<select id="templateSelect" class="form-select form-select-sm mb-2"></select>
|
|
||||||
<button id="createReportButton" class="btn btn-primary btn-sm w-100" type="button">
|
|
||||||
<i class="bi bi-plus-lg me-1"></i>Create new report
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-3 border-bottom">
|
|
||||||
<a id="userAreaLink" class="btn btn-outline-secondary btn-sm w-100 mb-1" href="/user">User area</a>
|
|
||||||
<a id="adminAreaLink" class="btn btn-outline-secondary btn-sm w-100 mb-1" href="/admin">Admin area</a>
|
|
||||||
<a class="btn btn-outline-secondary btn-sm w-100" href="/">Back to portal</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-grow-1 overflow-auto p-3">
|
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
||||||
<h6 class="fw-semibold mb-0">Local reports</h6>
|
|
||||||
<span id="reportCount" class="badge bg-secondary">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<input id="reportSearchInput" class="form-control form-control-sm mb-1" type="search" placeholder="Search reports" />
|
|
||||||
<select id="reportFilterSelect" class="form-select form-select-sm">
|
|
||||||
<option value="">All statuses</option>
|
|
||||||
<option value="draft">Draft</option>
|
|
||||||
<option value="in_progress">In Progress</option>
|
|
||||||
<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>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<!-- Main content -->
|
|
||||||
<main class="flex-grow-1 overflow-auto p-4 bg-white">
|
|
||||||
|
|
||||||
<!-- Operator workspace -->
|
|
||||||
<section id="reportsWorkspace" class="workspace-view workspace-view-active">
|
|
||||||
<div class="d-flex justify-content-between align-items-start mb-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-muted small mb-0">Proof of concept frontend</p>
|
|
||||||
<h3 id="heroTitle" class="fw-bold">No report selected</h3>
|
|
||||||
<p id="heroSubtitle" class="text-muted">Start by syncing templates and creating a local draft.</p>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2 align-items-center">
|
|
||||||
<label class="d-flex align-items-center gap-1 small">
|
|
||||||
<span>Status</span>
|
|
||||||
<select id="reportStatusSelect" class="form-select form-select-sm" style="width:auto">
|
|
||||||
<option value="draft">Draft</option>
|
|
||||||
<option value="in_progress">In Progress</option>
|
|
||||||
<option value="ready_for_export">Ready for Export</option>
|
|
||||||
<option value="exported">Exported</option>
|
|
||||||
<option value="archived">Archived</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button id="submitReportButton" class="btn btn-outline-secondary btn-sm" type="button">Submit</button>
|
|
||||||
<button id="exportReportButton" class="btn btn-outline-secondary btn-sm" type="button">Export CSV</button>
|
|
||||||
<button id="deleteReportButton" class="btn btn-outline-danger btn-sm" type="button">Delete</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Summary cards -->
|
|
||||||
<div class="row g-3 mb-4">
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card border-primary">
|
|
||||||
<div class="card-body py-2 px-3">
|
|
||||||
<small class="text-muted">Template</small>
|
|
||||||
<div class="fw-semibold" id="summaryTemplate">Not loaded</div>
|
|
||||||
<small class="text-muted" id="summaryVersion">Version -</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body py-2 px-3">
|
|
||||||
<small class="text-muted">Validation</small>
|
|
||||||
<div class="fw-semibold" id="validationHeadline">No report selected</div>
|
|
||||||
<small class="text-muted" id="validationDetail">Draft validation will appear here.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-body py-2 px-3">
|
|
||||||
<small class="text-muted">Offline cache</small>
|
|
||||||
<div class="fw-semibold" id="syncHeadline">No sync yet</div>
|
|
||||||
<small class="text-muted" id="syncDetail">Templates are cached locally after the first successful sync.</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Editor + Inspector -->
|
|
||||||
<div class="row g-4">
|
|
||||||
<div class="col-lg-8">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<h6 class="mb-0 fw-semibold">Report editor</h6>
|
|
||||||
<small id="editorHint" class="text-muted">Dynamic form rendering from template JSON</small>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form id="reportForm" class="report-form">
|
|
||||||
<div class="text-center text-muted py-4">
|
|
||||||
<h5>No report open</h5>
|
|
||||||
<p>Choose a template and create a report to start editing locally.</p>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-header fw-semibold">Inspector view</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<dl id="reportMeta" class="mb-0">
|
|
||||||
<dt class="small text-muted">Report ID</dt><dd class="mb-2">-</dd>
|
|
||||||
<dt class="small text-muted">Template</dt><dd class="mb-2">-</dd>
|
|
||||||
<dt class="small text-muted">Created</dt><dd class="mb-2">-</dd>
|
|
||||||
<dt class="small text-muted">Updated</dt><dd class="mb-0">-</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card mb-3">
|
|
||||||
<div class="card-header fw-semibold">Validation issues</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<ul id="validationList" class="mb-0 ps-3">
|
|
||||||
<li>No report selected.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header fw-semibold">Image policy</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p id="imagePolicyText" class="mb-0 text-muted small">
|
|
||||||
Load server configuration to see image limits and optimization rules.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Admin workspace (legacy) -->
|
|
||||||
<section id="adminWorkspace" class="workspace-view" hidden>
|
|
||||||
<div class="mb-4">
|
|
||||||
<p class="text-muted small mb-0">Administrator workspace</p>
|
|
||||||
<h3 class="fw-bold">Configuration control</h3>
|
|
||||||
<p class="text-muted">Update centrally managed image requirements used by the inspection frontend.</p>
|
|
||||||
<span id="adminSyncState" class="badge bg-secondary">Server-backed settings</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-4">
|
|
||||||
<div class="col-lg-8">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header fw-semibold">Image policy editor</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form id="adminImageRulesForm">
|
|
||||||
<div class="row g-3 mb-3">
|
|
||||||
<div class="col-12">
|
|
||||||
<label for="adminPolicyName" class="form-label">Policy name</label>
|
|
||||||
<input id="adminPolicyName" name="name" class="form-control" type="text" />
|
|
||||||
</div>
|
|
||||||
<div class="col-12">
|
|
||||||
<label for="adminAllowedMimeTypes" class="form-label">Allowed MIME types</label>
|
|
||||||
<input id="adminAllowedMimeTypes" name="allowedMimeTypes" class="form-control" type="text" placeholder="image/jpeg, image/png, image/webp" />
|
|
||||||
<div class="form-text">Comma-separated values used by the attachment field and browser validation.</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="adminMaxFileSizeMb" class="form-label">Max file size (MB)</label>
|
|
||||||
<input id="adminMaxFileSizeMb" name="maxFileSizeMb" class="form-control" type="number" min="1" step="0.1" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="adminMaxAttachmentsPerField" class="form-label">Max attachments per field</label>
|
|
||||||
<input id="adminMaxAttachmentsPerField" name="maxAttachmentsPerField" class="form-control" type="number" min="1" step="1" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label for="adminMaxWidthPx" class="form-label">Max width (px)</label>
|
|
||||||
<input id="adminMaxWidthPx" name="maxWidthPx" class="form-control" type="number" min="1" step="1" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label for="adminMaxHeightPx" class="form-label">Max height (px)</label>
|
|
||||||
<input id="adminMaxHeightPx" name="maxHeightPx" class="form-control" type="number" min="1" step="1" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-4">
|
|
||||||
<label for="adminJpegQuality" class="form-label">JPEG quality</label>
|
|
||||||
<input id="adminJpegQuality" name="jpegQuality" class="form-control" type="number" min="1" max="100" step="1" />
|
|
||||||
</div>
|
|
||||||
<div class="col-md-6">
|
|
||||||
<label for="adminOversizeBehavior" class="form-label">Oversize behavior</label>
|
|
||||||
<select id="adminOversizeBehavior" name="oversizeBehavior" class="form-select">
|
|
||||||
<option value="auto_optimize">Auto optimize</option>
|
|
||||||
<option value="warn_then_optimize">Warn then optimize</option>
|
|
||||||
<option value="block">Block oversized files</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<button id="saveImageRulesButton" class="btn btn-primary" type="submit">Save image policy</button>
|
|
||||||
<button id="resetImageRulesButton" class="btn btn-outline-secondary" type="button">Reset form</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-4">
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header fw-semibold">Admin summary</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<dl class="mb-0">
|
|
||||||
<dt class="small text-muted">Active policy code</dt><dd id="adminPolicyCode" class="mb-2">-</dd>
|
|
||||||
<dt class="small text-muted">Allowed types</dt><dd id="adminPolicyMimeTypes" class="mb-2">-</dd>
|
|
||||||
<dt class="small text-muted">Optimization</dt><dd id="adminPolicyOptimization" class="mb-2">-</dd>
|
|
||||||
<dt class="small text-muted">Limits</dt><dd id="adminPolicyLimits" class="mb-0">-</dd>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card mt-3">
|
|
||||||
<div class="card-header fw-semibold">Admin notes</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<ul id="adminNotesList" class="mb-0 ps-3">
|
|
||||||
<li>Changes are stored on the server and reused by report attachments.</li>
|
|
||||||
<li>Operators will use the updated policy after the next sync.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template id="reportListItemTemplate">
|
|
||||||
<button class="report-list-item" type="button" data-report-id="">
|
|
||||||
<span class="report-list-item__header">
|
|
||||||
<strong class="report-list-item__title"></strong>
|
|
||||||
<span class="report-list-item__status badge"></span>
|
|
||||||
</span>
|
|
||||||
<span class="report-list-item__meta"></span>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script type="module" src="/app.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+10
-3
@@ -655,15 +655,22 @@ function renderUserList() {
|
|||||||
container.innerHTML = '<div class="empty-state"><h3>No users</h3><p>Click "Add User" to create one.</p></div>';
|
container.innerHTML = '<div class="empty-state"><h3>No users</h3><p>Click "Add User" to create one.</p></div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rows = admin.users.map((u) => `<tr>
|
const rows = admin.users.map((u) => {
|
||||||
|
const taskCount = admin.tasks.filter((t) => t.userId === u.id).length;
|
||||||
|
const taskBadge = taskCount > 0
|
||||||
|
? `<span class="badge bg-primary">${taskCount}</span>`
|
||||||
|
: `<span class="badge bg-secondary">0</span>`;
|
||||||
|
return `<tr>
|
||||||
<td>${esc(u.email)}</td><td>${esc(u.name)}</td><td>${esc(u.familyName)}</td>
|
<td>${esc(u.email)}</td><td>${esc(u.name)}</td><td>${esc(u.familyName)}</td>
|
||||||
<td>${esc(u.company || '-')}</td><td>${esc(u.role || '-')}</td>
|
<td>${esc(u.company || '-')}</td><td>${esc(u.role || '-')}</td>
|
||||||
|
<td class="text-center">${taskBadge}</td>
|
||||||
<td class="admin-table-actions">
|
<td class="admin-table-actions">
|
||||||
<button class="button button-small button-secondary" data-edit-user="${u.id}">Edit</button>
|
<button class="button button-small button-secondary" data-edit-user="${u.id}">Edit</button>
|
||||||
<button class="button button-small button-ghost" data-delete-user="${u.id}">Delete</button>
|
<button class="button button-small button-ghost" data-delete-user="${u.id}">Delete</button>
|
||||||
</td></tr>`).join('');
|
</td></tr>`;
|
||||||
|
}).join('');
|
||||||
container.innerHTML = `<table class="admin-table"><thead><tr>
|
container.innerHTML = `<table class="admin-table"><thead><tr>
|
||||||
<th>Email</th><th>Name</th><th>Family Name</th><th>Company</th><th>Role</th><th>Actions</th>
|
<th>Email</th><th>Name</th><th>Family Name</th><th>Company</th><th>Role</th><th>Tasks</th><th>Actions</th>
|
||||||
</tr></thead><tbody>${rows}</tbody></table>`;
|
</tr></thead><tbody>${rows}</tbody></table>`;
|
||||||
container.querySelectorAll('[data-edit-user]').forEach((b) => b.addEventListener('click', () => editUser(Number(b.dataset.editUser))));
|
container.querySelectorAll('[data-edit-user]').forEach((b) => b.addEventListener('click', () => editUser(Number(b.dataset.editUser))));
|
||||||
container.querySelectorAll('[data-delete-user]').forEach((b) => b.addEventListener('click', () => deleteUser(Number(b.dataset.deleteUser))));
|
container.querySelectorAll('[data-delete-user]').forEach((b) => b.addEventListener('click', () => deleteUser(Number(b.dataset.deleteUser))));
|
||||||
|
|||||||
+8
-12
@@ -1,13 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* API communication module. Centralizes fetch calls and service-worker
|
* API communication module. Centralizes fetch calls so network details stay
|
||||||
* registration so network details stay out of rendering and state logic.
|
* out of rendering and state logic. All JSON traffic from the frontend goes
|
||||||
|
* through `fetchJson()`, which prepends the versioned base path and unwraps
|
||||||
|
* structured error responses into regular thrown errors.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { API_BASE } from './constants.js';
|
import { API_BASE } from './constants.js';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Generic JSON fetcher. All frontend API calls pass through this function so
|
* Generic JSON fetcher. Prepends the API base path when the caller passes a
|
||||||
* error handling, header defaults, and base path are consistent everywhere.
|
* relative path, forwards headers, and parses the response body on success.
|
||||||
|
* Non-2xx responses raise an `Error` whose message is the server's `message`
|
||||||
|
* field when present, otherwise a generic status-code fallback.
|
||||||
*/
|
*/
|
||||||
export async function fetchJson(path, options = {}) {
|
export async function fetchJson(path, options = {}) {
|
||||||
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
|
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
|
||||||
@@ -40,11 +44,3 @@ export async function fetchJson(path, options = {}) {
|
|||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerServiceWorker() {
|
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
navigator.serviceWorker.register('/sw.js').catch((error) => {
|
|
||||||
console.error('Service worker registration failed', error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
/*
|
|
||||||
* CSV and attachment export module (F2). Generates a CSV file from the current
|
|
||||||
* report's answers and allows downloading individual attachments. XLSX and ZIP
|
|
||||||
* export can be added by integrating SheetJS and JSZip libraries.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { state, getCurrentReport, getTemplateRecord } from './state.js';
|
|
||||||
import { dbGetAllByIndex } from './db.js';
|
|
||||||
import { STORE_ATTACHMENTS } from './constants.js';
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Exports the active report as a CSV file. Columns are derived from the template
|
|
||||||
* definition so field labels appear as headers and field values as the row.
|
|
||||||
*/
|
|
||||||
export async function exportReportCSV() {
|
|
||||||
const report = getCurrentReport();
|
|
||||||
|
|
||||||
if (!report) {
|
|
||||||
throw new Error('No report to export');
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = getTemplateRecord(report.templateCode, report.templateVersion);
|
|
||||||
|
|
||||||
if (!template) {
|
|
||||||
throw new Error('Template definition needed for export');
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = [];
|
|
||||||
const values = [];
|
|
||||||
|
|
||||||
/* Meta columns. */
|
|
||||||
headers.push('Report Number', 'Template', 'Version', 'Status', 'Created', 'Updated');
|
|
||||||
values.push(
|
|
||||||
report.reportNumber,
|
|
||||||
report.templateCode,
|
|
||||||
String(report.templateVersion),
|
|
||||||
report.status,
|
|
||||||
report.createdAt,
|
|
||||||
report.updatedAt
|
|
||||||
);
|
|
||||||
|
|
||||||
/* Dynamic field columns derived from the template definition. */
|
|
||||||
for (const section of template.definition.sections || []) {
|
|
||||||
for (const field of section.fields || []) {
|
|
||||||
if (field.type === 'attachment') {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
headers.push(field.label);
|
|
||||||
const raw = report.answers[field.id];
|
|
||||||
values.push(raw === undefined || raw === null ? '' : String(raw));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const csvContent = [
|
|
||||||
headers.map(csvEscape).join(','),
|
|
||||||
values.map(csvEscape).join(',')
|
|
||||||
].join('\r\n');
|
|
||||||
|
|
||||||
downloadBlob(
|
|
||||||
new Blob([csvContent], { type: 'text/csv;charset=utf-8' }),
|
|
||||||
`${report.reportNumber || 'report'}.csv`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Exports all attachments for the active report as individual file downloads.
|
|
||||||
* A future iteration could bundle these into a ZIP archive using JSZip.
|
|
||||||
*/
|
|
||||||
export async function exportReportAttachments() {
|
|
||||||
const report = getCurrentReport();
|
|
||||||
|
|
||||||
if (!report) {
|
|
||||||
throw new Error('No report to export');
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachments = await dbGetAllByIndex(STORE_ATTACHMENTS, 'byReportId', report.id);
|
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
|
||||||
downloadBlob(attachment.blob, attachment.generatedFilename);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Helpers ────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function csvEscape(value) {
|
|
||||||
const str = String(value).replace(/"/g, '""');
|
|
||||||
return /[",\r\n]/.test(str) ? `"${str}"` : str;
|
|
||||||
}
|
|
||||||
|
|
||||||
function downloadBlob(blob, filename) {
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const anchor = document.createElement('a');
|
|
||||||
anchor.href = url;
|
|
||||||
anchor.download = filename;
|
|
||||||
document.body.appendChild(anchor);
|
|
||||||
anchor.click();
|
|
||||||
anchor.remove();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
/*
|
|
||||||
* Dynamic form field creation. Each function returns a DOM node tree for a
|
|
||||||
* single template field. The approach keeps template-driven rendering in one
|
|
||||||
* module while the orchestrator (app.js) provides callbacks for state mutations.
|
|
||||||
*
|
|
||||||
* Callback contract:
|
|
||||||
* onFieldChange(field, nextValue) — called when the user edits a field
|
|
||||||
* onAttachFiles(field, report, files) — called when files are selected
|
|
||||||
* onRemoveAttachment(attachmentId) — called to remove an attachment
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { evaluateRequiredWhen } from './validation.js';
|
|
||||||
import { formatFileSize } from './utils.js';
|
|
||||||
|
|
||||||
export function createFieldNode(field, report, { state, onFieldChange, onAttachFiles, onRemoveAttachment }) {
|
|
||||||
const wrapper = document.createElement('div');
|
|
||||||
wrapper.className = `field ${field.type === 'comment' || field.type === 'attachment' ? 'field-full' : ''}`;
|
|
||||||
|
|
||||||
const isRequired = Boolean(field.required || evaluateRequiredWhen(field.requiredWhen, report.answers));
|
|
||||||
const currentValue = report.answers[field.id];
|
|
||||||
|
|
||||||
wrapper.innerHTML = `
|
|
||||||
<div class="field-header">
|
|
||||||
<label class="field-label" for="field-${field.id}">${escapeHtml(field.label)}</label>
|
|
||||||
${isRequired ? '<span class="required-pill">Required</span>' : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
let inputNode;
|
|
||||||
|
|
||||||
switch (field.type) {
|
|
||||||
case 'text':
|
|
||||||
case 'date':
|
|
||||||
case 'number':
|
|
||||||
inputNode = document.createElement('input');
|
|
||||||
inputNode.className = 'text-input';
|
|
||||||
inputNode.id = `field-${field.id}`;
|
|
||||||
inputNode.name = field.id;
|
|
||||||
inputNode.type = field.type;
|
|
||||||
inputNode.value = currentValue ?? '';
|
|
||||||
inputNode.readOnly = Boolean(field.readOnly);
|
|
||||||
|
|
||||||
if (field.validation?.min !== undefined) {
|
|
||||||
inputNode.min = String(field.validation.min);
|
|
||||||
}
|
|
||||||
|
|
||||||
inputNode.addEventListener('input', (event) => {
|
|
||||||
onFieldChange(field, event.target.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'lookup': {
|
|
||||||
inputNode = document.createElement('select');
|
|
||||||
inputNode.className = 'select-input';
|
|
||||||
inputNode.id = `field-${field.id}`;
|
|
||||||
inputNode.name = field.id;
|
|
||||||
|
|
||||||
const emptyOption = document.createElement('option');
|
|
||||||
emptyOption.value = '';
|
|
||||||
emptyOption.textContent = 'Select an option';
|
|
||||||
inputNode.append(emptyOption);
|
|
||||||
|
|
||||||
const lookup = state.lookups.get(field.lookupCode);
|
|
||||||
|
|
||||||
for (const optionData of lookup?.values || []) {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = optionData.value;
|
|
||||||
option.textContent = optionData.label;
|
|
||||||
option.selected = optionData.value === currentValue;
|
|
||||||
inputNode.append(option);
|
|
||||||
}
|
|
||||||
|
|
||||||
inputNode.addEventListener('change', (event) => {
|
|
||||||
onFieldChange(field, event.target.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'checkbox':
|
|
||||||
inputNode = document.createElement('label');
|
|
||||||
inputNode.className = 'checkbox-row';
|
|
||||||
inputNode.innerHTML = `
|
|
||||||
<input id="field-${field.id}" name="${field.id}" type="checkbox" ${currentValue ? 'checked' : ''} />
|
|
||||||
<span>${escapeHtml(field.label)}</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
inputNode.querySelector('input').addEventListener('change', (event) => {
|
|
||||||
onFieldChange(field, event.target.checked);
|
|
||||||
});
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'comment':
|
|
||||||
inputNode = document.createElement('textarea');
|
|
||||||
inputNode.className = 'text-area';
|
|
||||||
inputNode.id = `field-${field.id}`;
|
|
||||||
inputNode.name = field.id;
|
|
||||||
inputNode.maxLength = field.maxLength || 5000;
|
|
||||||
inputNode.value = currentValue ?? '';
|
|
||||||
|
|
||||||
inputNode.addEventListener('input', (event) => {
|
|
||||||
onFieldChange(field, event.target.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'attachment':
|
|
||||||
inputNode = createAttachmentFieldNode(field, report, { state, onAttachFiles, onRemoveAttachment });
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
inputNode = document.createElement('input');
|
|
||||||
inputNode.className = 'text-input';
|
|
||||||
inputNode.id = `field-${field.id}`;
|
|
||||||
inputNode.name = field.id;
|
|
||||||
inputNode.type = 'text';
|
|
||||||
inputNode.value = currentValue ?? '';
|
|
||||||
|
|
||||||
inputNode.addEventListener('input', (event) => {
|
|
||||||
onFieldChange(field, event.target.value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
wrapper.append(inputNode);
|
|
||||||
|
|
||||||
if (field.requiredWhen?.field) {
|
|
||||||
const help = document.createElement('p');
|
|
||||||
help.className = 'field-help';
|
|
||||||
help.textContent = `Required when ${field.requiredWhen.field} is ${String(field.requiredWhen.equals)}.`;
|
|
||||||
wrapper.append(help);
|
|
||||||
}
|
|
||||||
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Attachment field ───────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function createAttachmentFieldNode(field, report, { state, onAttachFiles, onRemoveAttachment }) {
|
|
||||||
const container = document.createElement('div');
|
|
||||||
container.className = 'attachment-list';
|
|
||||||
|
|
||||||
const toolbar = document.createElement('div');
|
|
||||||
toolbar.className = 'attachment-toolbar';
|
|
||||||
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.className = 'file-input';
|
|
||||||
input.id = `field-${field.id}`;
|
|
||||||
input.type = 'file';
|
|
||||||
input.accept = state.imageRules?.allowedMimeTypes?.join(',') || 'image/*';
|
|
||||||
input.multiple = true;
|
|
||||||
|
|
||||||
input.addEventListener('change', async (event) => {
|
|
||||||
const files = Array.from(event.target.files || []);
|
|
||||||
await onAttachFiles(field, report, files);
|
|
||||||
event.target.value = '';
|
|
||||||
});
|
|
||||||
|
|
||||||
toolbar.append(input);
|
|
||||||
container.append(toolbar);
|
|
||||||
|
|
||||||
/*
|
|
||||||
* P3 — Lazy-load attachment previews. Thumbnails are created from object URLs
|
|
||||||
* on demand using IntersectionObserver. Only attachments scrolled into view
|
|
||||||
* allocate a Blob URL, keeping memory use proportional to the visible area.
|
|
||||||
*/
|
|
||||||
const attachments = state.currentAttachments.filter((item) => item.fieldId === field.id);
|
|
||||||
|
|
||||||
if (!attachments.length) {
|
|
||||||
const hint = document.createElement('p');
|
|
||||||
hint.className = 'field-help';
|
|
||||||
hint.textContent = 'No images attached yet.';
|
|
||||||
container.append(hint);
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.isIntersecting) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const img = entry.target;
|
|
||||||
const attachment = attachments.find((a) => a.id === img.dataset.attachmentId);
|
|
||||||
|
|
||||||
if (attachment?.blob) {
|
|
||||||
const objectUrl = URL.createObjectURL(attachment.blob);
|
|
||||||
img.src = objectUrl;
|
|
||||||
img.addEventListener('load', () => URL.revokeObjectURL(objectUrl), { once: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
observer.unobserve(img);
|
|
||||||
}
|
|
||||||
}, { rootMargin: '200px' });
|
|
||||||
|
|
||||||
for (const attachment of attachments) {
|
|
||||||
const card = document.createElement('article');
|
|
||||||
card.className = 'attachment-card';
|
|
||||||
|
|
||||||
const preview = document.createElement('img');
|
|
||||||
preview.className = 'attachment-preview';
|
|
||||||
preview.alt = attachment.generatedFilename;
|
|
||||||
preview.dataset.attachmentId = attachment.id;
|
|
||||||
/* Actual src loaded lazily by the IntersectionObserver above. */
|
|
||||||
|
|
||||||
observer.observe(preview);
|
|
||||||
|
|
||||||
const copy = document.createElement('div');
|
|
||||||
copy.className = 'attachment-card__copy';
|
|
||||||
copy.innerHTML = `
|
|
||||||
<strong>${escapeHtml(attachment.generatedFilename)}</strong>
|
|
||||||
<span>${escapeHtml(attachment.originalFilename)}</span>
|
|
||||||
<span>${attachment.width}x${attachment.height}px • ${formatFileSize(attachment.sizeBytes)}</span>
|
|
||||||
`;
|
|
||||||
|
|
||||||
const removeButton = document.createElement('button');
|
|
||||||
removeButton.type = 'button';
|
|
||||||
removeButton.className = 'button button-small button-ghost';
|
|
||||||
removeButton.textContent = 'Remove';
|
|
||||||
|
|
||||||
removeButton.addEventListener('click', () => {
|
|
||||||
void onRemoveAttachment(attachment.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
card.append(preview, copy, removeButton);
|
|
||||||
container.append(card);
|
|
||||||
}
|
|
||||||
|
|
||||||
return container;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Helpers ────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
/*
|
|
||||||
* Lightweight i18n module. All user-facing strings are collected here so the app
|
|
||||||
* can be translated by swapping or extending the locale object. The current
|
|
||||||
* implementation is English-only; a future iteration could load locale files
|
|
||||||
* dynamically based on a user preference.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const en = {
|
|
||||||
/* General */
|
|
||||||
appTitle: 'Check List',
|
|
||||||
appTagline: 'Offline-first proof of concept for template-driven quality reports.',
|
|
||||||
checkingConnection: 'Checking connection',
|
|
||||||
online: 'Online',
|
|
||||||
offline: 'Offline',
|
|
||||||
noChanges: 'No changes',
|
|
||||||
|
|
||||||
/* Sync */
|
|
||||||
syncingTemplates: 'Syncing templates',
|
|
||||||
templatesSynced: 'Templates synced',
|
|
||||||
syncFailed: 'Sync failed, using cache',
|
|
||||||
offlineMode: 'Offline mode',
|
|
||||||
|
|
||||||
/* Reports */
|
|
||||||
noReportSelected: 'No report selected',
|
|
||||||
noReportSelectedHint: 'Start by syncing templates and creating a local draft.',
|
|
||||||
noLocalReports: 'No local reports yet.',
|
|
||||||
selectTemplate: 'Select a template first',
|
|
||||||
templateNotAvailable: 'Template not available locally',
|
|
||||||
newReportCreated: 'New report created',
|
|
||||||
reportDeleted: 'Report deleted',
|
|
||||||
deleteReportConfirm: 'Delete local report {0}?',
|
|
||||||
draftUpdated: 'Draft updated',
|
|
||||||
statusChanged: 'Status changed',
|
|
||||||
saved: 'Saved {0}',
|
|
||||||
|
|
||||||
/* Submission */
|
|
||||||
submitting: 'Submitting report…',
|
|
||||||
submitted: 'Report submitted to server',
|
|
||||||
submitFailed: 'Submission failed',
|
|
||||||
goOnlineToSubmit: 'Go online to submit a report',
|
|
||||||
|
|
||||||
/* Validation */
|
|
||||||
readyForExport: 'Ready for export validation',
|
|
||||||
noBlockingIssues: 'No blocking validation issues detected for the current draft.',
|
|
||||||
noValidationIssues: 'No validation issues.',
|
|
||||||
issueCount: '{0} issue(s) to resolve',
|
|
||||||
issueReadyWarning: 'The report is marked ready, but validation still has blocking items.',
|
|
||||||
issueDraftHint: 'Draft save is still allowed, but export should be blocked until these issues are fixed.',
|
|
||||||
templateUnavailable: 'Template unavailable',
|
|
||||||
validationNeedsTemplate: 'Validation cannot run until the template is cached again.',
|
|
||||||
required: 'value is required.',
|
|
||||||
numberInvalid: 'number is invalid.',
|
|
||||||
numberMin: 'must be at least {0}.',
|
|
||||||
imageRequired: 'at least one image is required.',
|
|
||||||
templateMissing: 'Template definition missing for this report version.',
|
|
||||||
|
|
||||||
/* Attachments */
|
|
||||||
noImagesAttached: 'No images attached yet.',
|
|
||||||
maxAttachments: 'Only {0} attachment(s) allowed',
|
|
||||||
imageSkipped: 'Image skipped: {0}',
|
|
||||||
imagesUpdated: 'Images updated',
|
|
||||||
attachmentRemoved: 'Attachment removed',
|
|
||||||
unsupportedFileType: 'Unsupported file type: {0}',
|
|
||||||
fileExceedsLimit: 'File exceeds limit: {0}',
|
|
||||||
optimizeFailed: 'Failed to optimize image: {0}',
|
|
||||||
optimizedStillExceeds: 'Optimized image still exceeds limit: {0}',
|
|
||||||
|
|
||||||
/* Admin */
|
|
||||||
savingImagePolicy: 'Saving image policy',
|
|
||||||
imagePolicySaved: 'Image policy saved',
|
|
||||||
goOnlineToSave: 'Go online to save admin settings',
|
|
||||||
policyNameRequired: 'Policy name is required',
|
|
||||||
addMimeType: 'Add at least one MIME type',
|
|
||||||
maxFileSizePositive: 'Max file size must be greater than 0',
|
|
||||||
maxWidthPositive: 'Max width must be a positive number',
|
|
||||||
maxHeightPositive: 'Max height must be a positive number',
|
|
||||||
jpegQualityRange: 'JPEG quality must be between 1 and 100',
|
|
||||||
maxAttachmentsPositive: 'Max attachments must be a positive number',
|
|
||||||
|
|
||||||
/* Template management */
|
|
||||||
publishingVersion: 'Publishing version…',
|
|
||||||
versionPublished: 'Template version published',
|
|
||||||
publishFailed: 'Failed to publish version',
|
|
||||||
|
|
||||||
/* Export */
|
|
||||||
exportStarted: 'Preparing export…',
|
|
||||||
exportComplete: 'Export ready — file downloaded',
|
|
||||||
exportFailed: 'Export failed',
|
|
||||||
noReportToExport: 'Open a report before exporting',
|
|
||||||
noTemplateForExport: 'Template definition needed for export',
|
|
||||||
|
|
||||||
/* Search */
|
|
||||||
searchPlaceholder: 'Search reports…',
|
|
||||||
|
|
||||||
/* Misc */
|
|
||||||
noTemplatesCached: 'No cached templates available',
|
|
||||||
liveConfig: 'Live server configuration',
|
|
||||||
offlineCachedConfig: 'Offline cached configuration',
|
|
||||||
noImageRulesLoaded: 'No image rules loaded',
|
|
||||||
imagePolicyHint: 'Load server configuration to see image limits and optimization rules.'
|
|
||||||
};
|
|
||||||
|
|
||||||
let currentLocale = en;
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Retrieve a translated string by key. Optional positional parameters replace
|
|
||||||
* {0}, {1}, etc. placeholders.
|
|
||||||
*/
|
|
||||||
export function t(key, ...params) {
|
|
||||||
let text = currentLocale[key] ?? key;
|
|
||||||
|
|
||||||
for (let i = 0; i < params.length; i++) {
|
|
||||||
text = text.replace(`{${i}}`, String(params[i]));
|
|
||||||
}
|
|
||||||
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setLocale(locale) {
|
|
||||||
currentLocale = { ...en, ...locale };
|
|
||||||
}
|
|
||||||
@@ -1,389 +0,0 @@
|
|||||||
/*
|
|
||||||
* Rendering module. All functions that manipulate the visible DOM live here.
|
|
||||||
* Each render function reads from the shared state and writes to the cached
|
|
||||||
* element references. The module has no side effects beyond DOM mutation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { state, elements, getCurrentReport, getTemplateRecord } from './state.js';
|
|
||||||
import { createFieldNode } from './forms.js';
|
|
||||||
import { validateReport } from './validation.js';
|
|
||||||
import {
|
|
||||||
formatDateTime,
|
|
||||||
formatTime,
|
|
||||||
formatRelativeTime,
|
|
||||||
formatFileSize,
|
|
||||||
prettifyStatus,
|
|
||||||
badgeClassForTone
|
|
||||||
} from './utils.js';
|
|
||||||
import { t } from './i18n.js';
|
|
||||||
|
|
||||||
/* ── Top-level render orchestrator ──────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function render(fieldCallbacks) {
|
|
||||||
renderTemplateSelector();
|
|
||||||
|
|
||||||
/* User workspace renders — only when the report editor DOM exists. */
|
|
||||||
if (elements.reportForm) {
|
|
||||||
renderReportList();
|
|
||||||
renderTemplateSummary();
|
|
||||||
renderCurrentReport(fieldCallbacks);
|
|
||||||
renderSyncSummary();
|
|
||||||
renderImagePolicy();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Admin workspace renders — only when the admin form DOM exists. */
|
|
||||||
if (elements.adminImageRulesForm) {
|
|
||||||
renderAdminImageRules();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Template selector ──────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function renderTemplateSelector() {
|
|
||||||
const currentValue = state.selectedTemplateCode;
|
|
||||||
elements.templateSelect.innerHTML = '';
|
|
||||||
|
|
||||||
if (!state.templatesCatalog.length) {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = '';
|
|
||||||
option.textContent = t('noTemplatesCached');
|
|
||||||
elements.templateSelect.append(option);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const template of state.templatesCatalog) {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = template.code;
|
|
||||||
option.textContent = `${template.name} (v${template.activeVersion})`;
|
|
||||||
option.selected = template.code === currentValue;
|
|
||||||
elements.templateSelect.append(option);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Report list (F4 — with search & status filter) ─────────────────────── */
|
|
||||||
|
|
||||||
export function renderReportList() {
|
|
||||||
if (!elements.reportList) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.reportList.innerHTML = '';
|
|
||||||
|
|
||||||
/* Apply search query and status filter. */
|
|
||||||
let filtered = state.reports;
|
|
||||||
|
|
||||||
if (state.reportSearchQuery) {
|
|
||||||
const q = state.reportSearchQuery.toLowerCase();
|
|
||||||
filtered = filtered.filter((r) => {
|
|
||||||
const num = (r.answers?.reportNumber || r.reportNumber || '').toLowerCase();
|
|
||||||
const title = (r.title || '').toLowerCase();
|
|
||||||
return num.includes(q) || title.includes(q);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.reportFilterStatus) {
|
|
||||||
filtered = filtered.filter((r) => r.status === state.reportFilterStatus);
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.reportCount.textContent = String(filtered.length);
|
|
||||||
|
|
||||||
if (!filtered.length) {
|
|
||||||
const empty = document.createElement('p');
|
|
||||||
empty.className = 'field-help';
|
|
||||||
empty.textContent = state.reports.length ? 'No reports match the current filter.' : t('noLocalReports');
|
|
||||||
elements.reportList.append(empty);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const report of filtered) {
|
|
||||||
const fragment = elements.reportListItemTemplate.content.cloneNode(true);
|
|
||||||
const button = fragment.querySelector('.report-list-item');
|
|
||||||
const title = fragment.querySelector('.report-list-item__title');
|
|
||||||
const statusEl = fragment.querySelector('.report-list-item__status');
|
|
||||||
const meta = fragment.querySelector('.report-list-item__meta');
|
|
||||||
|
|
||||||
title.textContent = report.answers.reportNumber || report.reportNumber;
|
|
||||||
statusEl.textContent = prettifyStatus(report.status);
|
|
||||||
statusEl.classList.add(`status-${report.status}`);
|
|
||||||
meta.textContent = `${report.title} • Updated ${formatDateTime(report.updatedAt)}`;
|
|
||||||
|
|
||||||
if (report.id === state.currentReportId) {
|
|
||||||
button.classList.add('is-active');
|
|
||||||
}
|
|
||||||
|
|
||||||
button.dataset.reportId = report.id;
|
|
||||||
elements.reportList.append(button);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Template summary cards ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function renderTemplateSummary() {
|
|
||||||
if (!elements.summaryTemplate) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const report = getCurrentReport();
|
|
||||||
const templateCode = report?.templateCode || state.selectedTemplateCode;
|
|
||||||
const catalogEntry = state.templatesCatalog.find((item) => item.code === templateCode);
|
|
||||||
|
|
||||||
if (!catalogEntry) {
|
|
||||||
elements.summaryTemplate.textContent = 'Not loaded';
|
|
||||||
elements.summaryVersion.textContent = 'Version -';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.summaryTemplate.textContent = catalogEntry.name;
|
|
||||||
elements.summaryVersion.textContent = report
|
|
||||||
? `Version ${report.templateVersion}`
|
|
||||||
: `Version ${catalogEntry.activeVersion}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderSyncSummary() {
|
|
||||||
if (!elements.syncHeadline) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.syncHeadline.textContent = state.lastSyncAt
|
|
||||||
? `Last sync ${formatRelativeTime(state.lastSyncAt)}`
|
|
||||||
: 'No sync yet';
|
|
||||||
elements.syncDetail.textContent = state.lastSyncAt
|
|
||||||
? `Cached template data from ${formatDateTime(state.lastSyncAt)}`
|
|
||||||
: 'Templates are cached locally after the first successful sync.';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Image policy ───────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function renderImagePolicy() {
|
|
||||||
if (!elements.imagePolicyText) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.imageRules) {
|
|
||||||
elements.imagePolicyText.textContent = t('imagePolicyHint');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.imagePolicyText.textContent = `${state.imageRules.name}: ${state.imageRules.allowedMimeTypes.join(
|
|
||||||
', '
|
|
||||||
)}, max ${formatFileSize(state.imageRules.maxFileSizeBytes)}, ${state.imageRules.maxWidthPx}x${state.imageRules.maxHeightPx}px, behavior ${state.imageRules.oversizeBehavior}.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Admin image rules ──────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function renderAdminImageRules() {
|
|
||||||
if (!elements.adminSyncState) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
populateAdminImageRulesForm(state.imageRules);
|
|
||||||
|
|
||||||
if (!state.imageRules) {
|
|
||||||
elements.adminSyncState.textContent = t('noImageRulesLoaded');
|
|
||||||
elements.adminSyncState.className = 'badge badge-offline';
|
|
||||||
elements.adminPolicyCode.textContent = '-';
|
|
||||||
elements.adminPolicyMimeTypes.textContent = '-';
|
|
||||||
elements.adminPolicyOptimization.textContent = '-';
|
|
||||||
elements.adminPolicyLimits.textContent = '-';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.adminSyncState.textContent = navigator.onLine ? t('liveConfig') : t('offlineCachedConfig');
|
|
||||||
elements.adminSyncState.className = `badge ${navigator.onLine ? 'badge-online' : 'badge-offline'}`;
|
|
||||||
elements.adminPolicyCode.textContent = state.imageRules.code;
|
|
||||||
elements.adminPolicyMimeTypes.textContent = state.imageRules.allowedMimeTypes.join(', ');
|
|
||||||
elements.adminPolicyOptimization.textContent = `${state.imageRules.oversizeBehavior}, JPEG ${state.imageRules.jpegQuality}%`;
|
|
||||||
elements.adminPolicyLimits.textContent = `${formatFileSize(state.imageRules.maxFileSizeBytes)}, ${state.imageRules.maxWidthPx}x${state.imageRules.maxHeightPx}px, ${state.imageRules.maxAttachmentsPerField} attachment(s)`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function populateAdminImageRulesForm(imageRules) {
|
|
||||||
if (!imageRules || !elements.adminPolicyName) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.adminPolicyName.value = imageRules.name || '';
|
|
||||||
elements.adminAllowedMimeTypes.value = (imageRules.allowedMimeTypes || []).join(', ');
|
|
||||||
elements.adminMaxFileSizeMb.value = String((Number(imageRules.maxFileSizeBytes || 0) / (1024 * 1024)).toFixed(1));
|
|
||||||
elements.adminMaxAttachmentsPerField.value = String(imageRules.maxAttachmentsPerField || 1);
|
|
||||||
elements.adminMaxWidthPx.value = String(imageRules.maxWidthPx || '');
|
|
||||||
elements.adminMaxHeightPx.value = String(imageRules.maxHeightPx || '');
|
|
||||||
elements.adminJpegQuality.value = String(imageRules.jpegQuality || '');
|
|
||||||
elements.adminOversizeBehavior.value = imageRules.oversizeBehavior || 'auto_optimize';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Current report ─────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/*
|
|
||||||
* `renderCurrentReport` accepts a `fieldCallbacks` object so the form module
|
|
||||||
* can invoke actions (field change, attach, remove) owned by the orchestrator
|
|
||||||
* without creating circular imports.
|
|
||||||
*/
|
|
||||||
export function renderCurrentReport(fieldCallbacks = {}) {
|
|
||||||
if (!elements.reportForm) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const report = getCurrentReport();
|
|
||||||
|
|
||||||
if (!report) {
|
|
||||||
elements.heroTitle.textContent = t('noReportSelected');
|
|
||||||
elements.heroSubtitle.textContent = t('noReportSelectedHint');
|
|
||||||
elements.reportStatusSelect.value = 'draft';
|
|
||||||
elements.reportStatusSelect.disabled = true;
|
|
||||||
elements.deleteReportButton.disabled = true;
|
|
||||||
if (elements.submitReportButton) elements.submitReportButton.disabled = true;
|
|
||||||
if (elements.exportReportButton) elements.exportReportButton.disabled = true;
|
|
||||||
elements.editorHint.textContent = 'Dynamic form rendering from template JSON';
|
|
||||||
elements.reportForm.innerHTML = `
|
|
||||||
<div class="empty-state">
|
|
||||||
<h3>No report open</h3>
|
|
||||||
<p>Choose a template and create a report to start editing locally.</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
renderMeta(null);
|
|
||||||
renderValidation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = getTemplateRecord(report.templateCode, report.templateVersion);
|
|
||||||
elements.reportStatusSelect.disabled = false;
|
|
||||||
elements.reportStatusSelect.value = report.status;
|
|
||||||
elements.deleteReportButton.disabled = false;
|
|
||||||
if (elements.submitReportButton) elements.submitReportButton.disabled = false;
|
|
||||||
if (elements.exportReportButton) elements.exportReportButton.disabled = false;
|
|
||||||
elements.heroTitle.textContent = report.answers.reportNumber || report.reportNumber;
|
|
||||||
elements.heroSubtitle.textContent = `${template?.name || report.templateCode} • Local draft bound to template version ${report.templateVersion}`;
|
|
||||||
elements.editorHint.textContent = `${state.currentAttachments.length} attachment(s) stored in IndexedDB for this report`;
|
|
||||||
|
|
||||||
if (!template) {
|
|
||||||
elements.reportForm.innerHTML = `
|
|
||||||
<div class="empty-state">
|
|
||||||
<h3>Template missing</h3>
|
|
||||||
<p>This draft is bound to template version ${report.templateVersion}, but that definition is not cached locally.</p>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
renderMeta(report);
|
|
||||||
renderValidation();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.reportForm.innerHTML = '';
|
|
||||||
|
|
||||||
for (const section of template.definition.sections || []) {
|
|
||||||
const sectionNode = document.createElement('section');
|
|
||||||
sectionNode.className = 'template-section';
|
|
||||||
|
|
||||||
const heading = document.createElement('div');
|
|
||||||
heading.className = 'section-heading-row';
|
|
||||||
heading.innerHTML = `<h3>${escapeHtml(section.title)}</h3><span class="panel-note">${section.fields.length} field(s)</span>`;
|
|
||||||
sectionNode.append(heading);
|
|
||||||
|
|
||||||
const fieldGrid = document.createElement('div');
|
|
||||||
fieldGrid.className = 'field-grid';
|
|
||||||
|
|
||||||
for (const field of section.fields || []) {
|
|
||||||
fieldGrid.append(createFieldNode(field, report, {
|
|
||||||
state,
|
|
||||||
onFieldChange: fieldCallbacks.onFieldChange || (() => {}),
|
|
||||||
onAttachFiles: fieldCallbacks.onAttachFiles || (() => {}),
|
|
||||||
onRemoveAttachment: fieldCallbacks.onRemoveAttachment || (() => {})
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
sectionNode.append(fieldGrid);
|
|
||||||
elements.reportForm.append(sectionNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
renderMeta(report);
|
|
||||||
renderValidation();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Meta & validation ──────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function renderMeta(report) {
|
|
||||||
if (!elements.reportMeta) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const values = report
|
|
||||||
? [
|
|
||||||
report.id,
|
|
||||||
`${report.templateCode} v${report.templateVersion}`,
|
|
||||||
formatDateTime(report.createdAt),
|
|
||||||
formatDateTime(report.updatedAt)
|
|
||||||
]
|
|
||||||
: ['-', '-', '-', '-'];
|
|
||||||
|
|
||||||
elements.reportMeta.querySelectorAll('dd').forEach((node, index) => {
|
|
||||||
node.textContent = values[index];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderValidation() {
|
|
||||||
if (!elements.validationHeadline) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const report = getCurrentReport();
|
|
||||||
|
|
||||||
if (!report) {
|
|
||||||
elements.validationHeadline.textContent = t('noReportSelected');
|
|
||||||
elements.validationDetail.textContent = 'Draft validation will appear here.';
|
|
||||||
elements.validationList.innerHTML = '<li>No report selected.</li>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const template = getTemplateRecord(report.templateCode, report.templateVersion);
|
|
||||||
|
|
||||||
if (!template) {
|
|
||||||
elements.validationHeadline.textContent = t('templateUnavailable');
|
|
||||||
elements.validationDetail.textContent = t('validationNeedsTemplate');
|
|
||||||
elements.validationList.innerHTML = `<li>${t('templateMissing')}</li>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const issues = validateReport(report, template, state.currentAttachments);
|
|
||||||
|
|
||||||
if (!issues.length) {
|
|
||||||
elements.validationHeadline.textContent = t('readyForExport');
|
|
||||||
elements.validationDetail.textContent = t('noBlockingIssues');
|
|
||||||
elements.validationList.innerHTML = `<li>${t('noValidationIssues')}</li>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.validationHeadline.textContent = t('issueCount', issues.length);
|
|
||||||
elements.validationDetail.textContent =
|
|
||||||
report.status === 'ready_for_export' ? t('issueReadyWarning') : t('issueDraftHint');
|
|
||||||
elements.validationList.innerHTML = '';
|
|
||||||
|
|
||||||
for (const issue of issues) {
|
|
||||||
const item = document.createElement('li');
|
|
||||||
item.textContent = issue;
|
|
||||||
elements.validationList.append(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Badge helpers ──────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function updateConnectionBadge() {
|
|
||||||
if (navigator.onLine) {
|
|
||||||
elements.connectionBadge.textContent = t('online');
|
|
||||||
elements.connectionBadge.className = 'badge badge-online';
|
|
||||||
} else {
|
|
||||||
elements.connectionBadge.textContent = t('offline');
|
|
||||||
elements.connectionBadge.className = 'badge badge-offline';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function updateSaveBadge(text, tone) {
|
|
||||||
elements.saveBadge.textContent = text;
|
|
||||||
elements.saveBadge.className = `badge ${badgeClassForTone(tone)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Helpers ────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function escapeHtml(text) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = text;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
+122
-8
@@ -53,11 +53,30 @@ const bulkImagesStore = new Map();
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* Loads all admin entity data from the server bulk endpoint and caches in IndexedDB.
|
* Loads all admin entity data from the server bulk endpoint and caches in IndexedDB.
|
||||||
|
*
|
||||||
|
* Failure handling:
|
||||||
|
* 401 — Session is gone (e.g. server restarted). Redirect to login so the
|
||||||
|
* user can re-authenticate and return with a fresh session.
|
||||||
|
* Other non-2xx — Transient server/network problem. Fall back to the last
|
||||||
|
* successful snapshot stored in IndexedDB so the user can still see
|
||||||
|
* their assigned tasks.
|
||||||
*/
|
*/
|
||||||
async function loadFromServer() {
|
async function loadFromServer() {
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/api/v1/admin/all', { headers: { Accept: 'application/json' } });
|
const resp = await fetch('/api/v1/admin/all', { headers: { Accept: 'application/json' } });
|
||||||
if (!resp.ok) return;
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
if (resp.status === 401) {
|
||||||
|
/* Session expired or server restarted — must re-authenticate. */
|
||||||
|
window.location.href = '/login-user';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
/* Other server error — use last cached snapshot so tasks remain visible. */
|
||||||
|
console.warn('Server returned', resp.status, '— falling back to IndexedDB cache.');
|
||||||
|
await loadFromCache();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
await dbPut(STORE_CONFIG, { key: 'admin_all', value: data });
|
await dbPut(STORE_CONFIG, { key: 'admin_all', value: data });
|
||||||
userState.templateSettings = data.templateSettings || { categories: [], subCategories: [], severities: [], statuses: [], handledBy: [] };
|
userState.templateSettings = data.templateSettings || { categories: [], subCategories: [], severities: [], statuses: [], handledBy: [] };
|
||||||
@@ -68,7 +87,9 @@ async function loadFromServer() {
|
|||||||
userState.clTemplates = data.clTemplates || [];
|
userState.clTemplates = data.clTemplates || [];
|
||||||
userState.tasks = data.tasks || [];
|
userState.tasks = data.tasks || [];
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
/* Network failure (offline, DNS, etc.) — fall back to cache. */
|
||||||
console.warn('Failed to load data from server, using IndexedDB cache', err);
|
console.warn('Failed to load data from server, using IndexedDB cache', err);
|
||||||
|
await loadFromCache();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +149,30 @@ async function loadAllData() {
|
|||||||
userState.taskData = await loadTaskData();
|
userState.taskData = await loadTaskData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Manually triggered synchronization with the server.
|
||||||
|
* Called when the user presses the "Sync" button.
|
||||||
|
* Fetches fresh data, re-applies the user filter, and re-renders task lists.
|
||||||
|
*/
|
||||||
|
async function forceSyncWithServer() {
|
||||||
|
const btn = document.getElementById('syncBtn');
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Syncing…';
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await loadFromServer();
|
||||||
|
filterTasksByUser();
|
||||||
|
renderTaskListView();
|
||||||
|
renderSidebarTasks();
|
||||||
|
} finally {
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="bi bi-arrow-repeat me-1"></i>Sync';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function filterTasksByUser() {
|
function filterTasksByUser() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const userId = params.get('userId');
|
const userId = params.get('userId');
|
||||||
@@ -142,6 +187,7 @@ function bindEvents() {
|
|||||||
document.getElementById('saveDraftBtn')?.addEventListener('click', saveDraft);
|
document.getElementById('saveDraftBtn')?.addEventListener('click', saveDraft);
|
||||||
document.getElementById('saveFinalBtn')?.addEventListener('click', saveFinal);
|
document.getElementById('saveFinalBtn')?.addEventListener('click', saveFinal);
|
||||||
document.getElementById('showSettingsBtn')?.addEventListener('click', showSettingsView);
|
document.getElementById('showSettingsBtn')?.addEventListener('click', showSettingsView);
|
||||||
|
document.getElementById('syncBtn')?.addEventListener('click', forceSyncWithServer);
|
||||||
document.getElementById('closeSettingsBtn')?.addEventListener('click', closeSettingsView);
|
document.getElementById('closeSettingsBtn')?.addEventListener('click', closeSettingsView);
|
||||||
document.getElementById('recordSearchInput')?.addEventListener('input', onSearchInput);
|
document.getElementById('recordSearchInput')?.addEventListener('input', onSearchInput);
|
||||||
|
|
||||||
@@ -203,16 +249,23 @@ async function showListView() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showDetailView(taskId) {
|
function showDetailView(taskId) {
|
||||||
userState.currentTaskId = Number(taskId);
|
/* Always work with a numeric ID. dataset attributes and sidebar event handlers
|
||||||
|
may pass either a string or a number depending on the call site. Normalizing
|
||||||
|
here keeps every downstream function consistent. */
|
||||||
|
const id = Number(taskId);
|
||||||
|
userState.currentTaskId = id;
|
||||||
userState.activeCategory = null;
|
userState.activeCategory = null;
|
||||||
userState.searchQuery = '';
|
userState.searchQuery = '';
|
||||||
const searchInput = document.getElementById('recordSearchInput');
|
const searchInput = document.getElementById('recordSearchInput');
|
||||||
if (searchInput) searchInput.value = '';
|
if (searchInput) searchInput.value = '';
|
||||||
hideAllViews();
|
hideAllViews();
|
||||||
document.getElementById('taskDetailView').classList.add('workspace-view-active');
|
document.getElementById('taskDetailView').classList.add('workspace-view-active');
|
||||||
/* If task was reopened and images were stripped, try to re-download from server */
|
/* If task was reopened and images were stripped, try to re-download from server.
|
||||||
maybeDownloadImages(taskId).then(() => renderTaskDetail());
|
Chain: hydrate values first → then fetch image blobs → then render. */
|
||||||
highlightSidebarTask(taskId);
|
maybeHydrateFromServer(id)
|
||||||
|
.then(() => maybeDownloadImages(id))
|
||||||
|
.then(() => renderTaskDetail());
|
||||||
|
highlightSidebarTask(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -1274,15 +1327,76 @@ function stripImageDataFromStorage(taskId) {
|
|||||||
updateStorageIndicator();
|
updateStorageIndicator();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
* Hydrate task data from server when IndexedDB has no local copy
|
||||||
|
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If the browser's IndexedDB has no record data for this task (cleared storage,
|
||||||
|
* first access on a new device, or fresh browser profile), fetch the last
|
||||||
|
* submitted report from the server and seed the local state so previously
|
||||||
|
* filled values are visible without the user having to re-enter them.
|
||||||
|
*
|
||||||
|
* Images are not re-embedded here — they are pointed to the server with
|
||||||
|
* uploadedToServer:true so that maybeDownloadImages (called next in the chain)
|
||||||
|
* can fetch the actual blobs.
|
||||||
|
*/
|
||||||
|
async function maybeHydrateFromServer(taskId) {
|
||||||
|
const id = Number(taskId);
|
||||||
|
|
||||||
|
/* Skip if IndexedDB already has record data for this task. */
|
||||||
|
const existing = userState.taskData[id];
|
||||||
|
if (existing && Object.keys(existing.records || {}).length > 0) return;
|
||||||
|
|
||||||
|
if (!navigator.onLine) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/api/v1/reports/${id}`, { headers: { Accept: 'application/json' } });
|
||||||
|
if (!resp.ok) return; /* 404 = task has never been saved — empty form is correct */
|
||||||
|
|
||||||
|
const report = await resp.json();
|
||||||
|
if (!report?.answers?.records) return;
|
||||||
|
|
||||||
|
/* Rebuild records, keeping all field values but replacing image dataUrls with
|
||||||
|
uploadedToServer markers so the image-download step can restore them. */
|
||||||
|
const hydratedRecords = {};
|
||||||
|
for (const [recId, rd] of Object.entries(report.answers.records)) {
|
||||||
|
hydratedRecords[recId] = {
|
||||||
|
status: rd.status || '',
|
||||||
|
handledBy: rd.handledBy || '',
|
||||||
|
comment: rd.comment || '',
|
||||||
|
images: (rd.images || []).map(img => ({
|
||||||
|
name: img.name || '',
|
||||||
|
size: img.size || 0,
|
||||||
|
uploadedToServer: true
|
||||||
|
/* dataUrl intentionally omitted — fetched by maybeDownloadImages */
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
userState.taskData[id] = {
|
||||||
|
visitDate: report.answers.visitDate || '',
|
||||||
|
records: hydratedRecords
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Persist to IndexedDB so future visits within the same browser are instant. */
|
||||||
|
await saveOneTaskData(id, userState.taskData[id]);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Could not hydrate task data from server:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ═══════════════════════════════════════════════════════════════════════════
|
/* ═══════════════════════════════════════════════════════════════════════════
|
||||||
* Download images from server when task is reopened
|
* Download images from server when task is reopened
|
||||||
* ═══════════════════════════════════════════════════════════════════════════ */
|
* ═══════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
async function maybeDownloadImages(taskId) {
|
async function maybeDownloadImages(taskId) {
|
||||||
const task = userState.tasks.find(t => t.id === taskId);
|
/* Normalize to number — callers may pass a string from a dataset attribute. */
|
||||||
|
const id = Number(taskId);
|
||||||
|
const task = userState.tasks.find(t => t.id === id);
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
|
|
||||||
const data = getTaskData(taskId);
|
const data = getTaskData(id);
|
||||||
if (!data.records) return;
|
if (!data.records) return;
|
||||||
|
|
||||||
/* Check if any images have uploadedToServer flag but no dataUrl */
|
/* Check if any images have uploadedToServer flag but no dataUrl */
|
||||||
@@ -1299,7 +1413,7 @@ async function maybeDownloadImages(taskId) {
|
|||||||
|
|
||||||
/* Fetch images (as dataUrls) from the server */
|
/* Fetch images (as dataUrls) from the server */
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`/api/v1/reports/${taskId}/images`, {
|
const resp = await fetch(`/api/v1/reports/${id}/images`, {
|
||||||
headers: { Accept: 'application/json' }
|
headers: { Accept: 'application/json' }
|
||||||
});
|
});
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
/*
|
|
||||||
* Utility functions used across multiple frontend modules. These are pure
|
|
||||||
* functions with no side effects and no dependency on application state.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/* ── Formatting ─────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function prettifyStatus(status) {
|
|
||||||
return status
|
|
||||||
.split('_')
|
|
||||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
||||||
.join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatDateTime(value) {
|
|
||||||
return new Date(value).toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatTime(value) {
|
|
||||||
return new Date(value).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatRelativeTime(value) {
|
|
||||||
const diffMs = Date.now() - new Date(value).getTime();
|
|
||||||
const diffMinutes = Math.round(diffMs / 60000);
|
|
||||||
|
|
||||||
if (diffMinutes < 1) {
|
|
||||||
return 'just now';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (diffMinutes < 60) {
|
|
||||||
return `${diffMinutes} minute(s) ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const diffHours = Math.round(diffMinutes / 60);
|
|
||||||
|
|
||||||
if (diffHours < 24) {
|
|
||||||
return `${diffHours} hour(s) ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${Math.round(diffHours / 24)} day(s) ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatFileSize(bytes) {
|
|
||||||
if (bytes < 1024) {
|
|
||||||
return `${bytes} B`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bytes < 1024 * 1024) {
|
|
||||||
return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Naming & Sanitization ──────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function sanitizeForFilename(value) {
|
|
||||||
return String(value || 'report')
|
|
||||||
.trim()
|
|
||||||
.replace(/\s+/g, '-')
|
|
||||||
.replace(/[^a-zA-Z0-9-_]/g, '')
|
|
||||||
.slice(0, 40);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateReportNumber() {
|
|
||||||
const now = new Date();
|
|
||||||
const stamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(
|
|
||||||
now.getDate()
|
|
||||||
).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(
|
|
||||||
now.getMinutes()
|
|
||||||
).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
|
|
||||||
|
|
||||||
return `POC-${stamp}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildGeneratedFilename(report, field, sequence, extension) {
|
|
||||||
const reportNumber = sanitizeForFilename(report.answers.reportNumber || report.reportNumber);
|
|
||||||
const sectionCode = sanitizeForFilename(field.id).slice(0, 10).toUpperCase();
|
|
||||||
return `${reportNumber}_${sectionCode}_${String(sequence).padStart(3, '0')}.${extension}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Badge helpers ──────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function badgeClassForTone(tone) {
|
|
||||||
if (tone === 'success') {
|
|
||||||
return 'badge-online';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tone === 'error') {
|
|
||||||
return 'badge-error';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tone === 'warning') {
|
|
||||||
return 'badge-offline';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'badge-neutral';
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Template helpers ───────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export function makeTemplateKey(code, version) {
|
|
||||||
return `${code}::${version}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Multiple versions of the same template can exist in local cache because old
|
|
||||||
* drafts remain bound to the version they started with. The catalog therefore
|
|
||||||
* picks the newest version per template code for the creation UI while keeping
|
|
||||||
* older records available in the version lookup map.
|
|
||||||
*/
|
|
||||||
export function deriveTemplateCatalog(templateRows) {
|
|
||||||
const byCode = new Map();
|
|
||||||
|
|
||||||
for (const row of templateRows) {
|
|
||||||
const existing = byCode.get(row.code);
|
|
||||||
const shouldReplace = !existing || Number(row.version) > Number(existing.version);
|
|
||||||
|
|
||||||
if (shouldReplace) {
|
|
||||||
byCode.set(row.code, row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(byCode.values())
|
|
||||||
.sort((left, right) => left.name.localeCompare(right.name))
|
|
||||||
.map((item) => ({
|
|
||||||
code: item.code,
|
|
||||||
name: item.name,
|
|
||||||
description: item.description,
|
|
||||||
activeVersion: item.version,
|
|
||||||
publishedAt: item.publishedAt
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── General ────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Debounce helper. Returns a wrapper that delays invocation until `ms`
|
|
||||||
* milliseconds of inactivity have passed, preventing expensive operations
|
|
||||||
* (such as a full form re-render) from running on every keystroke.
|
|
||||||
*/
|
|
||||||
export function debounce(fn, ms) {
|
|
||||||
let timer = null;
|
|
||||||
|
|
||||||
return function debounced(...args) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(() => fn.apply(this, args), ms);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -197,6 +197,58 @@ body {
|
|||||||
border: 1px solid rgba(0, 0, 0, 0.15);
|
border: 1px solid rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Admin list tables ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* All admin CRUD list tables share the admin-table class. They are rendered
|
||||||
|
* into containers that are already full-width, but without an explicit width
|
||||||
|
* the browser collapses tables to their content width. Setting width:100%
|
||||||
|
* stretches every table to the panel width. Striped rows and a light-bordered
|
||||||
|
* header make the data easier to scan. The compact variant reduces cell
|
||||||
|
* padding for tables with many columns (e.g. CL Records).
|
||||||
|
*/
|
||||||
|
.admin-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table thead th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody td {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table tbody tr:hover td {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-compact thead th {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-compact tbody td {
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-table-actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
width: 1%; /* shrink-wrap — the remaining columns get all the space */
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Drop zone (drag & drop images) ────────────────────────────────────── */
|
/* ── Drop zone (drag & drop images) ────────────────────────────────────── */
|
||||||
|
|
||||||
.drop-zone {
|
.drop-zone {
|
||||||
|
|||||||
-118
@@ -1,118 +0,0 @@
|
|||||||
const CACHE_NAME = 'check-list-poc-v3';
|
|
||||||
const DYNAMIC_CACHE_LIMIT = 50;
|
|
||||||
const APP_SHELL = [
|
|
||||||
'/',
|
|
||||||
'/user.html',
|
|
||||||
'/admin.html',
|
|
||||||
'/styles.css',
|
|
||||||
'/app.js',
|
|
||||||
'/manifest.webmanifest',
|
|
||||||
'/js/constants.js',
|
|
||||||
'/js/state.js',
|
|
||||||
'/js/i18n.js',
|
|
||||||
'/js/utils.js',
|
|
||||||
'/js/db.js',
|
|
||||||
'/js/api.js',
|
|
||||||
'/js/validation.js',
|
|
||||||
'/js/images.js',
|
|
||||||
'/js/image-worker.js',
|
|
||||||
'/js/forms.js',
|
|
||||||
'/js/renderer.js',
|
|
||||||
'/js/export.js'
|
|
||||||
];
|
|
||||||
|
|
||||||
/*
|
|
||||||
* P5 — Bounded SW cache with LRU eviction. Static shell assets use cache-first.
|
|
||||||
* API requests use network-first with automatic pruning of old dynamic entries
|
|
||||||
* so the cache stays within DYNAMIC_CACHE_LIMIT.
|
|
||||||
*/
|
|
||||||
self.addEventListener('install', (event) => {
|
|
||||||
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)));
|
|
||||||
self.skipWaiting();
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('activate', (event) => {
|
|
||||||
event.waitUntil(
|
|
||||||
caches.keys().then((keys) =>
|
|
||||||
Promise.all(
|
|
||||||
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
self.clients.claim();
|
|
||||||
});
|
|
||||||
|
|
||||||
self.addEventListener('fetch', (event) => {
|
|
||||||
const { request } = event;
|
|
||||||
|
|
||||||
if (request.method !== 'GET') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = new URL(request.url);
|
|
||||||
|
|
||||||
if (url.origin !== self.location.origin) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname.startsWith('/api/')) {
|
|
||||||
event.respondWith(networkFirst(request));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.respondWith(cacheFirst(request));
|
|
||||||
});
|
|
||||||
|
|
||||||
async function cacheFirst(request) {
|
|
||||||
const cache = await caches.open(CACHE_NAME);
|
|
||||||
const cached = await cache.match(request);
|
|
||||||
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(request);
|
|
||||||
cache.put(request, response.clone());
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function networkFirst(request) {
|
|
||||||
const cache = await caches.open(CACHE_NAME);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(request);
|
|
||||||
cache.put(request, response.clone());
|
|
||||||
await trimCache(cache, DYNAMIC_CACHE_LIMIT);
|
|
||||||
return response;
|
|
||||||
} catch {
|
|
||||||
const cached = await cache.match(request);
|
|
||||||
|
|
||||||
if (cached) {
|
|
||||||
return cached;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ message: 'Offline and no cached response available.' }), {
|
|
||||||
status: 503,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* P5 — Remove oldest entries once the cache exceeds the limit. Only dynamic
|
|
||||||
* (non-shell) entries are evicted so the app shell remains always available.
|
|
||||||
*/
|
|
||||||
async function trimCache(cache, maxEntries) {
|
|
||||||
const keys = await cache.keys();
|
|
||||||
|
|
||||||
if (keys.length <= maxEntries) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const shellSet = new Set(APP_SHELL.map((path) => new URL(path, self.location.origin).href));
|
|
||||||
const evictable = keys.filter((request) => !shellSet.has(request.url));
|
|
||||||
|
|
||||||
while (evictable.length + APP_SHELL.length > maxEntries && evictable.length > 0) {
|
|
||||||
await cache.delete(evictable.shift());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+9
-2
@@ -73,10 +73,17 @@
|
|||||||
|
|
||||||
<!-- TASK LIST VIEW (shown by default) -->
|
<!-- TASK LIST VIEW (shown by default) -->
|
||||||
<section id="taskListView" class="workspace-view workspace-view-active">
|
<section id="taskListView" class="workspace-view workspace-view-active">
|
||||||
<div class="mb-4">
|
<div class="d-flex justify-content-between align-items-start mb-4">
|
||||||
|
<div>
|
||||||
<p class="text-muted small mb-0">User workspace</p>
|
<p class="text-muted small mb-0">User workspace</p>
|
||||||
<h3 class="fw-bold">Assigned Tasks</h3>
|
<h3 class="fw-bold">Assigned Tasks</h3>
|
||||||
<p class="text-muted">Select a task to begin processing.</p>
|
<p class="text-muted mb-0">Select a task to begin processing.</p>
|
||||||
|
</div>
|
||||||
|
<!-- Force a fresh fetch from the server (useful after a server restart
|
||||||
|
that cleared in-memory sessions — log in again first, then press Sync). -->
|
||||||
|
<button id="syncBtn" class="btn btn-outline-secondary btn-sm mt-1" type="button">
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>Sync
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -106,19 +106,6 @@ CREATE TABLE IF NOT EXISTS export_profiles (
|
|||||||
UNIQUE KEY uq_export_profiles_code (code)
|
UNIQUE KEY uq_export_profiles_code (code)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Small application settings that do not justify dedicated tables are stored as
|
|
||||||
-- JSON key/value pairs. This keeps the schema lean during the PoC phase while
|
|
||||||
-- still allowing centrally managed frontend behavior.
|
|
||||||
CREATE TABLE IF NOT EXISTS app_config (
|
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
||||||
config_key VARCHAR(100) NOT NULL,
|
|
||||||
config_value_json JSON 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_app_config_key (config_key)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Submitted reports are stored server-side for centralized review and archival.
|
-- Submitted reports are stored server-side for centralized review and archival.
|
||||||
-- The browser creates reports locally first; this table receives them when the
|
-- The browser creates reports locally first; this table receives them when the
|
||||||
-- operator explicitly submits. The report_uuid links back to the browser-local
|
-- operator explicitly submits. The report_uuid links back to the browser-local
|
||||||
|
|||||||
@@ -223,12 +223,3 @@ ON DUPLICATE KEY UPDATE
|
|||||||
include_template_version = VALUES(include_template_version),
|
include_template_version = VALUES(include_template_version),
|
||||||
include_export_timestamp = VALUES(include_export_timestamp),
|
include_export_timestamp = VALUES(include_export_timestamp),
|
||||||
is_active = VALUES(is_active);
|
is_active = VALUES(is_active);
|
||||||
|
|
||||||
-- App config values fine-tune the client without changing code.
|
|
||||||
INSERT INTO app_config (config_key, config_value_json)
|
|
||||||
VALUES
|
|
||||||
('autosave', '{"enabled": true, "intervalSeconds": 20}'),
|
|
||||||
('offlineCache', '{"templateTtlHours": 24, "refreshOnStartup": true}'),
|
|
||||||
('reportStatuses', '["draft", "in_progress", "ready_for_export", "exported", "archived"]')
|
|
||||||
ON DUPLICATE KEY UPDATE
|
|
||||||
config_value_json = VALUES(config_value_json);
|
|
||||||
|
|||||||
+44
-9
@@ -5,7 +5,7 @@ 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 { requireAdminAuth, requireAnyAuth, requireUserAuth } from './middleware/authMiddleware.js';
|
||||||
import adminRoutes from './routes/adminRoutes.js';
|
import adminRoutes from './routes/adminRoutes.js';
|
||||||
import authRoutes from './routes/authRoutes.js';
|
import authRoutes from './routes/authRoutes.js';
|
||||||
import configRoutes from './routes/configRoutes.js';
|
import configRoutes from './routes/configRoutes.js';
|
||||||
@@ -37,6 +37,29 @@ app.use(cors());
|
|||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
app.use(express.json({ limit: '50mb' }));
|
app.use(express.json({ limit: '50mb' }));
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Prevent browsers from serving stale cached HTML pages.
|
||||||
|
*
|
||||||
|
* Without an explicit Cache-Control header, browsers apply heuristic freshness
|
||||||
|
* based on the Last-Modified timestamp. Inside a Docker container the file
|
||||||
|
* system mtime is frozen at image-build time, so browsers can cache HTML for
|
||||||
|
* hours or days and users see the old version until they do a hard-refresh.
|
||||||
|
*
|
||||||
|
* `no-cache` means "always revalidate with the server before using a cached
|
||||||
|
* copy". When the file has not changed Express returns a lightweight 304 Not
|
||||||
|
* Modified (no body) thanks to the ETag it already sends, so the cost is just
|
||||||
|
* one round-trip. When the file has changed the browser receives the new
|
||||||
|
* content immediately.
|
||||||
|
*
|
||||||
|
* JS and CSS assets are deliberately excluded — their ETags already handle
|
||||||
|
* freshness correctly and they are larger, so unnecessary revalidation would
|
||||||
|
* add more overhead.
|
||||||
|
*/
|
||||||
|
function noCacheHtml(_req, res, next) {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
app.get('/api/v1', (_req, res) => {
|
app.get('/api/v1', (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
service: 'check-list-poc-api',
|
service: 'check-list-poc-api',
|
||||||
@@ -54,18 +77,18 @@ app.use('/api/v1/health', healthRoutes);
|
|||||||
app.use('/api/v1/templates', templateRoutes);
|
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', requireAnyAuth, reportRoutes);
|
||||||
app.use('/api/v1/admin', adminRoutes);
|
app.use('/api/v1/admin', requireAnyAuth, adminRoutes);
|
||||||
app.use('/api/v1/auth', authRoutes);
|
app.use('/api/v1/auth', authRoutes);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Login pages are served without authentication.
|
* Login pages are served without authentication.
|
||||||
*/
|
*/
|
||||||
app.get('/login-admin', (_req, res) => {
|
app.get('/login-admin', noCacheHtml, (_req, res) => {
|
||||||
res.sendFile(loginAdminPath);
|
res.sendFile(loginAdminPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/login-user', (_req, res) => {
|
app.get('/login-user', noCacheHtml, (_req, res) => {
|
||||||
res.sendFile(loginUserPath);
|
res.sendFile(loginUserPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,7 +96,7 @@ app.get('/login-user', (_req, res) => {
|
|||||||
* 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.
|
* project distinct user and administrator entry points.
|
||||||
*/
|
*/
|
||||||
app.get('/', (_req, res) => {
|
app.get('/', noCacheHtml, (_req, res) => {
|
||||||
res.sendFile(portalPath);
|
res.sendFile(portalPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,14 +104,26 @@ 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. Authentication is required for both areas.
|
* the markup it needs. Authentication is required for both areas.
|
||||||
*/
|
*/
|
||||||
app.get(['/user', '/user/'], requireUserAuth, (_req, res) => {
|
app.get(['/user', '/user/'], requireUserAuth, noCacheHtml, (_req, res) => {
|
||||||
res.sendFile(userPagePath);
|
res.sendFile(userPagePath);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get(['/admin', '/admin/'], requireAdminAuth, (_req, res) => {
|
app.get(['/admin', '/admin/'], requireAdminAuth, noCacheHtml, (_req, res) => {
|
||||||
res.sendFile(adminPagePath);
|
res.sendFile(adminPagePath);
|
||||||
});
|
});
|
||||||
app.use(express.static(publicDir));
|
|
||||||
|
/*
|
||||||
|
* Serve static assets. HTML files get the same no-cache directive (covers
|
||||||
|
* direct URL access like /user.html). JS/CSS/images use the default
|
||||||
|
* ETag-based caching — conditional GETs (304) keep them efficient.
|
||||||
|
*/
|
||||||
|
app.use(express.static(publicDir, {
|
||||||
|
setHeaders: (res, filePath) => {
|
||||||
|
if (filePath.endsWith('.html')) {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ function getAuthToken(req) {
|
|||||||
return req.cookies?.auth_token || req.headers.authorization?.replace('Bearer ', '') || null;
|
return req.cookies?.auth_token || req.headers.authorization?.replace('Bearer ', '') || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Determine whether a request originates from API/JSON consumers. This decides
|
||||||
|
* whether an unauthenticated request should receive a `401` JSON body (for
|
||||||
|
* XHR/fetch callers) or a redirect to the appropriate login page (for browser
|
||||||
|
* navigation). `req.originalUrl` is used instead of `req.path` so the check
|
||||||
|
* also works when this middleware runs behind a `Router` mount point.
|
||||||
|
*/
|
||||||
|
function isApiRequest(req) {
|
||||||
|
return (
|
||||||
|
req.originalUrl?.startsWith('/api/') ||
|
||||||
|
req.xhr ||
|
||||||
|
req.headers.accept?.includes('application/json')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware to require admin authentication.
|
* Middleware to require admin authentication.
|
||||||
* Redirects to login page for HTML requests, returns 401 for API requests.
|
* Redirects to login page for HTML requests, returns 401 for API requests.
|
||||||
@@ -25,10 +40,7 @@ export function requireAdminAuth(req, res, next) {
|
|||||||
const session = token ? validateSession(token) : null;
|
const session = token ? validateSession(token) : null;
|
||||||
|
|
||||||
if (!session || session.type !== 'admin') {
|
if (!session || session.type !== 'admin') {
|
||||||
/* Check if this is an API request or page request */
|
if (isApiRequest(req)) {
|
||||||
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.' });
|
return res.status(401).json({ message: 'Admin authentication required.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,9 +61,7 @@ export function requireUserAuth(req, res, next) {
|
|||||||
const session = token ? validateSession(token) : null;
|
const session = token ? validateSession(token) : null;
|
||||||
|
|
||||||
if (!session || session.type !== 'user') {
|
if (!session || session.type !== 'user') {
|
||||||
const isApiRequest = req.path.startsWith('/api/') || req.xhr || req.headers.accept?.includes('application/json');
|
if (isApiRequest(req)) {
|
||||||
|
|
||||||
if (isApiRequest) {
|
|
||||||
return res.status(401).json({ message: 'User authentication required.' });
|
return res.status(401).json({ message: 'User authentication required.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,9 +81,7 @@ export function requireAnyAuth(req, res, next) {
|
|||||||
const session = token ? validateSession(token) : null;
|
const session = token ? validateSession(token) : null;
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
const isApiRequest = req.path.startsWith('/api/') || req.xhr || req.headers.accept?.includes('application/json');
|
if (isApiRequest(req)) {
|
||||||
|
|
||||||
if (isApiRequest) {
|
|
||||||
return res.status(401).json({ message: 'Authentication required.' });
|
return res.status(401).json({ message: 'Authentication required.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
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';
|
||||||
@@ -143,49 +140,4 @@ router.get(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get(
|
|
||||||
'/app-config',
|
|
||||||
asyncHandler(async (_req, res) => {
|
|
||||||
/*
|
|
||||||
* Generic application configuration is kept as a simple key/value list in
|
|
||||||
* the PoC. This avoids hardcoding small behavioral settings in the frontend
|
|
||||||
* while still keeping the schema easy to inspect and evolve.
|
|
||||||
*/
|
|
||||||
const config = await getAppConfig();
|
|
||||||
res.json({ items: config });
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -99,53 +99,3 @@ export async function getExportProfile() {
|
|||||||
|
|
||||||
return rows.length ? rows[0] : null;
|
return rows.length ? rows[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAppConfig() {
|
|
||||||
/*
|
|
||||||
* Config values are stored as JSON so the frontend can receive structured data
|
|
||||||
* without a separate table for every small setting. The helper converts JSON
|
|
||||||
* strings into usable objects and arrays before returning them.
|
|
||||||
*/
|
|
||||||
const rows = await query(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
config_key AS configKey,
|
|
||||||
config_value_json AS configValue
|
|
||||||
FROM app_config
|
|
||||||
ORDER BY config_key ASC
|
|
||||||
`
|
|
||||||
);
|
|
||||||
|
|
||||||
return rows.map((row) => ({
|
|
||||||
key: row.configKey,
|
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user