stage 1
This commit is contained in:
@@ -0,0 +1,676 @@
|
|||||||
|
# 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.
|
||||||
|
|
||||||
|
The project is split into a few clear parts:
|
||||||
|
- 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
|
||||||
|
|
||||||
|
The project has two big sides:
|
||||||
|
- the backend, which provides templates, lookups, configuration, report storage, and audit logging through REST API endpoints
|
||||||
|
- the frontend, which runs in the browser, stores drafts locally, and uses the backend for centrally managed configuration and report submission
|
||||||
|
|
||||||
|
In simple terms:
|
||||||
|
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
|
||||||
|
|
||||||
|
### [package.json](package.json)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
Defines the Node.js project itself.
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
Starts the full local development environment.
|
||||||
|
|
||||||
|
What it contains:
|
||||||
|
- the application container
|
||||||
|
- the MariaDB container
|
||||||
|
- the phpMyAdmin container
|
||||||
|
- port mappings and volume configuration
|
||||||
|
|
||||||
|
Why it matters:
|
||||||
|
Instead of installing and configuring everything manually, you can start the full stack with one command.
|
||||||
|
|
||||||
|
### [.env.example](.env.example)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
Example environment configuration.
|
||||||
|
|
||||||
|
What it contains:
|
||||||
|
- default port values
|
||||||
|
- database host, name, user, and password placeholders
|
||||||
|
|
||||||
|
Why it matters:
|
||||||
|
It shows which environment variables the application expects.
|
||||||
|
|
||||||
|
### [.env](.env)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
Real environment file used on the current machine.
|
||||||
|
|
||||||
|
What it contains:
|
||||||
|
- actual local values for ports and database credentials
|
||||||
|
|
||||||
|
Why it matters:
|
||||||
|
The backend reads this file during startup through `dotenv`.
|
||||||
|
|
||||||
|
## Public Folder
|
||||||
|
|
||||||
|
The [public](public) folder contains files that are sent directly to the browser.
|
||||||
|
|
||||||
|
### [public/user.html](public/user.html)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
Operator workspace HTML shell.
|
||||||
|
|
||||||
|
What it contains:
|
||||||
|
- shared sidebar with template selector, sync button, report list, and search/filter
|
||||||
|
- report editor main area with hero, summary cards, form, inspector panel
|
||||||
|
- report list item `<template>` for runtime rendering
|
||||||
|
|
||||||
|
When you edit it:
|
||||||
|
- when you need to change the report editing layout
|
||||||
|
- when adding new user-facing controls or sections
|
||||||
|
|
||||||
|
### [public/admin.html](public/admin.html)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
Administrator workspace HTML shell.
|
||||||
|
|
||||||
|
What it contains:
|
||||||
|
- simplified sidebar with template selector and sync button (no report list)
|
||||||
|
- admin main area with image policy editor form and admin summary panel
|
||||||
|
|
||||||
|
When you edit it:
|
||||||
|
- when adding new admin configuration panels
|
||||||
|
- when changing the image policy form fields
|
||||||
|
|
||||||
|
### [public/index.html](public/index.html)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
Legacy combined app shell (no longer served by routes).
|
||||||
|
|
||||||
|
Note:
|
||||||
|
This file has been superseded by [user.html](public/user.html) and [admin.html](public/admin.html).
|
||||||
|
It remains in the repository for reference but is not routed to by the Express server.
|
||||||
|
|
||||||
|
### [public/portal.html](public/portal.html)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
Simple chooser page that lets the user open either the user area or the admin area.
|
||||||
|
|
||||||
|
What it contains:
|
||||||
|
- a short explanation of the two entry points
|
||||||
|
- a link to `/user`
|
||||||
|
- a link to `/admin`
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
It is the landing page served at `/`. It does not contain complex logic. Its job is only to separate the entry points.
|
||||||
|
|
||||||
|
### [public/app.js](public/app.js)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
Thin orchestrator entry point for the frontend.
|
||||||
|
|
||||||
|
What it contains:
|
||||||
|
- imports from all ES modules under `public/js/`
|
||||||
|
- startup flow (`init()`, `cacheElements()`, `bindEvents()`)
|
||||||
|
- report CRUD operations (create, open, delete, submit)
|
||||||
|
- sync logic with the API using batch template fetch
|
||||||
|
- dirty-flag autosave loop
|
||||||
|
- admin image-policy save
|
||||||
|
- event delegation for the report list
|
||||||
|
- search/filter event wiring
|
||||||
|
- null guards for page-specific elements (user vs admin)
|
||||||
|
|
||||||
|
What changed:
|
||||||
|
This file was originally a monolithic ~1700-line file. It has been split into modules (A1)
|
||||||
|
and now acts as a controller. It is shared between user.html and admin.html — it detects
|
||||||
|
which DOM elements exist and only binds behavior for the current page.
|
||||||
|
|
||||||
|
### [public/styles.css](public/styles.css)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
Real server startup file.
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
Express application configuration file.
|
||||||
|
|
||||||
|
What it contains:
|
||||||
|
- middleware registration
|
||||||
|
- API v1 route registration under `/api/v1/` prefix (A3)
|
||||||
|
- static file serving
|
||||||
|
- frontend entry page routes
|
||||||
|
- global error handling
|
||||||
|
|
||||||
|
Important routes:
|
||||||
|
- `/api/v1/...` for versioned backend endpoints
|
||||||
|
- `/` for the chooser page
|
||||||
|
- `/user` and `/admin` for the frontend app shell
|
||||||
|
|
||||||
|
## Source Subfolders
|
||||||
|
|
||||||
|
### [src/config/env.js](src/config/env.js)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
Loads and validates environment variables.
|
||||||
|
|
||||||
|
What it contains:
|
||||||
|
- `dotenv` setup
|
||||||
|
- required environment key checks
|
||||||
|
- normalized `env` object export
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
It reads raw values from `.env` and converts them into a safer object the rest of the code can use.
|
||||||
|
|
||||||
|
Why this is useful:
|
||||||
|
Instead of reading `process.env` everywhere, the app reads from one clean place.
|
||||||
|
|
||||||
|
### [src/db/pool.js](src/db/pool.js)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
Creates and manages the MariaDB connection pool.
|
||||||
|
|
||||||
|
What it contains:
|
||||||
|
- one shared database pool
|
||||||
|
- `query()` helper
|
||||||
|
- `closePool()` helper
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
It gives the service layer a standard way to run SQL queries.
|
||||||
|
|
||||||
|
Why it matters:
|
||||||
|
Without this file, each route or service would have to manage its own connections, which would be messy and error-prone.
|
||||||
|
|
||||||
|
### [src/middleware/errorHandler.js](src/middleware/errorHandler.js)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
Handles not-found routes and server errors.
|
||||||
|
|
||||||
|
What it contains:
|
||||||
|
- `notFoundHandler`
|
||||||
|
- `errorHandler`
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
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.
|
||||||
|
|
||||||
|
### [src/middleware/validateParams.js](src/middleware/validateParams.js)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
URL parameter validation middleware (A7).
|
||||||
|
|
||||||
|
What it contains:
|
||||||
|
- `validateParam(name)` — checks route params against safe patterns (code or UUID)
|
||||||
|
- `validateNumericParam(name)` — validates numeric route parameters
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
It rejects malformed URL parameters at the middleware level before they reach route handlers.
|
||||||
|
|
||||||
|
### [src/routes/healthRoutes.js](src/routes/healthRoutes.js)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
Defines the health-check endpoint.
|
||||||
|
|
||||||
|
What it contains:
|
||||||
|
- `GET /api/health`
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
Checks whether the server and database are working.
|
||||||
|
|
||||||
|
Why it matters:
|
||||||
|
This endpoint is used by tests and is also useful when debugging container problems.
|
||||||
|
|
||||||
|
### [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)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
Creates the database structure.
|
||||||
|
|
||||||
|
What it contains:
|
||||||
|
- database creation
|
||||||
|
- table definitions
|
||||||
|
- keys and foreign keys
|
||||||
|
|
||||||
|
What it does:
|
||||||
|
It defines how templates, lookups, image rules, export profiles, and app config are stored.
|
||||||
|
|
||||||
|
### [sql/seed.sql](sql/seed.sql)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
Inserts example data.
|
||||||
|
|
||||||
|
What it contains:
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
Role:
|
||||||
|
Simple smoke test for the local environment.
|
||||||
|
|
||||||
|
What it contains:
|
||||||
|
- 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)
|
||||||
|
2. [PROJECT_FILES_GUIDE.md](PROJECT_FILES_GUIDE.md)
|
||||||
|
3. [src/server.js](src/server.js)
|
||||||
|
4. [src/app.js](src/app.js)
|
||||||
|
5. [src/routes/templateRoutes.js](src/routes/templateRoutes.js)
|
||||||
|
6. [src/services/templateService.js](src/services/templateService.js)
|
||||||
|
7. [public/js/constants.js](public/js/constants.js) and [public/js/state.js](public/js/state.js)
|
||||||
|
8. [public/app.js](public/app.js) — the thin orchestrator that wires everything together
|
||||||
|
9. [public/js/renderer.js](public/js/renderer.js) and [public/js/forms.js](public/js/forms.js)
|
||||||
|
7. [public/index.html](public/index.html)
|
||||||
|
8. [public/app.js](public/app.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.
|
||||||
|
|
||||||
|
## Practical Rule Of Thumb
|
||||||
|
|
||||||
|
When you are trying to change something, use this shortcut:
|
||||||
|
- if the browser layout looks wrong, check [public/index.html](public/index.html) and [public/styles.css](public/styles.css)
|
||||||
|
- if browser behavior is wrong, check [public/app.js](public/app.js)
|
||||||
|
- if an API endpoint is wrong, check [src/routes](src/routes) first and then [src/services](src/services)
|
||||||
|
- 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
|
||||||
|
- 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)
|
||||||
|
|
||||||
|
This simple rule is often enough to help you find the right file quickly.
|
||||||
@@ -1,34 +1,44 @@
|
|||||||
# Check List Proof of Concept
|
# 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.
|
This repository contains a proof-of-concept implementation for the Check List hybrid reporting solution described in the project documentation.
|
||||||
|
|
||||||
## What is included
|
## What is included
|
||||||
|
|
||||||
- Node.js REST API for template and configuration delivery
|
- Node.js REST API (v1) for template, configuration, report, and audit delivery
|
||||||
- MariaDB schema for phase 1 configuration data
|
- static frontend PoC served by Express, split into focused ES modules
|
||||||
|
- MariaDB schema for configuration data, submitted reports, and audit trail
|
||||||
- seed data with one sample inspection checklist template
|
- seed data with one sample inspection checklist template
|
||||||
- lookup values, image policy, and export profile
|
- 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
|
||||||
|
|
||||||
## Scope of this PoC
|
## Scope of this PoC
|
||||||
|
|
||||||
Included:
|
Included:
|
||||||
- template list endpoint
|
- all endpoints under versioned `/api/v1/` prefix
|
||||||
- active template endpoint
|
- batch template endpoint with `?include=definitions` for single-request sync
|
||||||
- specific template version endpoint
|
- template version listing and publishing management
|
||||||
- lookup endpoints
|
- lookup endpoints with parameter validation
|
||||||
- image rule endpoint
|
- image rule endpoint with server-side LRU cache and audit trail
|
||||||
- export profile endpoint
|
- export profile and generic application config endpoints
|
||||||
- generic application config endpoint
|
- report submission endpoint (POST) with UPSERT
|
||||||
- MariaDB schema and seed data
|
- audit log recording for admin mutations
|
||||||
|
- offline-capable frontend shell split into ES modules
|
||||||
|
- IndexedDB-based local drafts with multi-store transactions
|
||||||
|
- dynamic form rendering from template JSON
|
||||||
|
- local attachment storage with Web Worker image optimization
|
||||||
|
- report search and status filter
|
||||||
|
- CSV export for report data
|
||||||
|
- i18n locale extraction for UI strings
|
||||||
|
- PWA manifest with SVG icon
|
||||||
|
- debounced renders and dirty-flag autosave
|
||||||
|
|
||||||
Not included:
|
Not included:
|
||||||
- report upload
|
- authentication and authorization
|
||||||
- authentication
|
- file attachment upload to server (binary upload requires multer)
|
||||||
- admin UI
|
- XLSX or ZIP generation (CSV is provided; advanced formats require library vendoring)
|
||||||
- report draft storage backend
|
- production-grade frontend bundling
|
||||||
- XLSX or ZIP generation
|
- automated test suite
|
||||||
- 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.
|
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.
|
||||||
|
|
||||||
@@ -39,6 +49,29 @@ The PoC keeps template content inside a JSON column to reduce initial complexity
|
|||||||
├── .devcontainer/
|
├── .devcontainer/
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── package.json
|
├── package.json
|
||||||
|
├── public/
|
||||||
|
│ ├── admin.html ← administrator workspace
|
||||||
|
│ ├── app.js ← thin orchestrator entry point
|
||||||
|
│ ├── icon.svg ← PWA icon
|
||||||
|
│ ├── index.html ← legacy combined shell (unused)
|
||||||
|
│ ├── manifest.webmanifest
|
||||||
|
│ ├── portal.html
|
||||||
|
│ ├── styles.css
|
||||||
|
│ ├── sw.js
|
||||||
|
│ ├── user.html ← operator workspace
|
||||||
|
│ └── js/
|
||||||
|
│ ├── api.js ← API communication (versioned base path)
|
||||||
|
│ ├── constants.js ← shared constants (DB, API, limits)
|
||||||
|
│ ├── db.js ← IndexedDB operations, multi-store tx
|
||||||
|
│ ├── export.js ← CSV export and attachment download
|
||||||
|
│ ├── forms.js ← dynamic form field creation
|
||||||
|
│ ├── i18n.js ← English locale, t() translation
|
||||||
|
│ ├── image-worker.js ← OffscreenCanvas Web Worker
|
||||||
|
│ ├── images.js ← image optimization with worker fallback
|
||||||
|
│ ├── renderer.js ← all render functions with search/filter
|
||||||
|
│ ├── state.js ← centralized state management
|
||||||
|
│ ├── utils.js ← utility functions
|
||||||
|
│ └── validation.js ← shared validation (client-side)
|
||||||
├── scripts/
|
├── scripts/
|
||||||
│ └── test-environment.js
|
│ └── test-environment.js
|
||||||
├── sql/
|
├── sql/
|
||||||
@@ -48,13 +81,34 @@ The PoC keeps template content inside a JSON column to reduce initial complexity
|
|||||||
├── app.js
|
├── app.js
|
||||||
├── server.js
|
├── server.js
|
||||||
├── config/
|
├── config/
|
||||||
|
│ └── env.js
|
||||||
├── db/
|
├── db/
|
||||||
|
│ └── pool.js
|
||||||
├── middleware/
|
├── middleware/
|
||||||
|
│ ├── errorHandler.js
|
||||||
|
│ └── validateParams.js ← URL parameter validation
|
||||||
├── routes/
|
├── routes/
|
||||||
|
│ ├── configRoutes.js
|
||||||
|
│ ├── healthRoutes.js
|
||||||
|
│ ├── lookupRoutes.js
|
||||||
|
│ ├── reportRoutes.js ← report submission endpoints
|
||||||
|
│ └── templateRoutes.js
|
||||||
├── services/
|
├── services/
|
||||||
|
│ ├── auditService.js ← audit trail logging
|
||||||
|
│ ├── cacheService.js ← in-memory LRU cache with TTL
|
||||||
|
│ ├── configService.js
|
||||||
|
│ ├── lookupService.js
|
||||||
|
│ ├── reportService.js ← report CRUD operations
|
||||||
|
│ └── templateService.js
|
||||||
└── utils/
|
└── utils/
|
||||||
|
├── asyncHandler.js
|
||||||
|
└── json.js
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## File guide
|
||||||
|
|
||||||
|
For a junior-friendly explanation of what each main file does, see [PROJECT_FILES_GUIDE.md](PROJECT_FILES_GUIDE.md).
|
||||||
|
|
||||||
## 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` if you want custom local credentials.
|
||||||
@@ -87,37 +141,87 @@ npm run test:environment
|
|||||||
```
|
```
|
||||||
|
|
||||||
The test verifies:
|
The test verifies:
|
||||||
- the API health endpoint
|
- the API health endpoint at `/api/v1/health`
|
||||||
- seeded template data via `/api/templates`
|
- seeded template data 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.
|
||||||
|
|
||||||
|
Entry points:
|
||||||
|
- `http://localhost:3000/` opens the chooser portal
|
||||||
|
- `http://localhost:3000/user` opens the user workspace directly
|
||||||
|
- `http://localhost:3000/admin` opens the administrator workspace directly
|
||||||
|
|
||||||
|
The frontend demonstrates:
|
||||||
|
- template sync from the API via batch endpoint
|
||||||
|
- offline cache via IndexedDB and service worker (LRU-bounded)
|
||||||
|
- local report creation, switching, search, and filtering
|
||||||
|
- dynamic form rendering based on the seeded template
|
||||||
|
- local attachment storage and preview with lazy-loading
|
||||||
|
- Web Worker image optimization (OffscreenCanvas) with main-thread fallback
|
||||||
|
- debounced re-renders and dirty-flag autosave
|
||||||
|
- report submission to backend
|
||||||
|
- 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
|
## API endpoints
|
||||||
|
|
||||||
|
All endpoints are under the `/api/v1/` prefix.
|
||||||
|
|
||||||
### Service health
|
### Service health
|
||||||
|
|
||||||
`GET /api/health`
|
`GET /api/v1/health`
|
||||||
|
|
||||||
### Templates
|
### Templates
|
||||||
|
|
||||||
- `GET /api/templates`
|
- `GET /api/v1/templates` — list active templates
|
||||||
- `GET /api/templates/incoming-inspection`
|
- `GET /api/v1/templates?include=definitions` — batch: all templates with definitions in one request
|
||||||
- `GET /api/templates/incoming-inspection/versions/1`
|
- `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
|
### Lookups
|
||||||
|
|
||||||
- `GET /api/lookups`
|
- `GET /api/v1/lookups` — list all lookup sets
|
||||||
- `GET /api/lookups/pass-fail`
|
- `GET /api/v1/lookups/:code` — single lookup set with values
|
||||||
|
|
||||||
### Configuration
|
### Configuration
|
||||||
|
|
||||||
- `GET /api/config/image-rules`
|
- `GET /api/v1/config/image-rules` — active image policy
|
||||||
- `GET /api/config/export`
|
- `PUT /api/v1/config/image-rules` — update image policy (audit-logged)
|
||||||
- `GET /api/config/app-config`
|
- `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
|
## Example response
|
||||||
|
|
||||||
`GET /api/templates/incoming-inspection`
|
`GET /api/v1/templates/incoming-inspection`
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -135,12 +239,3 @@ The test verifies:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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.
|
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#f3efe6" />
|
||||||
|
<title>Check List PoC — Admin</title>
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!--
|
||||||
|
Administrator workspace: server-backed configuration editing for image
|
||||||
|
policies and other centrally managed settings.
|
||||||
|
-->
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="sidebar panel">
|
||||||
|
<div class="brand-block">
|
||||||
|
<p class="eyebrow">Hybrid Inspection Reporting</p>
|
||||||
|
<h1>Check List</h1>
|
||||||
|
<p class="lede">
|
||||||
|
Offline-first proof of concept for template-driven quality reports.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="status-row">
|
||||||
|
<span id="connectionBadge" class="badge badge-neutral">Checking connection</span>
|
||||||
|
<span id="saveBadge" class="badge badge-neutral">No changes</span>
|
||||||
|
</div>
|
||||||
|
<button id="syncTemplatesButton" class="button button-secondary" type="button">
|
||||||
|
Sync templates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<label class="field-label" for="templateSelect">Template</label>
|
||||||
|
<select id="templateSelect" class="select-input"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="section-heading-row sidebar-links-heading">
|
||||||
|
<h2>Access</h2>
|
||||||
|
<span class="muted-count">Direct links</span>
|
||||||
|
</div>
|
||||||
|
<a id="userAreaLink" class="button button-secondary sidebar-link" href="/user">User area</a>
|
||||||
|
<a id="adminAreaLink" class="button button-secondary sidebar-link is-active" href="/admin">Admin area</a>
|
||||||
|
<a class="button button-secondary sidebar-link" href="/">Back to portal</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="workspace">
|
||||||
|
<section id="adminWorkspace" class="workspace-view workspace-view-active">
|
||||||
|
<section class="hero panel">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Administrator workspace</p>
|
||||||
|
<h2>Configuration control</h2>
|
||||||
|
<p class="hero-copy">
|
||||||
|
Update centrally managed image requirements used by the inspection frontend.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<span id="adminSyncState" class="badge badge-neutral">Server-backed settings</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="editor-grid">
|
||||||
|
<section class="panel editor-panel">
|
||||||
|
<div class="section-heading-row">
|
||||||
|
<h2>Image policy editor</h2>
|
||||||
|
<span class="panel-note">Updates the active server rule</span>
|
||||||
|
</div>
|
||||||
|
<form id="adminImageRulesForm" class="report-form admin-form">
|
||||||
|
<section class="template-section">
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field field-full">
|
||||||
|
<div class="field-header">
|
||||||
|
<label class="field-label" for="adminPolicyName">Policy name</label>
|
||||||
|
</div>
|
||||||
|
<input id="adminPolicyName" name="name" class="text-input" type="text" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field-full">
|
||||||
|
<div class="field-header">
|
||||||
|
<label class="field-label" for="adminAllowedMimeTypes">Allowed MIME types</label>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="adminAllowedMimeTypes"
|
||||||
|
name="allowedMimeTypes"
|
||||||
|
class="text-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="image/jpeg, image/png, image/webp"
|
||||||
|
/>
|
||||||
|
<p class="field-help">Comma-separated values used by the attachment field and browser validation.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="field-header">
|
||||||
|
<label class="field-label" for="adminMaxFileSizeMb">Max file size (MB)</label>
|
||||||
|
</div>
|
||||||
|
<input id="adminMaxFileSizeMb" name="maxFileSizeMb" class="text-input" type="number" min="1" step="0.1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="field-header">
|
||||||
|
<label class="field-label" for="adminMaxAttachmentsPerField">Max attachments per field</label>
|
||||||
|
</div>
|
||||||
|
<input id="adminMaxAttachmentsPerField" name="maxAttachmentsPerField" class="text-input" type="number" min="1" step="1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="field-header">
|
||||||
|
<label class="field-label" for="adminMaxWidthPx">Max width (px)</label>
|
||||||
|
</div>
|
||||||
|
<input id="adminMaxWidthPx" name="maxWidthPx" class="text-input" type="number" min="1" step="1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="field-header">
|
||||||
|
<label class="field-label" for="adminMaxHeightPx">Max height (px)</label>
|
||||||
|
</div>
|
||||||
|
<input id="adminMaxHeightPx" name="maxHeightPx" class="text-input" type="number" min="1" step="1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="field-header">
|
||||||
|
<label class="field-label" for="adminJpegQuality">JPEG quality</label>
|
||||||
|
</div>
|
||||||
|
<input id="adminJpegQuality" name="jpegQuality" class="text-input" type="number" min="1" max="100" step="1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="field-header">
|
||||||
|
<label class="field-label" for="adminOversizeBehavior">Oversize behavior</label>
|
||||||
|
</div>
|
||||||
|
<select id="adminOversizeBehavior" name="oversizeBehavior" class="select-input">
|
||||||
|
<option value="auto_optimize">Auto optimize</option>
|
||||||
|
<option value="warn_then_optimize">Warn then optimize</option>
|
||||||
|
<option value="block">Block oversized files</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="admin-actions">
|
||||||
|
<button id="saveImageRulesButton" class="button button-primary" type="submit">
|
||||||
|
Save image policy
|
||||||
|
</button>
|
||||||
|
<button id="resetImageRulesButton" class="button button-secondary" type="button">
|
||||||
|
Reset form
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="panel inspector-panel">
|
||||||
|
<div class="section-heading-row">
|
||||||
|
<h2>Admin summary</h2>
|
||||||
|
<span class="panel-note">Live configuration preview</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="meta-list">
|
||||||
|
<div>
|
||||||
|
<dt>Active policy code</dt>
|
||||||
|
<dd id="adminPolicyCode">-</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Allowed types</dt>
|
||||||
|
<dd id="adminPolicyMimeTypes">-</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Optimization</dt>
|
||||||
|
<dd id="adminPolicyOptimization">-</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Limits</dt>
|
||||||
|
<dd id="adminPolicyLimits">-</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="validation-block">
|
||||||
|
<h3>Admin notes</h3>
|
||||||
|
<ul class="validation-list" id="adminNotesList">
|
||||||
|
<li>Changes are stored on the server and reused by report attachments.</li>
|
||||||
|
<li>Operators will use the updated policy after the next sync.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+780
@@ -0,0 +1,780 @@
|
|||||||
|
/*
|
||||||
|
* 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)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="96" fill="#9d3d2e"/>
|
||||||
|
<rect x="96" y="80" width="320" height="400" rx="32" fill="#fff8f0"/>
|
||||||
|
<line x1="160" y1="180" x2="360" y2="180" stroke="#e8dcc7" stroke-width="12" stroke-linecap="round"/>
|
||||||
|
<line x1="160" y1="250" x2="360" y2="250" stroke="#e8dcc7" stroke-width="12" stroke-linecap="round"/>
|
||||||
|
<line x1="160" y1="320" x2="320" y2="320" stroke="#e8dcc7" stroke-width="12" stroke-linecap="round"/>
|
||||||
|
<polyline points="128,170 148,190 188,150" fill="none" stroke="#25624c" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<polyline points="128,240 148,260 188,220" fill="none" stroke="#25624c" stroke-width="14" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<rect x="128" y="305" width="40" height="40" rx="6" fill="none" stroke="#685f53" stroke-width="8"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 896 B |
@@ -0,0 +1,347 @@
|
|||||||
|
<!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="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!--
|
||||||
|
This document is the shared app shell for both operator and administrator
|
||||||
|
routes. JavaScript decides which workspace to reveal based on the current
|
||||||
|
URL so the project can keep one frontend bundle while still presenting two
|
||||||
|
distinct entry points.
|
||||||
|
-->
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="sidebar panel">
|
||||||
|
<!--
|
||||||
|
The sidebar keeps app-level actions visible across both workspaces:
|
||||||
|
sync status, template selection, navigation links, and the local draft
|
||||||
|
list. That supports quick report switching on small operational screens.
|
||||||
|
-->
|
||||||
|
<div class="brand-block">
|
||||||
|
<p class="eyebrow">Hybrid Inspection Reporting</p>
|
||||||
|
<h1>Check List</h1>
|
||||||
|
<p class="lede">
|
||||||
|
Offline-first proof of concept for template-driven quality reports.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="status-row">
|
||||||
|
<span id="connectionBadge" class="badge badge-neutral">Checking connection</span>
|
||||||
|
<span id="saveBadge" class="badge badge-neutral">No changes</span>
|
||||||
|
</div>
|
||||||
|
<button id="syncTemplatesButton" class="button button-secondary" type="button">
|
||||||
|
Sync templates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<label class="field-label" for="templateSelect">Template</label>
|
||||||
|
<select id="templateSelect" class="select-input"></select>
|
||||||
|
<button id="createReportButton" class="button button-primary" type="button">
|
||||||
|
Create new report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="section-heading-row sidebar-links-heading">
|
||||||
|
<h2>Access</h2>
|
||||||
|
<span class="muted-count">Direct links</span>
|
||||||
|
</div>
|
||||||
|
<a id="userAreaLink" class="button button-secondary sidebar-link" href="/user">User area</a>
|
||||||
|
<a id="adminAreaLink" class="button button-secondary sidebar-link" href="/admin">Admin area</a>
|
||||||
|
<a class="button button-secondary sidebar-link" href="/">Back to portal</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section grow-section">
|
||||||
|
<div class="section-heading-row">
|
||||||
|
<h2>Local reports</h2>
|
||||||
|
<span id="reportCount" class="muted-count">0</span>
|
||||||
|
</div>
|
||||||
|
<!-- F4 — Search and status filter for the local report list -->
|
||||||
|
<div class="report-filter-row">
|
||||||
|
<input id="reportSearchInput" class="text-input text-input-small" type="search" placeholder="Search reports" />
|
||||||
|
<select id="reportFilterSelect" class="select-input select-input-small">
|
||||||
|
<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 class="workspace">
|
||||||
|
<!-- Operator workspace: draft editing, validation, and local attachments. -->
|
||||||
|
<section id="reportsWorkspace" class="workspace-view workspace-view-active">
|
||||||
|
<section class="hero panel">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Proof of concept frontend</p>
|
||||||
|
<h2 id="heroTitle">No report selected</h2>
|
||||||
|
<p id="heroSubtitle" class="hero-copy">
|
||||||
|
Start by syncing templates and creating a local draft.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<label class="status-picker">
|
||||||
|
<span>Status</span>
|
||||||
|
<select id="reportStatusSelect" class="select-input">
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="ready_for_export">Ready for Export</option>
|
||||||
|
<option value="exported">Exported</option>
|
||||||
|
<option value="archived">Archived</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button id="submitReportButton" class="button button-secondary" type="button">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
<button id="exportReportButton" class="button button-secondary" type="button">
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
<button id="deleteReportButton" class="button button-ghost" type="button">
|
||||||
|
Delete report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="summary-grid">
|
||||||
|
<article class="summary-card panel accent-card">
|
||||||
|
<p class="summary-label">Template</p>
|
||||||
|
<strong id="summaryTemplate">Not loaded</strong>
|
||||||
|
<span id="summaryVersion" class="summary-note">Version -</span>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card panel">
|
||||||
|
<p class="summary-label">Validation</p>
|
||||||
|
<strong id="validationHeadline">No report selected</strong>
|
||||||
|
<span id="validationDetail" class="summary-note">Draft validation will appear here.</span>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card panel">
|
||||||
|
<p class="summary-label">Offline cache</p>
|
||||||
|
<strong id="syncHeadline">No sync yet</strong>
|
||||||
|
<span id="syncDetail" class="summary-note">Templates are cached locally after the first successful sync.</span>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="editor-grid">
|
||||||
|
<section class="panel editor-panel">
|
||||||
|
<div class="section-heading-row">
|
||||||
|
<h2>Report editor</h2>
|
||||||
|
<span id="editorHint" class="panel-note">Dynamic form rendering from template JSON</span>
|
||||||
|
</div>
|
||||||
|
<form id="reportForm" class="report-form">
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>No report open</h3>
|
||||||
|
<p>Choose a template and create a report to start editing locally.</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="panel inspector-panel">
|
||||||
|
<div class="section-heading-row">
|
||||||
|
<h2>Inspector view</h2>
|
||||||
|
<span class="panel-note">Local draft summary</span>
|
||||||
|
</div>
|
||||||
|
<dl id="reportMeta" class="meta-list">
|
||||||
|
<div>
|
||||||
|
<dt>Report ID</dt>
|
||||||
|
<dd>-</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Template</dt>
|
||||||
|
<dd>-</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Created</dt>
|
||||||
|
<dd>-</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Updated</dt>
|
||||||
|
<dd>-</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="validation-block">
|
||||||
|
<h3>Validation issues</h3>
|
||||||
|
<ul id="validationList" class="validation-list">
|
||||||
|
<li>No report selected.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="attachment-policy">
|
||||||
|
<h3>Image policy</h3>
|
||||||
|
<p id="imagePolicyText" class="policy-copy">
|
||||||
|
Load server configuration to see image limits and optimization rules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Administrator workspace: server-backed configuration editing. -->
|
||||||
|
<section id="adminWorkspace" class="workspace-view" hidden>
|
||||||
|
<section class="hero panel">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Administrator workspace</p>
|
||||||
|
<h2>Configuration control</h2>
|
||||||
|
<p class="hero-copy">
|
||||||
|
Update centrally managed image requirements used by the inspection frontend.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<span id="adminSyncState" class="badge badge-neutral">Server-backed settings</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="editor-grid">
|
||||||
|
<section class="panel editor-panel">
|
||||||
|
<div class="section-heading-row">
|
||||||
|
<h2>Image policy editor</h2>
|
||||||
|
<span class="panel-note">Updates the active server rule</span>
|
||||||
|
</div>
|
||||||
|
<form id="adminImageRulesForm" class="report-form admin-form">
|
||||||
|
<section class="template-section">
|
||||||
|
<div class="field-grid">
|
||||||
|
<div class="field field-full">
|
||||||
|
<div class="field-header">
|
||||||
|
<label class="field-label" for="adminPolicyName">Policy name</label>
|
||||||
|
</div>
|
||||||
|
<input id="adminPolicyName" name="name" class="text-input" type="text" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field field-full">
|
||||||
|
<div class="field-header">
|
||||||
|
<label class="field-label" for="adminAllowedMimeTypes">Allowed MIME types</label>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="adminAllowedMimeTypes"
|
||||||
|
name="allowedMimeTypes"
|
||||||
|
class="text-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="image/jpeg, image/png, image/webp"
|
||||||
|
/>
|
||||||
|
<p class="field-help">Comma-separated values used by the attachment field and browser validation.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="field-header">
|
||||||
|
<label class="field-label" for="adminMaxFileSizeMb">Max file size (MB)</label>
|
||||||
|
</div>
|
||||||
|
<input id="adminMaxFileSizeMb" name="maxFileSizeMb" class="text-input" type="number" min="1" step="0.1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="field-header">
|
||||||
|
<label class="field-label" for="adminMaxAttachmentsPerField">Max attachments per field</label>
|
||||||
|
</div>
|
||||||
|
<input id="adminMaxAttachmentsPerField" name="maxAttachmentsPerField" class="text-input" type="number" min="1" step="1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="field-header">
|
||||||
|
<label class="field-label" for="adminMaxWidthPx">Max width (px)</label>
|
||||||
|
</div>
|
||||||
|
<input id="adminMaxWidthPx" name="maxWidthPx" class="text-input" type="number" min="1" step="1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="field-header">
|
||||||
|
<label class="field-label" for="adminMaxHeightPx">Max height (px)</label>
|
||||||
|
</div>
|
||||||
|
<input id="adminMaxHeightPx" name="maxHeightPx" class="text-input" type="number" min="1" step="1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="field-header">
|
||||||
|
<label class="field-label" for="adminJpegQuality">JPEG quality</label>
|
||||||
|
</div>
|
||||||
|
<input id="adminJpegQuality" name="jpegQuality" class="text-input" type="number" min="1" max="100" step="1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<div class="field-header">
|
||||||
|
<label class="field-label" for="adminOversizeBehavior">Oversize behavior</label>
|
||||||
|
</div>
|
||||||
|
<select id="adminOversizeBehavior" name="oversizeBehavior" class="select-input">
|
||||||
|
<option value="auto_optimize">Auto optimize</option>
|
||||||
|
<option value="warn_then_optimize">Warn then optimize</option>
|
||||||
|
<option value="block">Block oversized files</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="admin-actions">
|
||||||
|
<button id="saveImageRulesButton" class="button button-primary" type="submit">
|
||||||
|
Save image policy
|
||||||
|
</button>
|
||||||
|
<button id="resetImageRulesButton" class="button button-secondary" type="button">
|
||||||
|
Reset form
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="panel inspector-panel">
|
||||||
|
<div class="section-heading-row">
|
||||||
|
<h2>Admin summary</h2>
|
||||||
|
<span class="panel-note">Live configuration preview</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="meta-list">
|
||||||
|
<div>
|
||||||
|
<dt>Active policy code</dt>
|
||||||
|
<dd id="adminPolicyCode">-</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Allowed types</dt>
|
||||||
|
<dd id="adminPolicyMimeTypes">-</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Optimization</dt>
|
||||||
|
<dd id="adminPolicyOptimization">-</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Limits</dt>
|
||||||
|
<dd id="adminPolicyLimits">-</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="validation-block">
|
||||||
|
<h3>Admin notes</h3>
|
||||||
|
<ul class="validation-list" id="adminNotesList">
|
||||||
|
<li>Changes are stored on the server and reused by report attachments.</li>
|
||||||
|
<li>Operators will use the updated policy after the next sync.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Report list items are rendered from this template at runtime so the sidebar
|
||||||
|
can update without rebuilding the entire page markup from strings.
|
||||||
|
-->
|
||||||
|
<template id="reportListItemTemplate">
|
||||||
|
<button class="report-list-item" type="button" data-report-id="">
|
||||||
|
<span class="report-list-item__header">
|
||||||
|
<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>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* API communication module. Centralizes fetch calls and service-worker
|
||||||
|
* registration so network details stay out of rendering and state logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { API_BASE } from './constants.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Generic JSON fetcher. All frontend API calls pass through this function so
|
||||||
|
* error handling, header defaults, and base path are consistent everywhere.
|
||||||
|
*/
|
||||||
|
export async function fetchJson(path, options = {}) {
|
||||||
|
const url = path.startsWith('http') ? path : `${API_BASE}${path}`;
|
||||||
|
|
||||||
|
const requestOptions = {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(options.headers || {})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(url, requestOptions);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let message = `Request failed for ${url}: ${response.status}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorPayload = await response.json();
|
||||||
|
|
||||||
|
if (errorPayload?.message) {
|
||||||
|
message = errorPayload.message;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* Ignore JSON parse errors for non-JSON responses. */
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerServiceWorker() {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch((error) => {
|
||||||
|
console.error('Service worker registration failed', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
* Application-wide constants shared by all frontend modules.
|
||||||
|
*
|
||||||
|
* Centralizing these values prevents magic strings and numbers from being
|
||||||
|
* scattered across the codebase and makes configuration changes easy to trace.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* IndexedDB database name and schema version (bump when stores change). */
|
||||||
|
export const DB_NAME = 'check-list-poc-db';
|
||||||
|
export const DB_VERSION = 2;
|
||||||
|
|
||||||
|
/* IndexedDB object store names. */
|
||||||
|
export const STORE_TEMPLATES = 'templates';
|
||||||
|
export const STORE_LOOKUPS = 'lookups';
|
||||||
|
export const STORE_CONFIG = 'config';
|
||||||
|
export const STORE_REPORTS = 'reports';
|
||||||
|
export const STORE_ATTACHMENTS = 'attachments';
|
||||||
|
export const STORE_SETTINGS = 'settings';
|
||||||
|
|
||||||
|
/* Autosave interval used when the server does not supply a value. */
|
||||||
|
export const DEFAULT_AUTOSAVE_SECONDS = 20;
|
||||||
|
|
||||||
|
/* Base path for all versioned API calls. */
|
||||||
|
export const API_BASE = '/api/v1';
|
||||||
|
|
||||||
|
/* Minimum delay before re-rendering the form after a field change. */
|
||||||
|
export const RENDER_DEBOUNCE_MS = 200;
|
||||||
|
|
||||||
|
/* Maximum entries kept in the Service Worker dynamic cache (LRU eviction). */
|
||||||
|
export const SW_DYNAMIC_CACHE_LIMIT = 50;
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
/*
|
||||||
|
* IndexedDB operations module. Provides typed helpers for reading and writing to
|
||||||
|
* the browser-side database. All functions depend on `state.db` being set during
|
||||||
|
* initialization.
|
||||||
|
*
|
||||||
|
* Changes from the original monolithic app.js:
|
||||||
|
* - A5: `dbTransaction()` wraps multi-store operations in a single IndexedDB
|
||||||
|
* transaction so related writes (e.g. delete report + delete attachments) are
|
||||||
|
* atomic and won't leave orphaned records on mid-operation failure.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { state } from './state.js';
|
||||||
|
import {
|
||||||
|
DB_NAME,
|
||||||
|
DB_VERSION,
|
||||||
|
STORE_TEMPLATES,
|
||||||
|
STORE_LOOKUPS,
|
||||||
|
STORE_CONFIG,
|
||||||
|
STORE_REPORTS,
|
||||||
|
STORE_ATTACHMENTS,
|
||||||
|
STORE_SETTINGS
|
||||||
|
} from './constants.js';
|
||||||
|
|
||||||
|
/* ── Database bootstrap ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function openDatabase() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = request.result;
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains(STORE_TEMPLATES)) {
|
||||||
|
db.createObjectStore(STORE_TEMPLATES, { keyPath: 'cacheKey' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains(STORE_LOOKUPS)) {
|
||||||
|
db.createObjectStore(STORE_LOOKUPS, { keyPath: 'code' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains(STORE_CONFIG)) {
|
||||||
|
db.createObjectStore(STORE_CONFIG, { keyPath: 'key' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains(STORE_REPORTS)) {
|
||||||
|
db.createObjectStore(STORE_REPORTS, { keyPath: 'id' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains(STORE_ATTACHMENTS)) {
|
||||||
|
const store = db.createObjectStore(STORE_ATTACHMENTS, { keyPath: 'id' });
|
||||||
|
store.createIndex('byReportId', 'reportId', { unique: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains(STORE_SETTINGS)) {
|
||||||
|
db.createObjectStore(STORE_SETTINGS, { keyPath: 'key' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Single-store helpers ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function dbGetAll(storeName) {
|
||||||
|
return executeStoreRequest(storeName, 'readonly', (store) => store.getAll());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dbGet(storeName, key) {
|
||||||
|
return executeStoreRequest(storeName, 'readonly', (store) => store.get(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dbPut(storeName, value) {
|
||||||
|
return executeStoreRequest(storeName, 'readwrite', (store) => store.put(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dbDelete(storeName, key) {
|
||||||
|
return executeStoreRequest(storeName, 'readwrite', (store) => store.delete(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dbGetAllByIndex(storeName, indexName, key) {
|
||||||
|
return executeStoreRequest(storeName, 'readonly', (store) => {
|
||||||
|
return store.index(indexName).getAll(IDBKeyRange.only(key));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function executeStoreRequest(storeName, mode, callback) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = state.db.transaction(storeName, mode);
|
||||||
|
const store = transaction.objectStore(storeName);
|
||||||
|
const request = callback(store);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Multi-store transaction (A5) ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Wraps multiple writes across different object stores in a single IndexedDB
|
||||||
|
* transaction. The callback receives a helper that returns a store by name.
|
||||||
|
* All writes either commit together or roll back as a unit.
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* await dbTransaction([STORE_REPORTS, STORE_ATTACHMENTS], 'readwrite', (getStore) => {
|
||||||
|
* getStore(STORE_REPORTS).delete(reportId);
|
||||||
|
* getStore(STORE_ATTACHMENTS).delete(attachmentId);
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function dbTransaction(storeNames, mode, callback) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = state.db.transaction(storeNames, mode);
|
||||||
|
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
tx.onabort = () => reject(tx.error || new Error('Transaction aborted'));
|
||||||
|
|
||||||
|
const getStore = (name) => tx.objectStore(name);
|
||||||
|
callback(getStore);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Settings helpers ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export async function saveSetting(key, value) {
|
||||||
|
await dbPut(STORE_SETTINGS, { key, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadSetting(key) {
|
||||||
|
const record = await dbGet(STORE_SETTINGS, key);
|
||||||
|
return record?.value;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
/*
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/*
|
||||||
|
* 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 };
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* Web Worker for image optimization (P1). Offloads the expensive bitmap decode,
|
||||||
|
* resize, and JPEG/PNG compression to a background thread so the UI remains
|
||||||
|
* responsive while processing large photos.
|
||||||
|
*
|
||||||
|
* Uses OffscreenCanvas which is available in Chrome 69+, Firefox 105+, and
|
||||||
|
* Safari 16.4+. The main module (images.js) detects support at runtime and
|
||||||
|
* falls back to the main thread for older browsers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function fitIntoBox(originalWidth, originalHeight, maxWidth, maxHeight) {
|
||||||
|
const ratio = Math.min(maxWidth / originalWidth, maxHeight / originalHeight, 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: Math.round(originalWidth * ratio),
|
||||||
|
height: Math.round(originalHeight * ratio)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = async (event) => {
|
||||||
|
const { id, file, imageRules } = event.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imageBitmap = await createImageBitmap(file);
|
||||||
|
const maxWidth = imageRules?.maxWidthPx || imageBitmap.width;
|
||||||
|
const maxHeight = imageRules?.maxHeightPx || imageBitmap.height;
|
||||||
|
const { width, height } = fitIntoBox(imageBitmap.width, imageBitmap.height, maxWidth, maxHeight);
|
||||||
|
|
||||||
|
const canvas = new OffscreenCanvas(width, height);
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
context.drawImage(imageBitmap, 0, 0, width, height);
|
||||||
|
imageBitmap.close();
|
||||||
|
|
||||||
|
const targetMimeType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
|
||||||
|
const quality = Math.min(Math.max((imageRules?.jpegQuality || 82) / 100, 0.2), 0.95);
|
||||||
|
const blob = await canvas.convertToBlob({ type: targetMimeType, quality });
|
||||||
|
|
||||||
|
if (!blob) {
|
||||||
|
throw new Error(`Failed to optimize image: ${file.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageRules?.maxFileSizeBytes && blob.size > imageRules.maxFileSizeBytes) {
|
||||||
|
throw new Error(`Optimized image still exceeds limit: ${file.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.postMessage({
|
||||||
|
id,
|
||||||
|
result: {
|
||||||
|
blob,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
extension: targetMimeType === 'image/png' ? 'png' : 'jpg'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
self.postMessage({ id, error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/*
|
||||||
|
* Image optimization module (P1). When OffscreenCanvas is available the heavy
|
||||||
|
* pixel work runs in a dedicated Web Worker so the UI thread stays responsive
|
||||||
|
* during large-image processing. On browsers that lack OffscreenCanvas support
|
||||||
|
* (or when running inside a Worker is not possible) the module falls back to
|
||||||
|
* main-thread canvas operations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let worker = null;
|
||||||
|
let workerSupported = null;
|
||||||
|
|
||||||
|
function isWorkerSupported() {
|
||||||
|
if (workerSupported !== null) {
|
||||||
|
return workerSupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
/* OffscreenCanvas is required inside the Worker to draw without a DOM. */
|
||||||
|
workerSupported = typeof OffscreenCanvas !== 'undefined' && typeof Worker !== 'undefined';
|
||||||
|
} catch {
|
||||||
|
workerSupported = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return workerSupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorker() {
|
||||||
|
if (!worker && isWorkerSupported()) {
|
||||||
|
worker = new Worker('/js/image-worker.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Public entry point. Validates the file against the image rules, then delegates
|
||||||
|
* the actual resize/compress work to the worker or the main-thread fallback.
|
||||||
|
*/
|
||||||
|
export async function optimizeImage(file, imageRules) {
|
||||||
|
if (imageRules?.allowedMimeTypes?.length && !imageRules.allowedMimeTypes.includes(file.type)) {
|
||||||
|
throw new Error(`Unsupported file type: ${file.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageRules?.oversizeBehavior === 'block' && file.size > imageRules.maxFileSizeBytes) {
|
||||||
|
throw new Error(`File exceeds limit: ${file.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = getWorker();
|
||||||
|
|
||||||
|
if (w) {
|
||||||
|
return optimizeInWorker(w, file, imageRules);
|
||||||
|
}
|
||||||
|
|
||||||
|
return optimizeOnMainThread(file, imageRules);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Worker path ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function optimizeInWorker(w, file, imageRules) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const messageId = crypto.randomUUID();
|
||||||
|
|
||||||
|
function handler(event) {
|
||||||
|
if (event.data?.id !== messageId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
w.removeEventListener('message', handler);
|
||||||
|
w.removeEventListener('error', errorHandler);
|
||||||
|
|
||||||
|
if (event.data.error) {
|
||||||
|
reject(new Error(event.data.error));
|
||||||
|
} else {
|
||||||
|
resolve(event.data.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorHandler(event) {
|
||||||
|
w.removeEventListener('message', handler);
|
||||||
|
w.removeEventListener('error', errorHandler);
|
||||||
|
reject(new Error(event.message || 'Worker error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
w.addEventListener('message', handler);
|
||||||
|
w.addEventListener('error', errorHandler);
|
||||||
|
w.postMessage({ id: messageId, file, imageRules });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main-thread fallback ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
async function optimizeOnMainThread(file, imageRules) {
|
||||||
|
const imageBitmap = await createImageBitmap(file);
|
||||||
|
const maxWidth = imageRules?.maxWidthPx || imageBitmap.width;
|
||||||
|
const maxHeight = imageRules?.maxHeightPx || imageBitmap.height;
|
||||||
|
const { width, height } = fitIntoBox(imageBitmap.width, imageBitmap.height, maxWidth, maxHeight);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
context.drawImage(imageBitmap, 0, 0, width, height);
|
||||||
|
|
||||||
|
const targetMimeType = file.type === 'image/png' ? 'image/png' : 'image/jpeg';
|
||||||
|
const quality = Math.min(Math.max((imageRules?.jpegQuality || 82) / 100, 0.2), 0.95);
|
||||||
|
|
||||||
|
const blob = await new Promise((resolve) => {
|
||||||
|
canvas.toBlob(resolve, targetMimeType, quality);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!blob) {
|
||||||
|
throw new Error(`Failed to optimize image: ${file.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageRules?.maxFileSizeBytes && blob.size > imageRules.maxFileSizeBytes) {
|
||||||
|
throw new Error(`Optimized image still exceeds limit: ${file.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blob,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
extension: targetMimeType === 'image/png' ? 'png' : 'jpg'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fitIntoBox(originalWidth, originalHeight, maxWidth, maxHeight) {
|
||||||
|
const ratio = Math.min(maxWidth / originalWidth, maxHeight / originalHeight, 1);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: Math.round(originalWidth * ratio),
|
||||||
|
height: Math.round(originalHeight * ratio)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,389 @@
|
|||||||
|
/*
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* Centralized application state. Every module imports the same `state` object so
|
||||||
|
* all shared data lives in one place. The `elements` object caches DOM references
|
||||||
|
* established during initialization.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const state = {
|
||||||
|
db: null,
|
||||||
|
reports: [],
|
||||||
|
templatesCatalog: [],
|
||||||
|
templateDefinitions: new Map(),
|
||||||
|
lookups: new Map(),
|
||||||
|
imageRules: null,
|
||||||
|
appConfig: new Map(),
|
||||||
|
currentReportId: null,
|
||||||
|
currentAttachments: [],
|
||||||
|
selectedTemplateCode: null,
|
||||||
|
saveState: 'idle',
|
||||||
|
saveTimer: null,
|
||||||
|
autosaveIntervalId: null,
|
||||||
|
lastSyncAt: null,
|
||||||
|
/* Dirty flag: true when the current report has unsaved field changes (P6). */
|
||||||
|
dirty: false,
|
||||||
|
/* Search/filter state for the report list (F4). */
|
||||||
|
reportSearchQuery: '',
|
||||||
|
reportFilterStatus: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Cached DOM element references, populated once during init. */
|
||||||
|
export const elements = {};
|
||||||
|
|
||||||
|
/* ── State accessors ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function getCurrentReport() {
|
||||||
|
return state.reports.find((item) => item.id === state.currentReportId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTemplateRecord(code, version) {
|
||||||
|
if (!code || version == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { makeTemplateKey } = stateHelpers;
|
||||||
|
return state.templateDefinitions.get(makeTemplateKey(code, version)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Externalizing makeTemplateKey as a helper avoids a circular import — utils.js
|
||||||
|
* cannot import state.js. Instead state.js imports nothing from utils; callers
|
||||||
|
* that need both can reference stateHelpers.makeTemplateKey.
|
||||||
|
*/
|
||||||
|
const stateHelpers = {
|
||||||
|
makeTemplateKey(code, version) {
|
||||||
|
return `${code}::${version}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export { stateHelpers };
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
/*
|
||||||
|
* 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
* Report and image-rules validation. These are pure functions with no DOM or
|
||||||
|
* state dependencies so they can be tested independently and kept in sync with
|
||||||
|
* server-side validation in src/routes/configRoutes.js (A7).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Report validation ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Returns an array of human-readable issue strings. The caller decides whether
|
||||||
|
* issues are informational or blocking.
|
||||||
|
*/
|
||||||
|
export function validateReport(report, template, attachments) {
|
||||||
|
const issues = [];
|
||||||
|
|
||||||
|
for (const section of template.definition.sections || []) {
|
||||||
|
for (const field of section.fields || []) {
|
||||||
|
const value = report.answers[field.id];
|
||||||
|
const required = Boolean(field.required || evaluateRequiredWhen(field.requiredWhen, report.answers));
|
||||||
|
|
||||||
|
if (field.type === 'attachment') {
|
||||||
|
const fieldAttachments = attachments.filter((item) => item.fieldId === field.id);
|
||||||
|
|
||||||
|
if (required && fieldAttachments.length === 0) {
|
||||||
|
issues.push(`${field.label}: at least one image is required.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (required && isBlankValue(value, field.type)) {
|
||||||
|
issues.push(`${field.label}: value is required.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'number' && value !== '' && value != null) {
|
||||||
|
if (Number.isNaN(Number(value))) {
|
||||||
|
issues.push(`${field.label}: number is invalid.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.validation?.min !== undefined && Number(value) < field.validation.min) {
|
||||||
|
issues.push(`${field.label}: must be at least ${field.validation.min}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Image-rules validation (mirrors server-side logic in configRoutes) ── */
|
||||||
|
|
||||||
|
export function validateImageRulesPayload(payload) {
|
||||||
|
if (!payload.name) {
|
||||||
|
return 'Policy name is required';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload.allowedMimeTypes.length) {
|
||||||
|
return 'Add at least one MIME type';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isFinite(payload.maxFileSizeBytes) || payload.maxFileSizeBytes <= 0) {
|
||||||
|
return 'Max file size must be greater than 0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(payload.maxWidthPx) || payload.maxWidthPx <= 0) {
|
||||||
|
return 'Max width must be a positive number';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(payload.maxHeightPx) || payload.maxHeightPx <= 0) {
|
||||||
|
return 'Max height must be a positive number';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(payload.jpegQuality) || payload.jpegQuality < 1 || payload.jpegQuality > 100) {
|
||||||
|
return 'JPEG quality must be between 1 and 100';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(payload.maxAttachmentsPerField) || payload.maxAttachmentsPerField <= 0) {
|
||||||
|
return 'Max attachments must be a positive number';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Helpers ────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function evaluateRequiredWhen(requiredWhen, answers) {
|
||||||
|
if (!requiredWhen?.field) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return answers[requiredWhen.field] === requiredWhen.equals;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBlankValue(value, type) {
|
||||||
|
if (type === 'checkbox') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value === '' || value == null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "Check List Proof of Concept",
|
||||||
|
"short_name": "Check List",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f3efe6",
|
||||||
|
"theme_color": "#f3efe6",
|
||||||
|
"description": "Offline-first proof of concept for template-driven quality inspection reports.",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<!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 Portal</title>
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="portal-body">
|
||||||
|
<!--
|
||||||
|
The portal intentionally acts as a simple role chooser rather than a real
|
||||||
|
authentication page. It separates user and admin entry points in the PoC
|
||||||
|
without committing the project to a security model too early.
|
||||||
|
-->
|
||||||
|
<main class="portal-shell">
|
||||||
|
<section class="portal-hero panel">
|
||||||
|
<p class="eyebrow">Check List Access</p>
|
||||||
|
<h1>Choose workspace</h1>
|
||||||
|
<p class="portal-copy">
|
||||||
|
Use the operator workspace for quality reports and the administrator workspace for configuration.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="portal-grid">
|
||||||
|
<!-- Direct operator entry for report creation and local draft work. -->
|
||||||
|
<a class="portal-card panel" href="/user">
|
||||||
|
<p class="summary-label">User area</p>
|
||||||
|
<h2>Operator workspace</h2>
|
||||||
|
<p class="portal-copy">
|
||||||
|
Create reports, work offline, attach images, and manage local drafts.
|
||||||
|
</p>
|
||||||
|
<span class="button button-primary portal-button">Open user area</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Direct administrator entry for centrally managed configuration. -->
|
||||||
|
<a class="portal-card panel" href="/admin">
|
||||||
|
<p class="summary-label">Admin area</p>
|
||||||
|
<h2>Administrator workspace</h2>
|
||||||
|
<p class="portal-copy">
|
||||||
|
Maintain image requirements and other centrally managed configuration.
|
||||||
|
</p>
|
||||||
|
<span class="button button-secondary portal-button">Open admin area</span>
|
||||||
|
</a>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,638 @@
|
|||||||
|
:root {
|
||||||
|
/*
|
||||||
|
* The visual system uses a warm industrial palette instead of generic neutral
|
||||||
|
* SaaS colors. The goal is to make the PoC feel closer to an operational tool
|
||||||
|
* used in inspection environments than to a stock admin dashboard.
|
||||||
|
*/
|
||||||
|
--bg: #f3efe6;
|
||||||
|
--bg-strong: #e8dcc7;
|
||||||
|
--panel: rgba(255, 252, 247, 0.92);
|
||||||
|
--panel-border: rgba(93, 67, 35, 0.16);
|
||||||
|
--text: #1c1a18;
|
||||||
|
--muted: #685f53;
|
||||||
|
--accent: #9d3d2e;
|
||||||
|
--accent-strong: #7f2c20;
|
||||||
|
--accent-soft: #f4d2bf;
|
||||||
|
--success: #25624c;
|
||||||
|
--warning: #8a6119;
|
||||||
|
--danger: #8b2e34;
|
||||||
|
--shadow: 0 20px 50px rgba(77, 48, 18, 0.12);
|
||||||
|
--radius-lg: 24px;
|
||||||
|
--radius-md: 16px;
|
||||||
|
--radius-sm: 12px;
|
||||||
|
--font-ui: "Aptos", "Segoe UI Variable", "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(255, 255, 255, 0.7), transparent 35%),
|
||||||
|
linear-gradient(135deg, #f8f2e8 0%, #ead9bb 44%, #e9d8c5 100%);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-body {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
/*
|
||||||
|
* The main layout keeps navigation and report context visible at the same time.
|
||||||
|
* This is important for a checklist workflow where users often switch reports
|
||||||
|
* and need immediate awareness of save state, sync state, and current draft.
|
||||||
|
*/
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
|
||||||
|
min-height: 100vh;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid var(--panel-border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--panel);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 24px;
|
||||||
|
gap: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-block h1,
|
||||||
|
.hero h2,
|
||||||
|
.section-heading-row h2,
|
||||||
|
.empty-state h3,
|
||||||
|
.validation-block h3,
|
||||||
|
.attachment-policy h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--accent-strong);
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede,
|
||||||
|
.hero-copy,
|
||||||
|
.panel-note,
|
||||||
|
.summary-note,
|
||||||
|
.policy-copy,
|
||||||
|
.empty-state p,
|
||||||
|
.meta-list dd,
|
||||||
|
.validation-list,
|
||||||
|
.report-list-item__meta {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link.is-active {
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
|
||||||
|
color: #fff8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-shell {
|
||||||
|
/*
|
||||||
|
* The chooser page is intentionally sparse. Its only job is to separate entry
|
||||||
|
* points for operators and administrators without forcing a more complex auth
|
||||||
|
* design into the PoC before roles and identity are finalized.
|
||||||
|
*/
|
||||||
|
width: min(1100px, 100%);
|
||||||
|
display: grid;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-hero {
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 28px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-copy {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-button {
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow-section {
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row,
|
||||||
|
.section-heading-row,
|
||||||
|
.hero-actions,
|
||||||
|
.field-header,
|
||||||
|
.attachment-toolbar,
|
||||||
|
.report-list-item__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading-row {
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-neutral {
|
||||||
|
background: rgba(28, 26, 24, 0.08);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-online,
|
||||||
|
.status-in_progress,
|
||||||
|
.status-ready_for_export {
|
||||||
|
background: rgba(37, 98, 76, 0.12);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-offline,
|
||||||
|
.status-draft,
|
||||||
|
.status-archived {
|
||||||
|
background: rgba(138, 97, 25, 0.12);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-error,
|
||||||
|
.status-exported {
|
||||||
|
background: rgba(139, 46, 52, 0.12);
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary {
|
||||||
|
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-strong) 100%);
|
||||||
|
color: #fff8f0;
|
||||||
|
box-shadow: 0 12px 24px rgba(157, 61, 46, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary {
|
||||||
|
background: rgba(28, 26, 24, 0.08);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--danger);
|
||||||
|
border: 1px solid rgba(139, 46, 52, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-small {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-view {
|
||||||
|
/*
|
||||||
|
* User and admin workspaces share one HTML document. Hidden sections let the
|
||||||
|
* route control which workspace is visible while still reusing common styling
|
||||||
|
* and keeping asset delivery simple.
|
||||||
|
*/
|
||||||
|
display: none;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-view-active {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 24px 28px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: auto -80px -80px auto;
|
||||||
|
width: 220px;
|
||||||
|
height: 220px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(157, 61, 46, 0.18), transparent 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-actions {
|
||||||
|
align-items: end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-picker {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid,
|
||||||
|
.editor-grid {
|
||||||
|
/*
|
||||||
|
* These grids create a consistent rhythm between overview cards and working
|
||||||
|
* panels so the operator can scan status quickly before dropping into detail.
|
||||||
|
*/
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card {
|
||||||
|
padding: 20px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accent-card {
|
||||||
|
background: linear-gradient(145deg, rgba(157, 61, 46, 0.12), rgba(255, 252, 247, 0.96));
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-label,
|
||||||
|
.field-label,
|
||||||
|
.meta-list dt {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-grid {
|
||||||
|
grid-template-columns: minmax(0, 1.7fr) minmax(300px, 380px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-panel,
|
||||||
|
.inspector-panel {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-form {
|
||||||
|
/*
|
||||||
|
* The form styles are generic on purpose because fields are generated from
|
||||||
|
* template JSON. The same primitives must support report editing and admin
|
||||||
|
* configuration without each field type needing a dedicated page-specific skin.
|
||||||
|
*/
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(243, 239, 230, 0.68);
|
||||||
|
border: 1px solid rgba(93, 67, 35, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-header {
|
||||||
|
align-items: baseline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-pill {
|
||||||
|
color: var(--accent-strong);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input,
|
||||||
|
.select-input,
|
||||||
|
.text-area,
|
||||||
|
.file-input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid rgba(93, 67, 35, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input:focus,
|
||||||
|
.select-input:focus,
|
||||||
|
.text-area:focus,
|
||||||
|
.file-input:focus {
|
||||||
|
outline: 2px solid rgba(157, 61, 46, 0.22);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-area {
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
border: 1px solid rgba(93, 67, 35, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-help {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
max-height: 420px;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* F4 — Search and filter controls above the report list */
|
||||||
|
.report-filter-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input-small,
|
||||||
|
.select-input-small {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-input-small {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-list-item {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
border: 1px solid rgba(93, 67, 35, 0.12);
|
||||||
|
background: rgba(255, 255, 255, 0.74);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-list-item.is-active {
|
||||||
|
border-color: rgba(157, 61, 46, 0.36);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(157, 61, 46, 0.16);
|
||||||
|
background: rgba(244, 210, 191, 0.36);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-list-item__title {
|
||||||
|
font-size: 0.96rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted-count {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
min-height: 240px;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: rgba(243, 239, 230, 0.68);
|
||||||
|
border: 1px dashed rgba(93, 67, 35, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-list div {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid rgba(93, 67, 35, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-list dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-block,
|
||||||
|
.attachment-policy {
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-list {
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.validation-list li + li {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-card {
|
||||||
|
/*
|
||||||
|
* Attachments need enough visual weight to confirm that a photo is really tied
|
||||||
|
* to a report item. The card layout reserves space for preview, metadata, and
|
||||||
|
* removal action without requiring a modal or separate gallery screen.
|
||||||
|
*/
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 88px minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.86);
|
||||||
|
border: 1px solid rgba(93, 67, 35, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-preview {
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
border-radius: 12px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: rgba(93, 67, 35, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-card__copy {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-card__copy strong,
|
||||||
|
.attachment-card__copy span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
/*
|
||||||
|
* Tablet and narrow laptop layouts collapse the two-column structure into a
|
||||||
|
* single column so the editing surface remains usable without horizontal scroll.
|
||||||
|
*/
|
||||||
|
.app-shell,
|
||||||
|
.editor-grid,
|
||||||
|
.summary-grid,
|
||||||
|
.portal-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-list {
|
||||||
|
max-height: 260px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
/*
|
||||||
|
* Mobile layout prioritizes single-column readability and larger preview areas.
|
||||||
|
* This matters because one of the project requirements is viable use on phones
|
||||||
|
* where camera capture and image attachment happen directly in the browser.
|
||||||
|
*/
|
||||||
|
.app-shell {
|
||||||
|
padding: 14px;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero,
|
||||||
|
.hero-actions,
|
||||||
|
.field-grid,
|
||||||
|
.attachment-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-grid {
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-card {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#f3efe6" />
|
||||||
|
<title>Check List PoC — User</title>
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<link rel="stylesheet" href="/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!--
|
||||||
|
Operator workspace: report creation, local draft editing, validation,
|
||||||
|
image attachments, submission, and CSV export.
|
||||||
|
-->
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="sidebar panel">
|
||||||
|
<div class="brand-block">
|
||||||
|
<p class="eyebrow">Hybrid Inspection Reporting</p>
|
||||||
|
<h1>Check List</h1>
|
||||||
|
<p class="lede">
|
||||||
|
Offline-first proof of concept for template-driven quality reports.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="status-row">
|
||||||
|
<span id="connectionBadge" class="badge badge-neutral">Checking connection</span>
|
||||||
|
<span id="saveBadge" class="badge badge-neutral">No changes</span>
|
||||||
|
</div>
|
||||||
|
<button id="syncTemplatesButton" class="button button-secondary" type="button">
|
||||||
|
Sync templates
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<label class="field-label" for="templateSelect">Template</label>
|
||||||
|
<select id="templateSelect" class="select-input"></select>
|
||||||
|
<button id="createReportButton" class="button button-primary" type="button">
|
||||||
|
Create new report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="section-heading-row sidebar-links-heading">
|
||||||
|
<h2>Access</h2>
|
||||||
|
<span class="muted-count">Direct links</span>
|
||||||
|
</div>
|
||||||
|
<a id="userAreaLink" class="button button-secondary sidebar-link is-active" href="/user">User area</a>
|
||||||
|
<a id="adminAreaLink" class="button button-secondary sidebar-link" href="/admin">Admin area</a>
|
||||||
|
<a class="button button-secondary sidebar-link" href="/">Back to portal</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section grow-section">
|
||||||
|
<div class="section-heading-row">
|
||||||
|
<h2>Local reports</h2>
|
||||||
|
<span id="reportCount" class="muted-count">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="report-filter-row">
|
||||||
|
<input id="reportSearchInput" class="text-input text-input-small" type="search" placeholder="Search reports" />
|
||||||
|
<select id="reportFilterSelect" class="select-input select-input-small">
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="ready_for_export">Ready for Export</option>
|
||||||
|
<option value="exported">Exported</option>
|
||||||
|
<option value="archived">Archived</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="reportList" class="report-list"></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="workspace">
|
||||||
|
<section id="reportsWorkspace" class="workspace-view workspace-view-active">
|
||||||
|
<section class="hero panel">
|
||||||
|
<div>
|
||||||
|
<p class="eyebrow">Proof of concept frontend</p>
|
||||||
|
<h2 id="heroTitle">No report selected</h2>
|
||||||
|
<p id="heroSubtitle" class="hero-copy">
|
||||||
|
Start by syncing templates and creating a local draft.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="hero-actions">
|
||||||
|
<label class="status-picker">
|
||||||
|
<span>Status</span>
|
||||||
|
<select id="reportStatusSelect" class="select-input">
|
||||||
|
<option value="draft">Draft</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
<option value="ready_for_export">Ready for Export</option>
|
||||||
|
<option value="exported">Exported</option>
|
||||||
|
<option value="archived">Archived</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button id="submitReportButton" class="button button-secondary" type="button">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
<button id="exportReportButton" class="button button-secondary" type="button">
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
<button id="deleteReportButton" class="button button-ghost" type="button">
|
||||||
|
Delete report
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="summary-grid">
|
||||||
|
<article class="summary-card panel accent-card">
|
||||||
|
<p class="summary-label">Template</p>
|
||||||
|
<strong id="summaryTemplate">Not loaded</strong>
|
||||||
|
<span id="summaryVersion" class="summary-note">Version -</span>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card panel">
|
||||||
|
<p class="summary-label">Validation</p>
|
||||||
|
<strong id="validationHeadline">No report selected</strong>
|
||||||
|
<span id="validationDetail" class="summary-note">Draft validation will appear here.</span>
|
||||||
|
</article>
|
||||||
|
<article class="summary-card panel">
|
||||||
|
<p class="summary-label">Offline cache</p>
|
||||||
|
<strong id="syncHeadline">No sync yet</strong>
|
||||||
|
<span id="syncDetail" class="summary-note">Templates are cached locally after the first successful sync.</span>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="editor-grid">
|
||||||
|
<section class="panel editor-panel">
|
||||||
|
<div class="section-heading-row">
|
||||||
|
<h2>Report editor</h2>
|
||||||
|
<span id="editorHint" class="panel-note">Dynamic form rendering from template JSON</span>
|
||||||
|
</div>
|
||||||
|
<form id="reportForm" class="report-form">
|
||||||
|
<div class="empty-state">
|
||||||
|
<h3>No report open</h3>
|
||||||
|
<p>Choose a template and create a report to start editing locally.</p>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside class="panel inspector-panel">
|
||||||
|
<div class="section-heading-row">
|
||||||
|
<h2>Inspector view</h2>
|
||||||
|
<span class="panel-note">Local draft summary</span>
|
||||||
|
</div>
|
||||||
|
<dl id="reportMeta" class="meta-list">
|
||||||
|
<div>
|
||||||
|
<dt>Report ID</dt>
|
||||||
|
<dd>-</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Template</dt>
|
||||||
|
<dd>-</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Created</dt>
|
||||||
|
<dd>-</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Updated</dt>
|
||||||
|
<dd>-</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div class="validation-block">
|
||||||
|
<h3>Validation issues</h3>
|
||||||
|
<ul id="validationList" class="validation-list">
|
||||||
|
<li>No report selected.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="attachment-policy">
|
||||||
|
<h3>Image policy</h3>
|
||||||
|
<p id="imagePolicyText" class="policy-copy">
|
||||||
|
Load server configuration to see image limits and optimization rules.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</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>
|
||||||
@@ -55,14 +55,14 @@ async function testApi() {
|
|||||||
|
|
||||||
for (const baseUrl of candidates) {
|
for (const baseUrl of candidates) {
|
||||||
try {
|
try {
|
||||||
const health = await getJson(`${baseUrl}/api/health`);
|
const health = await getJson(`${baseUrl}/api/v1/health`);
|
||||||
|
|
||||||
assert.equal(health.statusCode, 200, `Unexpected health status from ${baseUrl}`);
|
assert.equal(health.statusCode, 200, `Unexpected health status from ${baseUrl}`);
|
||||||
assert.equal(health.json.status, 'ok', 'API health endpoint is not healthy');
|
assert.equal(health.json.status, 'ok', 'API health endpoint is not healthy');
|
||||||
assert.equal(health.json.service, 'check-list-poc-api', 'Unexpected API service name');
|
assert.equal(health.json.service, 'check-list-poc-api', 'Unexpected API service name');
|
||||||
assert.equal(health.json.database, 'connected', 'API cannot reach MariaDB');
|
assert.equal(health.json.database, 'connected', 'API cannot reach MariaDB');
|
||||||
|
|
||||||
const templates = await getJson(`${baseUrl}/api/templates`);
|
const templates = await getJson(`${baseUrl}/api/v1/templates`);
|
||||||
|
|
||||||
assert.equal(templates.statusCode, 200, `Unexpected templates status from ${baseUrl}`);
|
assert.equal(templates.statusCode, 200, `Unexpected templates status from ${baseUrl}`);
|
||||||
assert.ok(Array.isArray(templates.json.items), 'Templates response must contain an items array');
|
assert.ok(Array.isArray(templates.json.items), 'Templates response must contain an items array');
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
|
-- This schema supports the phase-1 hybrid proof of concept.
|
||||||
|
-- The database stores centrally managed configuration only: templates,
|
||||||
|
-- lookup values, image policy, export settings, and lightweight app flags.
|
||||||
|
-- Completed reports are intentionally excluded from the server in this phase;
|
||||||
|
-- they remain browser-local artifacts exported by the client.
|
||||||
|
|
||||||
CREATE DATABASE IF NOT EXISTS check_list CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
CREATE DATABASE IF NOT EXISTS check_list CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
USE check_list;
|
USE check_list;
|
||||||
|
|
||||||
|
-- Template identity stays separate from version rows so a single checklist can
|
||||||
|
-- publish multiple definitions over time while preserving a stable code.
|
||||||
CREATE TABLE IF NOT EXISTS templates (
|
CREATE TABLE IF NOT EXISTS templates (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
code VARCHAR(100) NOT NULL,
|
code VARCHAR(100) NOT NULL,
|
||||||
@@ -13,6 +21,9 @@ CREATE TABLE IF NOT EXISTS templates (
|
|||||||
UNIQUE KEY uq_templates_code (code)
|
UNIQUE KEY uq_templates_code (code)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Each template version stores a full JSON definition snapshot. That keeps the
|
||||||
|
-- frontend contract simple because the client can render a form from one payload
|
||||||
|
-- without performing additional joins or reconstruction logic.
|
||||||
CREATE TABLE IF NOT EXISTS template_versions (
|
CREATE TABLE IF NOT EXISTS template_versions (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
template_id BIGINT UNSIGNED NOT NULL,
|
template_id BIGINT UNSIGNED NOT NULL,
|
||||||
@@ -29,6 +40,9 @@ CREATE TABLE IF NOT EXISTS template_versions (
|
|||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Lookup sets support dropdown-style fields in a normalized way. The template
|
||||||
|
-- JSON references the set by code, while individual values remain editable as
|
||||||
|
-- data rather than hardcoded frontend options.
|
||||||
CREATE TABLE IF NOT EXISTS lookup_sets (
|
CREATE TABLE IF NOT EXISTS lookup_sets (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
code VARCHAR(100) NOT NULL,
|
code VARCHAR(100) NOT NULL,
|
||||||
@@ -55,6 +69,9 @@ CREATE TABLE IF NOT EXISTS lookup_values (
|
|||||||
ON DELETE CASCADE
|
ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Image rules are server-managed so administrators can tighten or relax client
|
||||||
|
-- behavior, such as size limits or attachment counts, without changing browser
|
||||||
|
-- code. The client reads the active row and enforces it locally.
|
||||||
CREATE TABLE IF NOT EXISTS image_rules (
|
CREATE TABLE IF NOT EXISTS image_rules (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
code VARCHAR(100) NOT NULL,
|
code VARCHAR(100) NOT NULL,
|
||||||
@@ -72,6 +89,9 @@ CREATE TABLE IF NOT EXISTS image_rules (
|
|||||||
UNIQUE KEY uq_image_rules_code (code)
|
UNIQUE KEY uq_image_rules_code (code)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Export profiles represent the eventual shape of the generated ZIP/XLSX output.
|
||||||
|
-- The current PoC only reads this information, but separating it now makes later
|
||||||
|
-- export customization easier.
|
||||||
CREATE TABLE IF NOT EXISTS export_profiles (
|
CREATE TABLE IF NOT EXISTS export_profiles (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
code VARCHAR(100) NOT NULL,
|
code VARCHAR(100) NOT NULL,
|
||||||
@@ -86,6 +106,9 @@ 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 (
|
CREATE TABLE IF NOT EXISTS app_config (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
config_key VARCHAR(100) NOT NULL,
|
config_key VARCHAR(100) NOT NULL,
|
||||||
@@ -95,3 +118,40 @@ CREATE TABLE IF NOT EXISTS app_config (
|
|||||||
PRIMARY KEY (id),
|
PRIMARY KEY (id),
|
||||||
UNIQUE KEY uq_app_config_key (config_key)
|
UNIQUE KEY uq_app_config_key (config_key)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Submitted reports are stored server-side for centralized review and archival.
|
||||||
|
-- The browser creates reports locally first; this table receives them when the
|
||||||
|
-- operator explicitly submits. The report_uuid links back to the browser-local
|
||||||
|
-- report ID so resubmissions are idempotent via ON DUPLICATE KEY UPDATE.
|
||||||
|
CREATE TABLE IF NOT EXISTS reports (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
report_uuid CHAR(36) NOT NULL,
|
||||||
|
report_number VARCHAR(100) NOT NULL,
|
||||||
|
template_code VARCHAR(100) NOT NULL,
|
||||||
|
template_version INT NOT NULL,
|
||||||
|
status ENUM('draft', 'in_progress', 'ready_for_export', 'exported', 'archived') NOT NULL DEFAULT 'draft',
|
||||||
|
answers_json JSON NOT NULL,
|
||||||
|
submitted_at DATETIME 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_reports_uuid (report_uuid),
|
||||||
|
KEY idx_reports_template (template_code, template_version),
|
||||||
|
KEY idx_reports_status (status)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The audit log captures every administrative mutation so the team can trace
|
||||||
|
-- when configuration changed and what the previous value was. Each row stores
|
||||||
|
-- the entity type, the entity identifier, the action, and JSON snapshots of
|
||||||
|
-- the old and new values.
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_log (
|
||||||
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
entity_type VARCHAR(100) NOT NULL,
|
||||||
|
entity_code VARCHAR(200) NOT NULL,
|
||||||
|
action VARCHAR(50) NOT NULL,
|
||||||
|
old_value_json JSON NULL,
|
||||||
|
new_value_json JSON NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_audit_entity (entity_type, entity_code)
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
-- Seed data provides one complete end-to-end example for the frontend:
|
||||||
|
-- a checklist template, lookup values, image policy, export profile, and a few
|
||||||
|
-- app-level settings. The goal is not exhaustive coverage but a realistic shape
|
||||||
|
-- that exercises the dynamic form renderer and admin configuration flow.
|
||||||
|
|
||||||
USE check_list;
|
USE check_list;
|
||||||
|
|
||||||
|
-- Register the template shell first so later version rows can reference it.
|
||||||
INSERT INTO templates (code, name, description)
|
INSERT INTO templates (code, name, description)
|
||||||
VALUES (
|
VALUES (
|
||||||
'incoming-inspection',
|
'incoming-inspection',
|
||||||
@@ -12,6 +18,9 @@ ON DUPLICATE KEY UPDATE
|
|||||||
|
|
||||||
SET @template_id = (SELECT id FROM templates WHERE code = 'incoming-inspection');
|
SET @template_id = (SELECT id FROM templates WHERE code = 'incoming-inspection');
|
||||||
|
|
||||||
|
-- The JSON payload below is intentionally close to the frontend contract. That
|
||||||
|
-- makes it easy to inspect how the browser creates fields, validation hints, and
|
||||||
|
-- attachment requirements without another transformation layer in the backend.
|
||||||
INSERT INTO template_versions (
|
INSERT INTO template_versions (
|
||||||
template_id,
|
template_id,
|
||||||
version_number,
|
version_number,
|
||||||
@@ -126,6 +135,8 @@ ON DUPLICATE KEY UPDATE
|
|||||||
definition_json = VALUES(definition_json),
|
definition_json = VALUES(definition_json),
|
||||||
published_at = VALUES(published_at);
|
published_at = VALUES(published_at);
|
||||||
|
|
||||||
|
-- Lookups are seeded separately because multiple templates may eventually reuse
|
||||||
|
-- the same option sets, such as pass/fail or standardized status lists.
|
||||||
INSERT INTO lookup_sets (code, name)
|
INSERT INTO lookup_sets (code, name)
|
||||||
VALUES
|
VALUES
|
||||||
('pass-fail', 'Pass/Fail'),
|
('pass-fail', 'Pass/Fail'),
|
||||||
@@ -150,6 +161,7 @@ ON DUPLICATE KEY UPDATE
|
|||||||
sort_order = VALUES(sort_order),
|
sort_order = VALUES(sort_order),
|
||||||
is_default = VALUES(is_default);
|
is_default = VALUES(is_default);
|
||||||
|
|
||||||
|
-- The image rule row is the configuration edited by the administrator UI.
|
||||||
INSERT INTO image_rules (
|
INSERT INTO image_rules (
|
||||||
code,
|
code,
|
||||||
name,
|
name,
|
||||||
@@ -185,6 +197,7 @@ ON DUPLICATE KEY UPDATE
|
|||||||
max_attachments_per_field = VALUES(max_attachments_per_field),
|
max_attachments_per_field = VALUES(max_attachments_per_field),
|
||||||
is_active = VALUES(is_active);
|
is_active = VALUES(is_active);
|
||||||
|
|
||||||
|
-- Export profile settings are placeholders for the later XLSX/ZIP phase.
|
||||||
INSERT INTO export_profiles (
|
INSERT INTO export_profiles (
|
||||||
code,
|
code,
|
||||||
name,
|
name,
|
||||||
@@ -211,6 +224,7 @@ ON DUPLICATE KEY UPDATE
|
|||||||
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)
|
INSERT INTO app_config (config_key, config_value_json)
|
||||||
VALUES
|
VALUES
|
||||||
('autosave', '{"enabled": true, "intervalSeconds": 20}'),
|
('autosave', '{"enabled": true, "intervalSeconds": 20}'),
|
||||||
|
|||||||
+54
-7
@@ -1,29 +1,76 @@
|
|||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
import { errorHandler, notFoundHandler } from './middleware/errorHandler.js';
|
import { errorHandler, notFoundHandler } from './middleware/errorHandler.js';
|
||||||
import configRoutes from './routes/configRoutes.js';
|
import configRoutes from './routes/configRoutes.js';
|
||||||
import healthRoutes from './routes/healthRoutes.js';
|
import healthRoutes from './routes/healthRoutes.js';
|
||||||
import lookupRoutes from './routes/lookupRoutes.js';
|
import lookupRoutes from './routes/lookupRoutes.js';
|
||||||
|
import reportRoutes from './routes/reportRoutes.js';
|
||||||
import templateRoutes from './routes/templateRoutes.js';
|
import templateRoutes from './routes/templateRoutes.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The application serves two concerns from the same Express process:
|
||||||
|
* 1. a versioned REST API (v1) used by the proof-of-concept frontend,
|
||||||
|
* 2. static frontend assets for the chooser portal and the app shell itself.
|
||||||
|
*
|
||||||
|
* All API endpoints live under /api/v1/ so the contract can evolve without
|
||||||
|
* breaking older clients. Keeping both concerns in one process keeps the PoC
|
||||||
|
* easy to run in Docker and avoids introducing an additional frontend dev
|
||||||
|
* server before the product shape is stable.
|
||||||
|
*/
|
||||||
|
const publicDir = fileURLToPath(new URL('../public', import.meta.url));
|
||||||
|
const userPagePath = path.join(publicDir, 'user.html');
|
||||||
|
const adminPagePath = path.join(publicDir, 'admin.html');
|
||||||
|
const portalPath = path.join(publicDir, 'portal.html');
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
|
||||||
app.get('/', (_req, res) => {
|
app.get('/api/v1', (_req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
service: 'check-list-poc-api',
|
service: 'check-list-poc-api',
|
||||||
version: '0.1.0',
|
version: '0.2.0',
|
||||||
description: 'PoC API for template and configuration delivery.'
|
description: 'Versioned PoC API for template, configuration, and report management.'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use('/api/health', healthRoutes);
|
/*
|
||||||
app.use('/api/templates', templateRoutes);
|
* All API routes are grouped under /api/v1/. The version prefix ensures future
|
||||||
app.use('/api/lookups', lookupRoutes);
|
* breaking changes can be introduced on /api/v2/ without disrupting existing
|
||||||
app.use('/api/config', configRoutes);
|
* frontend deployments that still reference v1 contract shapes.
|
||||||
|
*/
|
||||||
|
app.use('/api/v1/health', healthRoutes);
|
||||||
|
app.use('/api/v1/templates', templateRoutes);
|
||||||
|
app.use('/api/v1/lookups', lookupRoutes);
|
||||||
|
app.use('/api/v1/config', configRoutes);
|
||||||
|
app.use('/api/v1/reports', reportRoutes);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The root route intentionally serves a neutral portal page. This gives the
|
||||||
|
* project distinct user and administrator entry points without introducing a
|
||||||
|
* full authentication flow yet.
|
||||||
|
*/
|
||||||
|
app.get('/', (_req, res) => {
|
||||||
|
res.sendFile(portalPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* User and admin workspaces live in separate HTML files so each page only loads
|
||||||
|
* the markup it needs. The shared frontend JavaScript (app.js) detects which
|
||||||
|
* elements are present and binds behavior accordingly.
|
||||||
|
*/
|
||||||
|
app.get(['/user', '/user/'], (_req, res) => {
|
||||||
|
res.sendFile(userPagePath);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get(['/admin', '/admin/'], (_req, res) => {
|
||||||
|
res.sendFile(adminPagePath);
|
||||||
|
});
|
||||||
|
app.use(express.static(publicDir));
|
||||||
|
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
app.use(errorHandler);
|
app.use(errorHandler);
|
||||||
|
|||||||
@@ -4,12 +4,23 @@ dotenv.config();
|
|||||||
|
|
||||||
const requiredKeys = ['DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD'];
|
const requiredKeys = ['DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD'];
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Environment validation happens at module load time so configuration mistakes
|
||||||
|
* are discovered immediately. That is intentional because this service is small
|
||||||
|
* enough that there is no benefit in deferring a misconfiguration error until a
|
||||||
|
* later database call or request handler.
|
||||||
|
*/
|
||||||
for (const key of requiredKeys) {
|
for (const key of requiredKeys) {
|
||||||
if (!process.env[key]) {
|
if (!process.env[key]) {
|
||||||
throw new Error(`Missing required environment variable: ${key}`);
|
throw new Error(`Missing required environment variable: ${key}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The exported env object becomes the single place where raw process variables
|
||||||
|
* are normalized into application-friendly types such as numbers. That keeps the
|
||||||
|
* rest of the codebase from repeating string-to-number conversion logic.
|
||||||
|
*/
|
||||||
export const env = {
|
export const env = {
|
||||||
port: Number(process.env.PORT || 3000),
|
port: Number(process.env.PORT || 3000),
|
||||||
db: {
|
db: {
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import * as mariadb from 'mariadb';
|
|||||||
|
|
||||||
import { env } from '../config/env.js';
|
import { env } from '../config/env.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* One shared pool is enough for the current backend because the service is read-
|
||||||
|
* heavy and low volume. Centralizing pool creation here prevents each route or
|
||||||
|
* service module from opening its own connections and makes shutdown predictable.
|
||||||
|
*/
|
||||||
const pool = mariadb.createPool({
|
const pool = mariadb.createPool({
|
||||||
host: env.db.host,
|
host: env.db.host,
|
||||||
port: env.db.port,
|
port: env.db.port,
|
||||||
@@ -16,6 +21,11 @@ export async function query(sql, params = []) {
|
|||||||
let connection;
|
let connection;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
/*
|
||||||
|
* The helper deliberately exposes a low-level query primitive instead of a
|
||||||
|
* custom repository abstraction. For the PoC that keeps SQL visible and easy
|
||||||
|
* to reason about while still ensuring every query uses the same pool.
|
||||||
|
*/
|
||||||
connection = await pool.getConnection();
|
connection = await pool.getConnection();
|
||||||
return await connection.query(sql, params);
|
return await connection.query(sql, params);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* Parameter validation middleware. Each route parameter is checked against
|
||||||
|
* a safe pattern to prevent unexpected input from reaching database queries.
|
||||||
|
* The whitelist approach rejects obviously invalid identifiers early, keeping
|
||||||
|
* service-layer code cleaner.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SAFE_CODE_PATTERN = /^[a-zA-Z0-9_-]{1,100}$/;
|
||||||
|
const SAFE_UUID_PATTERN = /^[a-f0-9-]{36}$/;
|
||||||
|
|
||||||
|
export function validateParam(paramName, { pattern = null } = {}) {
|
||||||
|
const resolvedPattern = pattern || (paramName.toLowerCase().includes('id') ? SAFE_UUID_PATTERN : SAFE_CODE_PATTERN);
|
||||||
|
|
||||||
|
return (req, res, next) => {
|
||||||
|
const value = req.params[paramName];
|
||||||
|
|
||||||
|
if (!value || !resolvedPattern.test(value)) {
|
||||||
|
return res.status(400).json({ message: `Invalid parameter: ${paramName}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateNumericParam(paramName) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const value = Number(req.params[paramName]);
|
||||||
|
|
||||||
|
if (!Number.isFinite(value) || value < 0) {
|
||||||
|
return res.status(400).json({ message: `Invalid numeric parameter: ${paramName}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
+113
-2
@@ -3,22 +3,128 @@ import { Router } from 'express';
|
|||||||
import {
|
import {
|
||||||
getAppConfig,
|
getAppConfig,
|
||||||
getExportProfile,
|
getExportProfile,
|
||||||
getImageRules
|
getImageRules,
|
||||||
|
updateImageRules
|
||||||
} from '../services/configService.js';
|
} from '../services/configService.js';
|
||||||
|
import { logAuditEvent } from '../services/auditService.js';
|
||||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||||
|
import { configCache } from '../services/cacheService.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Image-rules validation is shared between server and admin frontend. The server
|
||||||
|
* acts as the final authority while the client validates proactively to give the
|
||||||
|
* administrator immediate feedback before the round-trip.
|
||||||
|
*/
|
||||||
|
function validateImageRulesPayload(payload) {
|
||||||
|
const oversizeBehaviors = ['auto_optimize', 'warn_then_optimize', 'block'];
|
||||||
|
const allowedMimeTypes = Array.isArray(payload.allowedMimeTypes)
|
||||||
|
? payload.allowedMimeTypes.filter((value) => typeof value === 'string' && value.trim())
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (!payload.name || typeof payload.name !== 'string') {
|
||||||
|
return 'Image policy name is required.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowedMimeTypes.length) {
|
||||||
|
return 'At least one allowed MIME type is required.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(payload.maxFileSizeBytes) || payload.maxFileSizeBytes <= 0) {
|
||||||
|
return 'Maximum file size must be a positive integer.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(payload.maxWidthPx) || payload.maxWidthPx <= 0) {
|
||||||
|
return 'Maximum width must be a positive integer.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(payload.maxHeightPx) || payload.maxHeightPx <= 0) {
|
||||||
|
return 'Maximum height must be a positive integer.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(payload.jpegQuality) || payload.jpegQuality < 1 || payload.jpegQuality > 100) {
|
||||||
|
return 'JPEG quality must be an integer between 1 and 100.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oversizeBehaviors.includes(payload.oversizeBehavior)) {
|
||||||
|
return 'Oversize behavior is invalid.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Number.isInteger(payload.maxAttachmentsPerField) || payload.maxAttachmentsPerField <= 0) {
|
||||||
|
return 'Maximum attachments per field must be a positive integer.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/image-rules',
|
'/image-rules',
|
||||||
asyncHandler(async (_req, res) => {
|
asyncHandler(async (_req, res) => {
|
||||||
|
const cached = configCache.get('image-rules');
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return res.json(cached);
|
||||||
|
}
|
||||||
|
|
||||||
const imageRules = await getImageRules();
|
const imageRules = await getImageRules();
|
||||||
|
|
||||||
if (!imageRules) {
|
if (!imageRules) {
|
||||||
return res.status(404).json({ message: 'Image rules not found.' });
|
return res.status(404).json({ message: 'Image rules not found.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json(imageRules);
|
configCache.set('image-rules', imageRules);
|
||||||
|
return res.json(imageRules);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/image-rules',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
/*
|
||||||
|
* Normalize incoming values before validation so the API can accept the
|
||||||
|
* browser form payload in a predictable shape. The frontend sends numbers as
|
||||||
|
* form values, but they still arrive over HTTP as strings until coerced.
|
||||||
|
*/
|
||||||
|
const payload = {
|
||||||
|
name: req.body?.name?.trim(),
|
||||||
|
allowedMimeTypes: Array.isArray(req.body?.allowedMimeTypes)
|
||||||
|
? req.body.allowedMimeTypes.map((value) => String(value).trim()).filter(Boolean)
|
||||||
|
: [],
|
||||||
|
maxFileSizeBytes: Number(req.body?.maxFileSizeBytes),
|
||||||
|
maxWidthPx: Number(req.body?.maxWidthPx),
|
||||||
|
maxHeightPx: Number(req.body?.maxHeightPx),
|
||||||
|
jpegQuality: Number(req.body?.jpegQuality),
|
||||||
|
oversizeBehavior: req.body?.oversizeBehavior,
|
||||||
|
maxAttachmentsPerField: Number(req.body?.maxAttachmentsPerField)
|
||||||
|
};
|
||||||
|
|
||||||
|
const validationMessage = validateImageRulesPayload(payload);
|
||||||
|
|
||||||
|
if (validationMessage) {
|
||||||
|
return res.status(400).json({ message: validationMessage });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Capture the current value before mutation for the audit trail. */
|
||||||
|
const previousRules = await getImageRules();
|
||||||
|
|
||||||
|
const imageRules = await updateImageRules(payload);
|
||||||
|
|
||||||
|
if (!imageRules) {
|
||||||
|
return res.status(404).json({ message: 'Image rules not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
configCache.invalidate('image-rules');
|
||||||
|
|
||||||
|
await logAuditEvent({
|
||||||
|
entityType: 'image_rules',
|
||||||
|
entityCode: imageRules.code,
|
||||||
|
action: 'update',
|
||||||
|
oldValue: previousRules,
|
||||||
|
newValue: imageRules
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(imageRules);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -38,6 +144,11 @@ router.get(
|
|||||||
router.get(
|
router.get(
|
||||||
'/app-config',
|
'/app-config',
|
||||||
asyncHandler(async (_req, res) => {
|
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();
|
const config = await getAppConfig();
|
||||||
res.json({ items: config });
|
res.json({ items: config });
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ const router = Router();
|
|||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
asyncHandler(async (_req, res) => {
|
asyncHandler(async (_req, res) => {
|
||||||
|
/*
|
||||||
|
* The health endpoint checks the database on purpose instead of only proving
|
||||||
|
* that Express can answer HTTP. In this project, the server is not useful if
|
||||||
|
* MariaDB is unavailable, so health must include that dependency.
|
||||||
|
*/
|
||||||
await query('SELECT 1 AS ok');
|
await query('SELECT 1 AS ok');
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -2,26 +2,55 @@ import { Router } from 'express';
|
|||||||
|
|
||||||
import { getLookup, listLookups } from '../services/lookupService.js';
|
import { getLookup, listLookups } from '../services/lookupService.js';
|
||||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||||
|
import { validateParam } from '../middleware/validateParams.js';
|
||||||
|
import { lookupCache } from '../services/cacheService.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
asyncHandler(async (_req, res) => {
|
asyncHandler(async (_req, res) => {
|
||||||
|
/*
|
||||||
|
* The bulk lookup endpoint is convenient for client startup because dropdown
|
||||||
|
* lists are small and reused across many dynamic fields. Fetching them in one
|
||||||
|
* call keeps the frontend startup sequence short.
|
||||||
|
*/
|
||||||
|
const cached = lookupCache.get('all-lookups');
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return res.json(cached);
|
||||||
|
}
|
||||||
|
|
||||||
const lookups = await listLookups();
|
const lookups = await listLookups();
|
||||||
res.json({ items: lookups });
|
const payload = { items: lookups };
|
||||||
|
lookupCache.set('all-lookups', payload);
|
||||||
|
return res.json(payload);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/:lookupCode',
|
'/:lookupCode',
|
||||||
|
validateParam('lookupCode'),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
/*
|
||||||
|
* The single-lookup endpoint is still useful for debugging and for possible
|
||||||
|
* future optimization if the number of lookup sets grows and startup payloads
|
||||||
|
* need to become more selective.
|
||||||
|
*/
|
||||||
|
const cacheKey = `lookup-${req.params.lookupCode}`;
|
||||||
|
const cached = lookupCache.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return res.json(cached);
|
||||||
|
}
|
||||||
|
|
||||||
const lookup = await getLookup(req.params.lookupCode);
|
const lookup = await getLookup(req.params.lookupCode);
|
||||||
|
|
||||||
if (!lookup) {
|
if (!lookup) {
|
||||||
return res.status(404).json({ message: 'Lookup not found.' });
|
return res.status(404).json({ message: 'Lookup not found.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lookupCache.set(cacheKey, lookup);
|
||||||
return res.json(lookup);
|
return res.json(lookup);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
|
||||||
|
import { getReport, listReports, submitReport } from '../services/reportService.js';
|
||||||
|
import { logAuditEvent } from '../services/auditService.js';
|
||||||
|
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||||
|
import { validateParam } from '../middleware/validateParams.js';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Report submission accepts the full local report payload (answers, template
|
||||||
|
* binding, status) and stores it server-side. This bridges the offline-first
|
||||||
|
* client workflow with centralized storage for review and archival.
|
||||||
|
*/
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const reports = await listReports({
|
||||||
|
status: req.query.status || undefined,
|
||||||
|
templateCode: req.query.templateCode || undefined,
|
||||||
|
limit: Math.min(Number(req.query.limit) || 100, 500),
|
||||||
|
offset: Math.max(Number(req.query.offset) || 0, 0)
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ items: reports });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:reportId',
|
||||||
|
validateParam('reportId'),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const report = await getReport(req.params.reportId);
|
||||||
|
|
||||||
|
if (!report) {
|
||||||
|
return res.status(404).json({ message: 'Report not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json(report);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const body = req.body;
|
||||||
|
|
||||||
|
if (!body?.id || !body?.reportNumber || !body?.templateCode || !body?.templateVersion || !body?.answers) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: 'id, reportNumber, templateCode, templateVersion, and answers are required.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await submitReport({
|
||||||
|
id: String(body.id).trim(),
|
||||||
|
reportNumber: String(body.reportNumber).trim(),
|
||||||
|
templateCode: String(body.templateCode).trim(),
|
||||||
|
templateVersion: Number(body.templateVersion),
|
||||||
|
status: body.status || 'exported',
|
||||||
|
answers: body.answers
|
||||||
|
});
|
||||||
|
|
||||||
|
await logAuditEvent({
|
||||||
|
entityType: 'report',
|
||||||
|
entityCode: report.id,
|
||||||
|
action: 'submit',
|
||||||
|
newValue: { reportNumber: report.reportNumber, templateCode: report.templateCode }
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.status(201).json(report);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -2,37 +2,100 @@ import { Router } from 'express';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getActiveTemplate,
|
getActiveTemplate,
|
||||||
|
getAllActiveTemplates,
|
||||||
getTemplateVersion,
|
getTemplateVersion,
|
||||||
listTemplates
|
listTemplates,
|
||||||
|
listTemplateVersions,
|
||||||
|
publishTemplateVersion
|
||||||
} from '../services/templateService.js';
|
} from '../services/templateService.js';
|
||||||
|
import { logAuditEvent } from '../services/auditService.js';
|
||||||
import { asyncHandler } from '../utils/asyncHandler.js';
|
import { asyncHandler } from '../utils/asyncHandler.js';
|
||||||
|
import { validateParam, validateNumericParam } from '../middleware/validateParams.js';
|
||||||
|
import { templateCache } from '../services/cacheService.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
asyncHandler(async (_req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
/*
|
||||||
|
* When ?include=definitions is set the response embeds the full JSON
|
||||||
|
* definition for every active template. This eliminates the N+1 round-trip
|
||||||
|
* the old client performed (list → fetch each) and makes initial sync a
|
||||||
|
* single request. Without the flag the response stays lightweight.
|
||||||
|
*/
|
||||||
|
const includeDefinitions = req.query.include === 'definitions';
|
||||||
|
const cacheKey = `templates-list-${includeDefinitions}`;
|
||||||
|
const cached = templateCache.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return res.json(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeDefinitions) {
|
||||||
|
const templates = await getAllActiveTemplates();
|
||||||
|
const payload = { items: templates };
|
||||||
|
templateCache.set(cacheKey, payload);
|
||||||
|
return res.json(payload);
|
||||||
|
}
|
||||||
|
|
||||||
const templates = await listTemplates();
|
const templates = await listTemplates();
|
||||||
res.json({ items: templates });
|
const payload = { items: templates };
|
||||||
|
templateCache.set(cacheKey, payload);
|
||||||
|
return res.json(payload);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/:templateCode',
|
'/:templateCode',
|
||||||
|
validateParam('templateCode'),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
/*
|
||||||
|
* New reports always use the latest active template, so the primary route is
|
||||||
|
* optimized for that case. Older versions remain accessible through the
|
||||||
|
* versioned route so existing drafts can stay bound to the original schema.
|
||||||
|
*/
|
||||||
|
const cacheKey = `template-active-${req.params.templateCode}`;
|
||||||
|
const cached = templateCache.get(cacheKey);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return res.json(cached);
|
||||||
|
}
|
||||||
|
|
||||||
const template = await getActiveTemplate(req.params.templateCode);
|
const template = await getActiveTemplate(req.params.templateCode);
|
||||||
|
|
||||||
if (!template) {
|
if (!template) {
|
||||||
return res.status(404).json({ message: 'Template not found.' });
|
return res.status(404).json({ message: 'Template not found.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
templateCache.set(cacheKey, template);
|
||||||
return res.json(template);
|
return res.json(template);
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
'/:templateCode/versions/:versionNumber',
|
'/:templateCode/versions',
|
||||||
|
validateParam('templateCode'),
|
||||||
asyncHandler(async (req, res) => {
|
asyncHandler(async (req, res) => {
|
||||||
|
/*
|
||||||
|
* Version listing lets the admin workspace display a template's publication
|
||||||
|
* history and choose which version to activate or review.
|
||||||
|
*/
|
||||||
|
const versions = await listTemplateVersions(req.params.templateCode);
|
||||||
|
return res.json({ items: versions });
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/:templateCode/versions/:versionNumber',
|
||||||
|
validateParam('templateCode'),
|
||||||
|
validateNumericParam('versionNumber'),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
/*
|
||||||
|
* Version-specific access is what allows the frontend to reopen old drafts
|
||||||
|
* safely even after templates evolve. Without this route, cached reports
|
||||||
|
* would eventually drift away from the structure they were created against.
|
||||||
|
*/
|
||||||
const template = await getTemplateVersion(
|
const template = await getTemplateVersion(
|
||||||
req.params.templateCode,
|
req.params.templateCode,
|
||||||
req.params.versionNumber
|
req.params.versionNumber
|
||||||
@@ -46,4 +109,36 @@ router.get(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.put(
|
||||||
|
'/:templateCode/versions/:versionNumber/publish',
|
||||||
|
validateParam('templateCode'),
|
||||||
|
validateNumericParam('versionNumber'),
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
/*
|
||||||
|
* Publishing a version marks it active and retires the previously active
|
||||||
|
* version for the same template. This lets the admin promote a draft version
|
||||||
|
* to production. Existing reports keep their bound version unchanged.
|
||||||
|
*/
|
||||||
|
const result = await publishTemplateVersion(
|
||||||
|
req.params.templateCode,
|
||||||
|
Number(req.params.versionNumber)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
return res.status(404).json({ message: 'Template version not found.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
templateCache.clear();
|
||||||
|
|
||||||
|
await logAuditEvent({
|
||||||
|
entityType: 'template_version',
|
||||||
|
entityCode: `${req.params.templateCode}::v${req.params.versionNumber}`,
|
||||||
|
action: 'publish',
|
||||||
|
newValue: { templateCode: req.params.templateCode, version: Number(req.params.versionNumber) }
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json(result);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { env } from './config/env.js';
|
|||||||
import { closePool, query } from './db/pool.js';
|
import { closePool, query } from './db/pool.js';
|
||||||
|
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
|
/*
|
||||||
|
* Fail fast on startup if the database is not reachable. For this project that
|
||||||
|
* is preferable to serving the frontend with a broken API because templates,
|
||||||
|
* lookups, and administrator configuration all depend on MariaDB.
|
||||||
|
*/
|
||||||
await query('SELECT 1 AS ok');
|
await query('SELECT 1 AS ok');
|
||||||
|
|
||||||
const server = app.listen(env.port, () => {
|
const server = app.listen(env.port, () => {
|
||||||
@@ -10,6 +15,11 @@ async function startServer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function shutdown(signal) {
|
async function shutdown(signal) {
|
||||||
|
/*
|
||||||
|
* Graceful shutdown matters even in a PoC because Docker restarts and local
|
||||||
|
* stop/start cycles are common during development. Closing the HTTP server
|
||||||
|
* first and then the connection pool avoids abruptly dropping active work.
|
||||||
|
*/
|
||||||
console.log(`Received ${signal}, shutting down...`);
|
console.log(`Received ${signal}, shutting down...`);
|
||||||
server.close(async () => {
|
server.close(async () => {
|
||||||
await closePool();
|
await closePool();
|
||||||
@@ -22,6 +32,10 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startServer().catch(async (error) => {
|
startServer().catch(async (error) => {
|
||||||
|
/*
|
||||||
|
* If startup fails after the pool has been created, explicitly end the pool so
|
||||||
|
* the process does not linger with open handles and confusing partial state.
|
||||||
|
*/
|
||||||
console.error('Failed to start server');
|
console.error('Failed to start server');
|
||||||
console.error(error);
|
console.error(error);
|
||||||
await closePool();
|
await closePool();
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { query } from '../db/pool.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The audit service records every administrative mutation so the team can trace
|
||||||
|
* when configuration changed and what the previous value was. Each row captures
|
||||||
|
* the entity type (e.g. "image_rules"), the entity identifier, the action name,
|
||||||
|
* and JSON snapshots of the old and new values.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function logAuditEvent({ entityType, entityCode, action, oldValue = null, newValue = null }) {
|
||||||
|
await query(
|
||||||
|
`
|
||||||
|
INSERT INTO audit_log (entity_type, entity_code, action, old_value_json, new_value_json)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
entityType,
|
||||||
|
entityCode,
|
||||||
|
action,
|
||||||
|
oldValue ? JSON.stringify(oldValue) : null,
|
||||||
|
newValue ? JSON.stringify(newValue) : null
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAuditLog({ entityType, entityCode, limit = 50 } = {}) {
|
||||||
|
let sql = 'SELECT id, entity_type, entity_code, action, old_value_json, new_value_json, created_at FROM audit_log';
|
||||||
|
const params = [];
|
||||||
|
const clauses = [];
|
||||||
|
|
||||||
|
if (entityType) {
|
||||||
|
clauses.push('entity_type = ?');
|
||||||
|
params.push(entityType);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entityCode) {
|
||||||
|
clauses.push('entity_code = ?');
|
||||||
|
params.push(entityCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clauses.length) {
|
||||||
|
sql += ` WHERE ${clauses.join(' AND ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY id DESC LIMIT ?';
|
||||||
|
params.push(limit);
|
||||||
|
|
||||||
|
const rows = await query(sql, params);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
entityType: row.entity_type,
|
||||||
|
entityCode: row.entity_code,
|
||||||
|
action: row.action,
|
||||||
|
oldValue: safeParseJson(row.old_value_json),
|
||||||
|
newValue: safeParseJson(row.new_value_json),
|
||||||
|
createdAt: row.created_at
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParseJson(value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/*
|
||||||
|
* Simple in-memory LRU cache for read-heavy data such as templates and lookups.
|
||||||
|
* Each cache entry tracks its last-access timestamp. When the cache exceeds the
|
||||||
|
* configured maximum size the least-recently-used entry is evicted automatically.
|
||||||
|
*
|
||||||
|
* This avoids hitting MariaDB on every request for data that changes rarely while
|
||||||
|
* keeping the implementation dependency-free.
|
||||||
|
*/
|
||||||
|
export function createCache({ maxEntries = 100, ttlMs = 5 * 60 * 1000 } = {}) {
|
||||||
|
const store = new Map();
|
||||||
|
|
||||||
|
function get(key) {
|
||||||
|
const entry = store.get(key);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - entry.createdAt > ttlMs) {
|
||||||
|
store.delete(key);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.lastAccess = Date.now();
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function set(key, value) {
|
||||||
|
if (store.size >= maxEntries) {
|
||||||
|
evictLru();
|
||||||
|
}
|
||||||
|
|
||||||
|
store.set(key, { value, createdAt: Date.now(), lastAccess: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidate(key) {
|
||||||
|
store.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
store.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function evictLru() {
|
||||||
|
let oldestKey = null;
|
||||||
|
let oldestAccess = Infinity;
|
||||||
|
|
||||||
|
for (const [key, entry] of store) {
|
||||||
|
if (entry.lastAccess < oldestAccess) {
|
||||||
|
oldestAccess = entry.lastAccess;
|
||||||
|
oldestKey = key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldestKey !== null) {
|
||||||
|
store.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { get, set, invalidate, clear };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Shared cache instances. Templates and lookups change rarely enough that a
|
||||||
|
* five-minute TTL is practical during normal operations. The admin invalidates
|
||||||
|
* the relevant cache key on write so changes appear immediately.
|
||||||
|
*/
|
||||||
|
export const templateCache = createCache({ maxEntries: 50, ttlMs: 5 * 60 * 1000 });
|
||||||
|
export const lookupCache = createCache({ maxEntries: 50, ttlMs: 5 * 60 * 1000 });
|
||||||
|
export const configCache = createCache({ maxEntries: 20, ttlMs: 2 * 60 * 1000 });
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
import { query } from '../db/pool.js';
|
import { query } from '../db/pool.js';
|
||||||
import { parseJsonColumn } from '../utils/json.js';
|
import { parseJsonColumn } from '../utils/json.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Phase 1 keeps exactly one active image rule set. The frontend asks only for
|
||||||
|
* the active rule because that matches the current business need: operators use
|
||||||
|
* the latest centrally managed policy, while drafts themselves do not yet store
|
||||||
|
* an immutable copy of the rule configuration.
|
||||||
|
*/
|
||||||
export async function getImageRules() {
|
export async function getImageRules() {
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
@@ -31,6 +37,49 @@ export async function getImageRules() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateImageRules(nextImageRules) {
|
||||||
|
const currentRules = await getImageRules();
|
||||||
|
|
||||||
|
if (!currentRules) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The PoC updates the currently active rule in place instead of creating a new
|
||||||
|
* version row. That keeps the administrator flow small and easy to reason
|
||||||
|
* about. If later phases need audit history, this is the point where versioned
|
||||||
|
* writes or soft-retired rows should be introduced.
|
||||||
|
*/
|
||||||
|
await query(
|
||||||
|
`
|
||||||
|
UPDATE image_rules
|
||||||
|
SET
|
||||||
|
name = ?,
|
||||||
|
allowed_mime_types_json = ?,
|
||||||
|
max_file_size_bytes = ?,
|
||||||
|
max_width_px = ?,
|
||||||
|
max_height_px = ?,
|
||||||
|
jpeg_quality = ?,
|
||||||
|
oversize_behavior = ?,
|
||||||
|
max_attachments_per_field = ?
|
||||||
|
WHERE code = ?
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
nextImageRules.name,
|
||||||
|
JSON.stringify(nextImageRules.allowedMimeTypes),
|
||||||
|
nextImageRules.maxFileSizeBytes,
|
||||||
|
nextImageRules.maxWidthPx,
|
||||||
|
nextImageRules.maxHeightPx,
|
||||||
|
nextImageRules.jpegQuality,
|
||||||
|
nextImageRules.oversizeBehavior,
|
||||||
|
nextImageRules.maxAttachmentsPerField,
|
||||||
|
currentRules.code
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return getImageRules();
|
||||||
|
}
|
||||||
|
|
||||||
export async function getExportProfile() {
|
export async function getExportProfile() {
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
@@ -52,6 +101,11 @@ export async function getExportProfile() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getAppConfig() {
|
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(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { query } from '../db/pool.js';
|
import { query } from '../db/pool.js';
|
||||||
|
|
||||||
function groupLookups(rows) {
|
function groupLookups(rows) {
|
||||||
|
/*
|
||||||
|
* SQL returns one row per lookup value, but the frontend wants a grouped shape
|
||||||
|
* where each lookup code owns an array of options. Building that structure here
|
||||||
|
* keeps the API contract friendly for dynamic form rendering.
|
||||||
|
*/
|
||||||
const lookups = new Map();
|
const lookups = new Map();
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
@@ -26,6 +31,10 @@ function groupLookups(rows) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listLookups() {
|
export async function listLookups() {
|
||||||
|
/*
|
||||||
|
* Active lookup values are sorted in SQL so the client receives them in display
|
||||||
|
* order without additional sorting logic in the browser.
|
||||||
|
*/
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -48,6 +57,11 @@ export async function listLookups() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getLookup(lookupCode) {
|
export async function getLookup(lookupCode) {
|
||||||
|
/*
|
||||||
|
* The single-lookup query reuses the same grouping logic as the bulk endpoint,
|
||||||
|
* which keeps the returned shape consistent regardless of how the frontend or a
|
||||||
|
* debugging tool chooses to retrieve lookup data.
|
||||||
|
*/
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { query } from '../db/pool.js';
|
||||||
|
import { parseJsonColumn } from '../utils/json.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The report service handles server-side storage of submitted reports. In
|
||||||
|
* phase 1, reports are created locally in the browser and only uploaded when
|
||||||
|
* the operator explicitly submits. This keeps the offline-first workflow intact
|
||||||
|
* while giving the backend a durable copy for review, export, or archival.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function submitReport(report) {
|
||||||
|
await query(
|
||||||
|
`
|
||||||
|
INSERT INTO reports (report_uuid, report_number, template_code, template_version, status, answers_json, submitted_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
status = VALUES(status),
|
||||||
|
answers_json = VALUES(answers_json),
|
||||||
|
submitted_at = VALUES(submitted_at),
|
||||||
|
updated_at = NOW()
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
report.id,
|
||||||
|
report.reportNumber,
|
||||||
|
report.templateCode,
|
||||||
|
report.templateVersion,
|
||||||
|
report.status,
|
||||||
|
JSON.stringify(report.answers)
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return getReport(report.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReport(reportUuid) {
|
||||||
|
const rows = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
report_uuid AS reportUuid,
|
||||||
|
report_number AS reportNumber,
|
||||||
|
template_code AS templateCode,
|
||||||
|
template_version AS templateVersion,
|
||||||
|
status,
|
||||||
|
answers_json AS answersJson,
|
||||||
|
submitted_at AS submittedAt,
|
||||||
|
created_at AS createdAt,
|
||||||
|
updated_at AS updatedAt
|
||||||
|
FROM reports
|
||||||
|
WHERE report_uuid = ?
|
||||||
|
LIMIT 1
|
||||||
|
`,
|
||||||
|
[reportUuid]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.length ? mapReportRow(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listReports({ status, templateCode, limit = 100, offset = 0 } = {}) {
|
||||||
|
let sql = `
|
||||||
|
SELECT
|
||||||
|
report_uuid AS reportUuid,
|
||||||
|
report_number AS reportNumber,
|
||||||
|
template_code AS templateCode,
|
||||||
|
template_version AS templateVersion,
|
||||||
|
status,
|
||||||
|
answers_json AS answersJson,
|
||||||
|
submitted_at AS submittedAt,
|
||||||
|
created_at AS createdAt,
|
||||||
|
updated_at AS updatedAt
|
||||||
|
FROM reports
|
||||||
|
`;
|
||||||
|
const params = [];
|
||||||
|
const clauses = [];
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
clauses.push('status = ?');
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (templateCode) {
|
||||||
|
clauses.push('template_code = ?');
|
||||||
|
params.push(templateCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clauses.length) {
|
||||||
|
sql += ` WHERE ${clauses.join(' AND ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += ' ORDER BY updated_at DESC LIMIT ? OFFSET ?';
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const rows = await query(sql, params);
|
||||||
|
return rows.map(mapReportRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapReportRow(row) {
|
||||||
|
return {
|
||||||
|
id: row.reportUuid,
|
||||||
|
reportNumber: row.reportNumber,
|
||||||
|
templateCode: row.templateCode,
|
||||||
|
templateVersion: row.templateVersion,
|
||||||
|
status: row.status,
|
||||||
|
answers: parseJsonColumn(row.answersJson, {}),
|
||||||
|
submittedAt: row.submittedAt,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,6 +2,11 @@ import { query } from '../db/pool.js';
|
|||||||
import { parseJsonColumn } from '../utils/json.js';
|
import { parseJsonColumn } from '../utils/json.js';
|
||||||
|
|
||||||
function mapTemplateRow(row) {
|
function mapTemplateRow(row) {
|
||||||
|
/*
|
||||||
|
* Template definitions are stored as JSON in MariaDB, but the frontend expects
|
||||||
|
* them as native objects. The mapper centralizes that translation and keeps the
|
||||||
|
* route handlers free from storage-specific details.
|
||||||
|
*/
|
||||||
return {
|
return {
|
||||||
code: row.code,
|
code: row.code,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
@@ -14,6 +19,11 @@ function mapTemplateRow(row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listTemplates() {
|
export async function listTemplates() {
|
||||||
|
/*
|
||||||
|
* Only active versions are listed for new report creation. Retired or draft
|
||||||
|
* versions may still exist in the database, but they should not appear in the
|
||||||
|
* main template picker used by operators.
|
||||||
|
*/
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -40,7 +50,40 @@ export async function listTemplates() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getAllActiveTemplates() {
|
||||||
|
/*
|
||||||
|
* Batch endpoint: returns every active template with its full JSON definition
|
||||||
|
* in a single query. This replaces the N+1 pattern where the client listed
|
||||||
|
* templates then fetched each definition individually — cutting initial sync
|
||||||
|
* from N+1 round-trips to one.
|
||||||
|
*/
|
||||||
|
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'
|
||||||
|
ORDER BY t.name ASC
|
||||||
|
`
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map(mapTemplateRow);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getActiveTemplate(templateCode) {
|
export async function getActiveTemplate(templateCode) {
|
||||||
|
/*
|
||||||
|
* This query returns the single currently active version for a given template
|
||||||
|
* code. That matches the business rule that new drafts should always start from
|
||||||
|
* the newest active template definition.
|
||||||
|
*/
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -65,6 +108,11 @@ export async function getActiveTemplate(templateCode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getTemplateVersion(templateCode, versionNumber) {
|
export async function getTemplateVersion(templateCode, versionNumber) {
|
||||||
|
/*
|
||||||
|
* Version-specific reads are intentionally separate from active-template reads
|
||||||
|
* so draft reopening can be explicit and reliable, even if the active version
|
||||||
|
* changes later.
|
||||||
|
*/
|
||||||
const rows = await query(
|
const rows = await query(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -87,3 +135,73 @@ export async function getTemplateVersion(templateCode, versionNumber) {
|
|||||||
|
|
||||||
return rows.length ? mapTemplateRow(rows[0]) : null;
|
return rows.length ? mapTemplateRow(rows[0]) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listTemplateVersions(templateCode) {
|
||||||
|
/*
|
||||||
|
* Returns all versions of a template so the admin workspace can show the full
|
||||||
|
* version history and allow publishing a different version.
|
||||||
|
*/
|
||||||
|
const rows = await query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
t.code,
|
||||||
|
t.name,
|
||||||
|
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
|
||||||
|
WHERE t.code = ?
|
||||||
|
ORDER BY tv.version_number DESC
|
||||||
|
`,
|
||||||
|
[templateCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
version: row.versionNumber,
|
||||||
|
status: row.status,
|
||||||
|
publishedAt: row.publishedAt
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function publishTemplateVersion(templateCode, versionNumber) {
|
||||||
|
/*
|
||||||
|
* Publishing retires the currently active version and activates the requested
|
||||||
|
* one. Both updates run sequentially. In production this would be wrapped in a
|
||||||
|
* database transaction; the PoC trades strict atomicity for simplicity.
|
||||||
|
*/
|
||||||
|
const templateRows = await query(
|
||||||
|
'SELECT id FROM templates WHERE code = ?',
|
||||||
|
[templateCode]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!templateRows.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const templateId = templateRows[0].id;
|
||||||
|
|
||||||
|
const versionRows = await query(
|
||||||
|
'SELECT id FROM template_versions WHERE template_id = ? AND version_number = ?',
|
||||||
|
[templateId, versionNumber]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!versionRows.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await query(
|
||||||
|
"UPDATE template_versions SET status = 'retired' WHERE template_id = ? AND status = 'active'",
|
||||||
|
[templateId]
|
||||||
|
);
|
||||||
|
|
||||||
|
await query(
|
||||||
|
"UPDATE template_versions SET status = 'active', published_at = NOW() WHERE id = ?",
|
||||||
|
[versionRows[0].id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return getActiveTemplate(templateCode);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user