start to do simple frontend using alpinejs

This commit is contained in:
mahdahar 2025-12-19 16:48:48 +07:00
parent 3c1aaafe18
commit eb305d8567
14 changed files with 2049 additions and 1 deletions

View File

@ -1 +1,56 @@
"# clqms-be"
# CLQMS (Clinical Laboratory Quality Management System)
> **The core backend engine for modern clinical laboratory workflows.**
CLQMS is a robust, mission-critical API suite designed to streamline laboratory operations, ensure data integrity, and manage complex diagnostic workflows. Built on a foundation of precision and regulatory compliance, this system handles everything from patient registration to high-throughput test resulting.
---
## 🏛️ Core Architecture & Design
The system is currently undergoing a strategic **Architectural Redesign** to consolidate legacy structures into a high-performance, maintainable schema. This design, spearheaded by leadership, focuses on reducing technical debt and improving data consistency across:
- **Unified Test Definitions:** Consolidating technical, calculated, and site-specific test data.
- **Reference Range Centralization:** A unified engine for numeric, threshold, text, and coded results.
- **Ordered Workflow Management:** Precise tracking of orders from collection to verification.
---
## 🛡️ Strategic Pillars
- **Precision & Accuracy:** Strict validation for all laboratory parameters and reference ranges.
- **Scalability:** Optimized for high-volume diagnostic environments.
- **Compliance:** Built-in audit trails and status history for full traceability.
- **Interoperability:** Modular architecture designed for LIS, HIS, and analyzer integrations.
---
## 🛠️ Technical Stack
| Component | Specification |
| :------------- | :------------ |
| **Language** | PHP 8.1+ (PSR-compliant) |
| **Framework** | CodeIgniter 4 |
| **Security** | JWT (JSON Web Tokens) Authorization |
| **Database** | MySQL (Optimized Schema Migration in progress) |
---
## 📂 Documentation & Specifications
For detailed architectural blueprints and API specifications, please refer to the internal documentation:
👉 **[Internal Documentation Index](./docs/README.md)**
Key documents:
- [Database Schema Redesign Proposal](./docs/20251216002-Test_OrderTest_RefRange_schema_redesign_proposal.md)
- [API Contract: Patient Registration](./docs/api_contract_patient_registration.md)
- [Database Design Review (Reference)](./docs/20251212001-database_design_review_sonnet.md)
---
### 📜 Usage Notice
This repository contains proprietary information intended for the 5Panda Team and authorized collaborators.
---
*© 2025 5Panda Team. Engineering Precision in Clinical Diagnostics.*

View File

@ -8,6 +8,11 @@ use CodeIgniter\Router\RouteCollection;
$routes->options('(:any)', function() { return ''; });
$routes->get('/', 'Home::index');
// Frontend Pages
$routes->get('/login', 'Pages\AuthPage::login');
$routes->get('/logout', 'Pages\AuthPage::logout');
$routes->get('/dashboard', 'Pages\DashboardPage::index');
// Faker
$routes->get('faker/faker-patient/(:num)', 'faker\FakerPatient::sendMany/$1');

View File

@ -0,0 +1,43 @@
<?php
namespace App\Controllers\Pages;
use CodeIgniter\Controller;
/**
* Auth Pages Controller
* Handles rendering of authentication-related pages
*/
class AuthPage extends Controller
{
/**
* Display the login page
*/
public function login()
{
// Check if user is already authenticated
$token = $this->request->getCookie('token');
if ($token) {
// If token exists, redirect to dashboard
return redirect()->to('/dashboard');
}
return view('pages/login', [
'title' => 'Login',
'description' => 'Sign in to your CLQMS account'
]);
}
/**
* Handle logout - clear cookie and redirect
*/
public function logout()
{
// Delete the token cookie
$response = service('response');
$response->deleteCookie('token');
return redirect()->to('/login');
}
}

View File

@ -0,0 +1,49 @@
<?php
namespace App\Controllers\Pages;
use CodeIgniter\Controller;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
/**
* Dashboard Page Controller
* Handles rendering of the main dashboard
*/
class DashboardPage extends Controller
{
/**
* Display the dashboard page
*/
public function index()
{
// Check authentication
$token = $this->request->getCookie('token');
if (!$token) {
return redirect()->to('/login');
}
try {
$key = getenv('JWT_SECRET');
$decoded = JWT::decode($token, new Key($key, 'HS256'));
return view('pages/dashboard', [
'title' => 'Dashboard',
'description' => 'CLQMS Dashboard - Overview',
'user' => $decoded
]);
} catch (ExpiredException $e) {
// Token expired, redirect to login
$response = service('response');
$response->deleteCookie('token');
return redirect()->to('/login');
} catch (\Exception $e) {
// Invalid token
$response = service('response');
$response->deleteCookie('token');
return redirect()->to('/login');
}
}
}

View File

@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<!-- SEO Meta -->
<title><?= $title ?? 'CLQMS' ?> - Clinical Laboratory QMS</title>
<meta name="description" content="<?= $description ?? 'CLQMS - Modern Clinical Laboratory Quality Management System' ?>">
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="/favicon.ico">
<!-- Google Fonts - Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
<!-- App Styles -->
<link rel="stylesheet" href="/assets/css/app.css">
<!-- Page-specific styles -->
<?= $this->renderSection('styles') ?>
</head>
<body class="bg-pattern" x-data>
<!-- Floating Decorative Shapes -->
<div class="floating-shapes">
<div class="shape shape-1"></div>
<div class="shape shape-2"></div>
<div class="shape shape-3"></div>
</div>
<!-- Main Content -->
<?= $this->renderSection('content') ?>
<!-- Toast Notifications Container -->
<div
x-data
class="toast-container"
style="position: fixed; top: 1rem; right: 1rem; z-index: 9999; display: flex; flex-direction: column; gap: 0.5rem;"
>
<template x-for="toast in $store.toast.messages" :key="toast.id">
<div
x-show="true"
x-transition:enter="transition ease-out duration-300"
x-transition:enter-start="opacity-0 transform translate-x-8"
x-transition:enter-end="opacity-100 transform translate-x-0"
x-transition:leave="transition ease-in duration-200"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
:class="{
'alert': true,
'alert-success': toast.type === 'success',
'alert-error': toast.type === 'error',
'alert-info': toast.type === 'info'
}"
style="min-width: 280px; cursor: pointer;"
@click="$store.toast.dismiss(toast.id)"
>
<span x-text="toast.message"></span>
</div>
</template>
</div>
<!-- Alpine.js 3.x -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- App Scripts (loaded before Alpine) -->
<script src="/assets/js/app.js"></script>
<!-- Initialize Lucide Icons -->
<script>
document.addEventListener('DOMContentLoaded', () => {
lucide.createIcons();
});
</script>
<!-- Page-specific scripts -->
<?= $this->renderSection('scripts') ?>
</body>
</html>

View File

@ -0,0 +1,40 @@
<?= $this->extend('layouts/main') ?>
<?= $this->section('content') ?>
<div style="min-height: 100vh; padding: 2rem;">
<div class="card card-glass fade-in" style="max-width: 600px; margin: 2rem auto; text-align: center;">
<div class="login-logo" style="margin-bottom: 1.5rem;">
<i data-lucide="layout-dashboard"></i>
</div>
<h1 style="margin-bottom: 0.5rem;">🎉 Welcome to Dashboard!</h1>
<p class="text-muted" style="margin-bottom: 2rem;">
You're successfully logged in. This is a placeholder page.
</p>
<?php if (isset($user)): ?>
<div class="alert alert-success" style="text-align: left;">
<i data-lucide="check-circle" style="width: 18px; height: 18px;"></i>
<span>Logged in as: <strong><?= esc($user->username ?? 'User') ?></strong></span>
</div>
<?php endif; ?>
<div style="display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;">
<a href="/login" class="btn btn-secondary">
<i data-lucide="arrow-left" style="width: 18px; height: 18px;"></i>
Back to Login
</a>
<form action="/logout" method="get" style="margin: 0;">
<button type="submit" class="btn btn-primary">
<i data-lucide="log-out" style="width: 18px; height: 18px;"></i>
Logout
</button>
</form>
</div>
</div>
</div>
<?= $this->endSection() ?>

130
app/Views/pages/login.php Normal file
View File

@ -0,0 +1,130 @@
<?= $this->extend('layouts/main') ?>
<?= $this->section('content') ?>
<div class="login-container">
<div class="login-card card card-glass fade-in" x-data="loginForm" x-ref="loginCard">
<!-- Header -->
<div class="login-header">
<div class="login-logo">
<i data-lucide="flask-conical"></i>
</div>
<h1 class="login-title">Welcome Back!</h1>
<p class="login-subtitle">Sign in to your CLQMS account</p>
</div>
<!-- Error Alert -->
<template x-if="error">
<div class="alert alert-error" x-transition>
<i data-lucide="alert-circle" style="width: 18px; height: 18px;"></i>
<span x-text="error"></span>
</div>
</template>
<!-- Login Form -->
<form @submit.prevent="submitLogin">
<!-- Username Field -->
<div class="form-group">
<label class="form-label" for="username">Username</label>
<div class="form-input-icon">
<i data-lucide="user" class="icon" style="width: 18px; height: 18px;"></i>
<input
type="text"
id="username"
class="form-input"
placeholder="Enter your username"
x-model="username"
:disabled="isLoading"
autocomplete="username"
autofocus
>
</div>
</div>
<!-- Password Field -->
<div class="form-group">
<label class="form-label" for="password">Password</label>
<div class="form-input-icon">
<i data-lucide="lock" class="icon" style="width: 18px; height: 18px;"></i>
<input
:type="showPassword ? 'text' : 'password'"
id="password"
class="form-input"
placeholder="Enter your password"
x-model="password"
:disabled="isLoading"
autocomplete="current-password"
style="padding-right: 3rem;"
>
<button
type="button"
class="password-toggle"
@click="togglePassword"
:title="showPassword ? 'Hide password' : 'Show password'"
>
<i :data-lucide="showPassword ? 'eye-off' : 'eye'" style="width: 18px; height: 18px;"></i>
</button>
</div>
</div>
<!-- Remember Me & Forgot Password -->
<div class="flex items-center justify-between mb-4">
<label class="checkbox-wrapper">
<input
type="checkbox"
class="checkbox-input"
x-model="rememberMe"
:disabled="isLoading"
>
<span class="checkbox-label">Remember me</span>
</label>
<a href="#" class="text-sm text-primary">Forgot password?</a>
</div>
<!-- Submit Button -->
<button
type="submit"
class="btn btn-primary btn-lg btn-block"
:disabled="isLoading"
>
<template x-if="isLoading">
<div class="spinner"></div>
</template>
<template x-if="!isLoading">
<i data-lucide="log-in" style="width: 20px; height: 20px;"></i>
</template>
<span x-text="isLoading ? 'Signing in...' : 'Sign In'"></span>
</button>
</form>
<!-- Footer -->
<div class="login-footer">
<p class="text-muted">
&copy; <?= date('Y') ?> CLQMS • Clinical Laboratory QMS
</p>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('scripts') ?>
<script>
// Re-initialize Lucide icons after Alpine updates the DOM
document.addEventListener('alpine:initialized', () => {
// Watch for DOM changes and re-create icons
const observer = new MutationObserver(() => {
lucide.createIcons();
});
observer.observe(document.body, {
childList: true,
subtree: true
});
});
</script>
<?= $this->endSection() ?>

55
docs/README.md Normal file
View File

@ -0,0 +1,55 @@
# CLQMS Technical Documentation Index
This repository serves as the central knowledge base for the CLQMS Backend. It contains architectural proposals, API contracts, and design reviews that guide the development of the clinical laboratory management system.
---
## 🏗️ Architectural Redesign (Manager's Proposal)
The system is currently transitioning to a consolidated database schema to enhance performance and maintainability. This is a critical initiative aimed at reducing schema complexity.
- **[Detailed Redesign Proposal](./20251216002-Test_OrderTest_RefRange_schema_redesign_proposal.md)**
- *Focus:* Consolidating 13 legacy tables into 7 optimized tables.
- *Modules Impacted:* Test Definition, Reference Ranges, and Order Management.
---
## 🛠️ Functional Modules
### 1. Test Management
Handles the definition of laboratory tests, including technical specifications, calculation formulas, and external system mappings.
- See: `tests` table and `test_panels` in the redesign proposal.
### 2. Patient & Order Workflow
Manages the lifecycle of a laboratory order:
- **[Patient Registration API Contract](./api_contract_patient_registration.md)**: Specifications for patient intake and data validation.
- **Order Tracking**: From collection and receipt to technical verification.
### 3. Reference Range Engine
A unified logic for determining normal and critical flags across various test types.
- *Types supported:* Numeric, Threshold (Cut-off), Textual, and Coded (Value Sets).
---
## 📊 Design Reviews & Legacy Reference
Documentation regarding the initial assessments and legacy structures:
- **[Database Design Review - Sonnet](./20251212001-database_design_review_sonnet.md)**: Comprehensive analysis of legacy table relationships and bottlenecks.
- **[Database Design Review - Opus](./20251212002-database_design_review_opus.md)**: Additional perspectives on the initial system architecture.
---
## 🛡️ Development Standards
All contributions to the CLQMS backend must adhere to the following:
1. **Data Integrity:** All database migrations must include data validation scripts.
2. **Auditability:** Status changes in the `orders` module must be logged in `order_history`.
3. **Security:** Every endpoint requires JWT authentication.
---
### 📜 Administration
Documentation maintained by the **5Panda Team**.
---
*Built with ❤️ by the 5Panda Team.*

157
docs/clqms-wst-concept.md Normal file
View File

@ -0,0 +1,157 @@
---
title: "Project Pandaria: Next-Gen LIS Architecture"
description: "An offline-first, event-driven architecture concept for the CLQMS."
date: 2025-12-19
order: 6
tags:
- posts
- clqms
layout: clqms-post.njk
---
## 1. 💀 Pain vs. 🛡️ Solution
### 🚩 Problem 1: "The Server is Dead!"
> **The Pain:** When the internet cuts or the server crashes, the entire lab stops. Patients wait, doctors get angry.
**🛡️ The Solution: "Offline-First Mode"**
The workstation keeps working 100% offline. It has a local brain (database). Patients never know the internet is down.
---
### 🚩 Problem 2: "Data Vanished?"
> **The Pain:** We pushed data, the network blinked, and the sample disappeared. We have to re-scan manually.
**🛡️ The Solution: "The Outbox Guarantee"**
Data is treated like Registered Mail. It stays in a safe SQL "Outbox" until the workstation signs a receipt (ACK) confirming it is saved.
---
### 🚩 Problem 3: "Spaghetti Code"
> **The Pain:** Adding a new machine (like Mindray) means hacking the core LIS code with endless `if-else` statements.
**🛡️ The Solution: "Universal Adapters"**
Every machine gets a simple plugin (Driver). The Core System stays clean, modular, and untouched.
---
### 🚩 Problem 4: "Inconsistent Results"
> **The Pain:** One machine says `WBC`, another says `Leukocytes`. The Database is a mess of different codes.
**🛡️ The Solution: "The Translator"**
A built-in dictionary auto-translates everything to Standard English (e.g., `WBC`) before it ever touches the database.
---
## 2. 🏗️ System Architecture: The "Edge" Concept
We are moving from a **Dependent** model (dumb terminal) to an **Empowered** model (Edge Computing).
### The "Core" (Central Server)
* **Role:** The "Hippocampus" (Long-term Memory).
* **Stack:** CodeIgniter 4 + MySQL.
* **Responsibilities:**
* Billing & Financials (Single Source of Truth).
* Permanent Patient History.
* API Gateway for external apps (Mobile, Website).
* Administrator Dashboard.
### The "Edge" (Smart Workstation)
* **Role:** The "Cortex" (Immediate Processing).
* **Stack:** Node.js (Electron) + SQLite.
* **Responsibilities:**
* **Hardware I/O:** Speaking directly to RS232/TCP ports.
* **Hot Caching:** Keeping the last 7 days of active orders locally.
* **Logic Engine:** Validating results against reference ranges *before* syncing.
> **Key Difference:** The Workstation no longer asks "Can I work?" It assumes it can work. It treats the server as a "Sync Partner," not a "Master." If the internet dies, the Edge keeps processing samples, printing labels, and validating results without a hiccup.
---
## 3. 🔌 The "Universal Adapter" (Hardware Layer)
We use the **Adapter Design Pattern** to isolate hardware chaos from our clean business logic.
### The Problem: "The Tower of Babel"
Every manufacturer speaks a proprietary dialect.
* **Sysmex:** Uses ASTM protocols with checksums.
* **Roche:** Uses custom HL7 variants.
* **Mindray:** Often uses raw hex streams.
### The Fix: "Drivers as Plugins"
The Workstation loads a specific `.js` file (The Driver) for each connected machine. This driver has one job: **Normalization.**
#### Example: ASTM to JSON
**Raw Input (Alien Language):**
`P|1||12345||Smith^John||19800101|M|||||`
`R|1|^^^WBC|10.5|10^3/uL|4.0-11.0|N||F||`
**Normalized Output (clean JSON):**
```json
{
"test_code": "WBC",
"value": 10.5,
"unit": "10^3/uL",
"flag": "Normal",
"timestamp": "2025-12-19T10:00:00Z"
}
```
### Benefit: "Hot-Swappable Labs"
Buying a new machine? You don't need to obscurely patch the `LISSender.exe`. You just drop in `driver-sysmex-xn1000.js` into the `plugins/` folder, and the Edge Workstation instantly learns how to speak Sysmex.
---
## 4. 🗣️ The "Translator" (Data Mapping)
Machines are stubborn. They send whatever test codes they want (`WBC`, `Leukocytes`, `W.B.C`, `White_Cells`). If we save these directly, our database becomes a swamp.
### The Solution: "Local Dictionary & Rules Engine"
Before data is saved to SQLite, it passes through the **Translator**.
1. **Alias Matching:**
* The dictionary knows that `W.B.C` coming from *Machine A* actually means `WBC_TOTAL`.
* It renames the key instantly.
2. **Unit Conversion (Math Layer):**
* *Machine A* sends Hemoglobin in `g/dL` (e.g., 14.5).
* *Our Standard* is `g/L` (e.g., 145).
* **The Rule:** `Apply: Value * 10`.
* The translator automatically mathematical normalized the result.
This ensures that our Analytics Dashboard sees **clean, comparable data** regardless of whether it came from a 10-year-old machine or a brand new one.
---
## 5. 📨 The "Registered Mail" Sync (Redis + Outbox)
We are banning the word "Polling" (checking every 5 seconds). It's inefficient and scary. We are switching to **Events** using **Redis**.
### 🤔 What is Redis?
Think of **MySQL** as a filing cabinet (safe, permanent, but slow to open).
Think of **Redis** as a **loudspeaker system** (instant, in-memory, very fast).
We use Redis specifically for its **Pub/Sub (Publish/Subscribe)** feature. It lets us "broadcast" a message to all connected workstations instantly without writing to a disk.
### 🔄 How the Flow Works:
1. **👨‍⚕️ Order Created:** The Doctor saves an order on the Server.
2. **📮 The Outbox:** The server puts a copy of the order in a special SQL table called `outbox_queue`.
3. **🔔 The Bell (Redis):** The server "shouts" into the Redis loudspeaker: *"New mail for Lab 1!"*.
4. **📥 Delivery:** The Workstation (listening to Redis) hears the shout instantly. It then goes to the SQL Outbox to download the actual heavy data.
5. **✍️ The Signature (ACK):** The Workstation sends a digital signature back: *"I have received and saved Order #123."*
6. **✅ Done:** Only *then* does the server delete the message from the Outbox.
**Safety Net & Self-Healing:**
* **Redis is just the doorbell:** If the workstation is offline and misses the shout, it doesn't matter.
* **SQL is the mailbox:** The message sits safely in the `outbox_queue` table indefinitely.
* **Recovery:** When the Workstation turns back on, it automatically asks: *"Did I miss anything?"* and downloads all pending items from the SQL Outbox. **Zero data loss, even if the notification is lost.**
---
## 6. 🏆 Summary: Why We Win
* **Reliability:** 🛡️ 100% Uptime for the Lab.
* **Speed:** ⚡ Instant response times (Local Database is faster than Cloud).
* **Sanity:** 🧘 No more panic attacks when the internet provider fails.
* **Future Proof:** 🚀 Ready for any new machine connection in the future.

432
docs/clqms-wst-database.md Normal file
View File

@ -0,0 +1,432 @@
---
title: "Edge Workstation: SQLite Database Schema"
description: "Database design for the offline-first smart workstation."
date: 2025-12-19
order: 7
tags:
- posts
- clqms
- database
layout: clqms-post.njk
---
## Overview
This document describes the **SQLite database schema** for the Edge Workstation — the local "brain" that enables **100% offline operation** for lab technicians.
> **Stack:** Node.js (Electron) + SQLite
> **Role:** The "Cortex" — Immediate Processing
---
## 📊 Entity Relationship Diagram
```
┌─────────────┐ ┌──────────────┐
│ orders │────────<│ order_tests │
└─────────────┘ └──────────────┘
┌─────────────┐ ┌──────────────┐
│ machines │────────<│ results │
└─────────────┘ └──────────────┘
┌─────────────────┐
│ test_dictionary │ (The Translator)
└─────────────────┘
┌───────────────┐ ┌───────────────┐
│ outbox_queue │ │ inbox_queue │
└───────────────┘ └───────────────┘
(Push to Server) (Pull from Server)
┌───────────────┐ ┌───────────────┐
│ sync_log │ │ config │
└───────────────┘ └───────────────┘
```
---
## 🗂️ Table Definitions
### 1. `orders` — Cached Patient Orders
Orders downloaded from the Core Server. Keeps the **last 7 days** for offline processing.
| Column | Type | Description |
|--------|------|-------------|
| `id` | INTEGER | Primary key (local) |
| `server_order_id` | TEXT | Original ID from Core Server |
| `patient_id` | TEXT | Patient identifier |
| `patient_name` | TEXT | Patient full name |
| `patient_dob` | DATE | Date of birth |
| `patient_gender` | TEXT | M, F, or O |
| `order_date` | DATETIME | When order was created |
| `priority` | TEXT | `stat`, `routine`, `urgent` |
| `status` | TEXT | `pending`, `in_progress`, `completed`, `cancelled` |
| `barcode` | TEXT | Sample barcode |
| `synced_at` | DATETIME | Last sync timestamp |
```sql
CREATE TABLE orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_order_id TEXT UNIQUE NOT NULL,
patient_id TEXT NOT NULL,
patient_name TEXT NOT NULL,
patient_dob DATE,
patient_gender TEXT CHECK(patient_gender IN ('M', 'F', 'O')),
order_date DATETIME NOT NULL,
priority TEXT DEFAULT 'routine' CHECK(priority IN ('stat', 'routine', 'urgent')),
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'in_progress', 'completed', 'cancelled')),
barcode TEXT,
notes TEXT,
synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
---
### 2. `order_tests` — Requested Tests per Order
Each order can have multiple tests (CBC, Urinalysis, etc.)
| Column | Type | Description |
|--------|------|-------------|
| `id` | INTEGER | Primary key |
| `order_id` | INTEGER | FK to orders |
| `test_code` | TEXT | Standardized code (e.g., `WBC_TOTAL`) |
| `test_name` | TEXT | Display name |
| `status` | TEXT | `pending`, `processing`, `completed`, `failed` |
```sql
CREATE TABLE order_tests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
test_code TEXT NOT NULL,
test_name TEXT NOT NULL,
status TEXT DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
);
```
---
### 3. `results` — Machine Output (Normalized)
Results from lab machines, **already translated** to standard format by The Translator.
| Column | Type | Description |
|--------|------|-------------|
| `id` | INTEGER | Primary key |
| `order_test_id` | INTEGER | FK to order_tests |
| `machine_id` | INTEGER | FK to machines |
| `test_code` | TEXT | Standardized test code |
| `value` | REAL | Numeric result |
| `unit` | TEXT | Standardized unit |
| `flag` | TEXT | `L`, `N`, `H`, `LL`, `HH`, `A` |
| `raw_value` | TEXT | Original value from machine |
| `raw_unit` | TEXT | Original unit from machine |
| `raw_test_code` | TEXT | Original code before translation |
| `validated` | BOOLEAN | Has been reviewed by tech? |
```sql
CREATE TABLE results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_test_id INTEGER,
machine_id INTEGER,
test_code TEXT NOT NULL,
value REAL NOT NULL,
unit TEXT NOT NULL,
reference_low REAL,
reference_high REAL,
flag TEXT CHECK(flag IN ('L', 'N', 'H', 'LL', 'HH', 'A')),
raw_value TEXT,
raw_unit TEXT,
raw_test_code TEXT,
validated BOOLEAN DEFAULT 0,
validated_by TEXT,
validated_at DATETIME,
machine_timestamp DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_test_id) REFERENCES order_tests(id),
FOREIGN KEY (machine_id) REFERENCES machines(id)
);
```
---
### 4. `outbox_queue` — The Registered Mail 📮
Data waits here until the Core Server sends an **ACK (acknowledgment)**. This is the heart of our **zero data loss** guarantee.
| Column | Type | Description |
|--------|------|-------------|
| `id` | INTEGER | Primary key |
| `event_type` | TEXT | `result_created`, `result_validated`, etc. |
| `payload` | TEXT | JSON data to sync |
| `target_entity` | TEXT | `results`, `orders`, etc. |
| `priority` | INTEGER | 1 = highest, 10 = lowest |
| `retry_count` | INTEGER | Number of failed attempts |
| `status` | TEXT | `pending`, `processing`, `sent`, `acked`, `failed` |
| `acked_at` | DATETIME | When server confirmed receipt |
```sql
CREATE TABLE outbox_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
payload TEXT NOT NULL,
target_entity TEXT,
target_id INTEGER,
priority INTEGER DEFAULT 5,
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 5,
last_error TEXT,
status TEXT DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
sent_at DATETIME,
acked_at DATETIME
);
```
> **Flow:** Data enters as `pending` → moves to `sent` when transmitted → becomes `acked` when server confirms → deleted after cleanup.
---
### 5. `inbox_queue` — Messages from Server 📥
Incoming orders/updates from Core Server waiting to be processed locally.
```sql
CREATE TABLE inbox_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_message_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
payload TEXT NOT NULL,
status TEXT DEFAULT 'pending',
error_message TEXT,
received_at DATETIME DEFAULT CURRENT_TIMESTAMP,
processed_at DATETIME
);
```
---
### 6. `machines` — Connected Lab Equipment 🔌
Registry of all connected analyzers.
| Column | Type | Description |
|--------|------|-------------|
| `id` | INTEGER | Primary key |
| `name` | TEXT | "Sysmex XN-1000" |
| `driver_file` | TEXT | "driver-sysmex-xn1000.js" |
| `connection_type` | TEXT | `RS232`, `TCP`, `USB`, `FILE` |
| `connection_config` | TEXT | JSON config |
```sql
CREATE TABLE machines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
manufacturer TEXT,
model TEXT,
serial_number TEXT,
driver_file TEXT NOT NULL,
connection_type TEXT CHECK(connection_type IN ('RS232', 'TCP', 'USB', 'FILE')),
connection_config TEXT,
is_active BOOLEAN DEFAULT 1,
last_communication DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
**Example config:**
```json
{
"port": "COM3",
"baudRate": 9600,
"dataBits": 8,
"parity": "none"
}
```
---
### 7. `test_dictionary` — The Translator 📖
This table solves the **"WBC vs Leukocytes"** problem. It maps machine-specific codes to our standard codes.
| Column | Type | Description |
|--------|------|-------------|
| `machine_id` | INTEGER | FK to machines (NULL = universal) |
| `raw_code` | TEXT | What machine sends: `W.B.C`, `Leukocytes` |
| `standard_code` | TEXT | Our standard: `WBC_TOTAL` |
| `unit_conversion_factor` | REAL | Math conversion (e.g., 10 for g/dL → g/L) |
```sql
CREATE TABLE test_dictionary (
id INTEGER PRIMARY KEY AUTOINCREMENT,
machine_id INTEGER,
raw_code TEXT NOT NULL,
standard_code TEXT NOT NULL,
standard_name TEXT NOT NULL,
unit_conversion_factor REAL DEFAULT 1.0,
raw_unit TEXT,
standard_unit TEXT,
reference_low REAL,
reference_high REAL,
is_active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (machine_id) REFERENCES machines(id),
UNIQUE(machine_id, raw_code)
);
```
**Translation Example:**
| Machine | Raw Code | Standard Code | Conversion |
|---------|----------|---------------|------------|
| Sysmex | `WBC` | `WBC_TOTAL` | × 1.0 |
| Mindray | `Leukocytes` | `WBC_TOTAL` | × 1.0 |
| Sysmex | `HGB` (g/dL) | `HGB` (g/L) | × 10 |
| Universal | `W.B.C` | `WBC_TOTAL` | × 1.0 |
---
### 8. `sync_log` — Audit Trail 📜
Track all sync activities for debugging and recovery.
```sql
CREATE TABLE sync_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
direction TEXT CHECK(direction IN ('push', 'pull')),
event_type TEXT NOT NULL,
entity_type TEXT,
entity_id INTEGER,
server_response_code INTEGER,
success BOOLEAN,
duration_ms INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
---
### 9. `config` — Local Settings ⚙️
Key-value store for workstation-specific settings.
```sql
CREATE TABLE config (
key TEXT PRIMARY KEY,
value TEXT,
description TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
**Default values:**
| Key | Value | Description |
|-----|-------|-------------|
| `workstation_id` | `LAB-WS-001` | Unique identifier |
| `server_url` | `https://api.clqms.com` | Core Server endpoint |
| `cache_days` | `7` | Days to keep cached orders |
| `auto_validate` | `false` | Auto-validate normal results |
---
## 🔄 How the Sync Works
### Outbox Pattern (Push)
```
┌─────────────────┐
│ Lab Result │
│ Generated │
└────────┬────────┘
┌─────────────────┐
│ Save to SQLite │
│ + Outbox │
└────────┬────────┘
┌─────────────────┐ ┌─────────────────┐
│ Send to Server │────>│ Core Server │
└────────┬────────┘ └────────┬────────┘
│ │
│ ◄──── ACK ─────────┘
┌─────────────────┐
│ Mark as 'acked' │
│ in Outbox │
└─────────────────┘
```
### Self-Healing Recovery
If the workstation was offline and missed Redis notifications:
```javascript
// On startup, ask: "Did I miss anything?"
async function recoverMissedMessages() {
const lastSync = await db.get("SELECT value FROM config WHERE key = 'last_sync'");
const missed = await api.get(`/outbox/pending?since=${lastSync}`);
for (const message of missed) {
await inbox.insert(message);
}
}
```
---
## 📋 Sample Data
### Sample Machine Registration
```sql
INSERT INTO machines (name, manufacturer, driver_file, connection_type, connection_config)
VALUES ('Sysmex XN-1000', 'Sysmex', 'driver-sysmex-xn1000.js', 'RS232',
'{"port": "COM3", "baudRate": 9600}');
```
### Sample Dictionary Entry
```sql
-- Mindray calls WBC "Leukocytes" — we translate it!
INSERT INTO test_dictionary (machine_id, raw_code, standard_code, standard_name, raw_unit, standard_unit)
VALUES (2, 'Leukocytes', 'WBC_TOTAL', 'White Blood Cell Count', 'x10^9/L', '10^3/uL');
```
### Sample Result with Translation
```sql
-- Machine sent: { code: "Leukocytes", value: 8.5, unit: "x10^9/L" }
-- After translation:
INSERT INTO results (test_code, value, unit, flag, raw_test_code, raw_value, raw_unit)
VALUES ('WBC_TOTAL', 8.5, '10^3/uL', 'N', 'Leukocytes', '8.5', 'x10^9/L');
```
---
## 🏆 Key Benefits
| Feature | Benefit |
|---------|---------|
| **Offline-First** | Lab never stops, even without internet |
| **Outbox Queue** | Zero data loss guarantee |
| **Test Dictionary** | Clean, standardized data from any machine |
| **Inbox Queue** | Never miss orders, even if offline |
| **Sync Log** | Full audit trail for debugging |
---
## 📁 Full SQL Migration
The complete SQL migration file is available at:
📄 [`docs/examples/edge_workstation.sql`](/docs/examples/edge_workstation.sql)

View File

@ -0,0 +1,32 @@
# 001. Database Design Constraints
Date: 2025-12-18
Status: Accepted
## Context
The database schema and relationship design for the CLQMS system were established by management and external stakeholders. The backend engineering team was brought in after the core data structure was finalized.
The development team has identified potential challenges regarding:
- Normalization levels in specific tables.
- Naming conventions differ from standard framework defaults.
- Specific relationship structures that may impact query performance or data integrity.
## Decision
The backend team will implement the application logic based on the provided database schema. Significant structural changes to the database (refactoring tables, altering core relationships) are out of scope for the current development phase unless explicitly approved by management.
The team will:
1. Map application entities to the existing table structures.
2. Handle necessary data integrity and consistency checks within the Application Layer (Models/Services) where the database constraints are insufficient.
3. Document any workarounds required to bridge the gap between the schema and the application framework (CodeIgniter 4).
## Consequences
### Positive
- Development can proceed immediately without spending time on database redesign discussions.
- Alignment with the manager's initial vision and requirements.
### Negative
- **Technical Debt**: Potential accumulation of "glue code" to make modern framework features work with the non-standard schema.
- **Maintainability**: Future developers may find the data model unintuitive if it deviates significantly from standard practices.
- **Performance**: Sub-optimal schema designs may require complex queries or application-side processing that impacts performance at scale.
- **Responsibility**: The backend team explicitly notes that issues arising directly from the inherent database structure (e.g., anomalies, scaling bottlenecks related to schema) are consequences of this design constraint.

View File

@ -0,0 +1,254 @@
-- ============================================================
-- 🖥️ CLQMS Edge Workstation - SQLite Database Schema
-- Project Pandaria: Offline-First LIS Architecture
-- ============================================================
-- This is the LOCAL database for each Smart Workstation.
-- Stack: Node.js (Electron) + SQLite
-- Role: "The Cortex" - Immediate Processing
-- ============================================================
-- 🔧 Enable foreign keys (SQLite needs this explicitly)
PRAGMA foreign_keys = ON;
-- ============================================================
-- 1. 📋 CACHED ORDERS (Hot Cache - Last 7 Days)
-- ============================================================
-- Orders downloaded from the Core Server for local processing.
-- Workstation can work 100% offline with this data.
CREATE TABLE IF NOT EXISTS orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_order_id TEXT UNIQUE NOT NULL, -- Original ID from Core Server
patient_id TEXT NOT NULL,
patient_name TEXT NOT NULL,
patient_dob DATE,
patient_gender TEXT CHECK(patient_gender IN ('M', 'F', 'O')),
order_date DATETIME NOT NULL,
priority TEXT DEFAULT 'routine' CHECK(priority IN ('stat', 'routine', 'urgent')),
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'in_progress', 'completed', 'cancelled')),
barcode TEXT,
notes TEXT,
synced_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_orders_barcode ON orders(barcode);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_patient ON orders(patient_id);
-- ============================================================
-- 2. 🔬 ORDER TESTS (What tests are requested?)
-- ============================================================
-- Each order can have multiple tests (CBC, Urinalysis, etc.)
CREATE TABLE IF NOT EXISTS order_tests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_id INTEGER NOT NULL,
test_code TEXT NOT NULL, -- Standardized code (e.g., 'WBC_TOTAL')
test_name TEXT NOT NULL, -- Display name (e.g., 'White Blood Cell Count')
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'processing', 'completed', 'failed')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
);
CREATE INDEX idx_order_tests_order ON order_tests(order_id);
CREATE INDEX idx_order_tests_code ON order_tests(test_code);
-- ============================================================
-- 3. 📊 RESULTS (Machine Output - Normalized)
-- ============================================================
-- Results from lab machines, already translated to standard format.
CREATE TABLE IF NOT EXISTS results (
id INTEGER PRIMARY KEY AUTOINCREMENT,
order_test_id INTEGER,
machine_id INTEGER,
test_code TEXT NOT NULL, -- Standardized test code
value REAL NOT NULL, -- Numeric result
unit TEXT NOT NULL, -- Standardized unit
reference_low REAL,
reference_high REAL,
flag TEXT CHECK(flag IN ('L', 'N', 'H', 'LL', 'HH', 'A')), -- Low, Normal, High, Critical Low/High, Abnormal
raw_value TEXT, -- Original value from machine
raw_unit TEXT, -- Original unit from machine
raw_test_code TEXT, -- Original code from machine (before translation)
validated BOOLEAN DEFAULT 0,
validated_by TEXT,
validated_at DATETIME,
machine_timestamp DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (order_test_id) REFERENCES order_tests(id) ON DELETE SET NULL,
FOREIGN KEY (machine_id) REFERENCES machines(id) ON DELETE SET NULL
);
CREATE INDEX idx_results_order_test ON results(order_test_id);
CREATE INDEX idx_results_test_code ON results(test_code);
CREATE INDEX idx_results_validated ON results(validated);
-- ============================================================
-- 4. 📮 OUTBOX QUEUE (Registered Mail Pattern)
-- ============================================================
-- Data waits here until the Core Server confirms receipt (ACK).
-- Zero data loss, even if network blinks!
CREATE TABLE IF NOT EXISTS outbox_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL, -- 'result_created', 'result_validated', 'order_updated'
payload TEXT NOT NULL, -- JSON data to sync
target_entity TEXT, -- 'results', 'orders', etc.
target_id INTEGER, -- ID of the record
priority INTEGER DEFAULT 5, -- 1 = highest, 10 = lowest
retry_count INTEGER DEFAULT 0,
max_retries INTEGER DEFAULT 5,
last_error TEXT,
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'processing', 'sent', 'acked', 'failed')),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
sent_at DATETIME,
acked_at DATETIME
);
CREATE INDEX idx_outbox_status ON outbox_queue(status);
CREATE INDEX idx_outbox_priority ON outbox_queue(priority, created_at);
-- ============================================================
-- 5. 📥 INBOX QUEUE (Messages from Server)
-- ============================================================
-- Incoming messages/orders from Core Server waiting to be processed.
CREATE TABLE IF NOT EXISTS inbox_queue (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_message_id TEXT UNIQUE NOT NULL, -- ID from server for deduplication
event_type TEXT NOT NULL, -- 'new_order', 'order_cancelled', 'config_update'
payload TEXT NOT NULL, -- JSON data
status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'processing', 'completed', 'failed')),
error_message TEXT,
received_at DATETIME DEFAULT CURRENT_TIMESTAMP,
processed_at DATETIME
);
CREATE INDEX idx_inbox_status ON inbox_queue(status);
-- ============================================================
-- 6. 🔌 MACHINES (Connected Lab Equipment)
-- ============================================================
-- Registry of connected machines/analyzers.
CREATE TABLE IF NOT EXISTS machines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- 'Sysmex XN-1000', 'Mindray BC-6800'
manufacturer TEXT,
model TEXT,
serial_number TEXT,
driver_file TEXT NOT NULL, -- 'driver-sysmex-xn1000.js'
connection_type TEXT CHECK(connection_type IN ('RS232', 'TCP', 'USB', 'FILE')),
connection_config TEXT, -- JSON: {"port": "COM3", "baudRate": 9600}
is_active BOOLEAN DEFAULT 1,
last_communication DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================
-- 7. 📖 TEST DICTIONARY (The Translator)
-- ============================================================
-- Maps machine-specific codes to standard codes.
-- Solves the "WBC vs Leukocytes" problem!
CREATE TABLE IF NOT EXISTS test_dictionary (
id INTEGER PRIMARY KEY AUTOINCREMENT,
machine_id INTEGER, -- NULL = universal mapping
raw_code TEXT NOT NULL, -- What the machine sends: 'W.B.C', 'Leukocytes'
standard_code TEXT NOT NULL, -- Our standard: 'WBC_TOTAL'
standard_name TEXT NOT NULL, -- 'White Blood Cell Count'
unit_conversion_factor REAL DEFAULT 1.0, -- Multiply raw value by this (e.g., 10 for g/dL to g/L)
raw_unit TEXT, -- Unit machine sends
standard_unit TEXT, -- Our standard unit
reference_low REAL,
reference_high REAL,
is_active BOOLEAN DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (machine_id) REFERENCES machines(id) ON DELETE CASCADE,
UNIQUE(machine_id, raw_code)
);
CREATE INDEX idx_dictionary_lookup ON test_dictionary(machine_id, raw_code);
-- ============================================================
-- 8. 📜 SYNC LOG (Audit Trail)
-- ============================================================
-- Track all sync activities for debugging & recovery.
CREATE TABLE IF NOT EXISTS sync_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
direction TEXT CHECK(direction IN ('push', 'pull')),
event_type TEXT NOT NULL,
entity_type TEXT,
entity_id INTEGER,
server_response_code INTEGER,
server_message TEXT,
success BOOLEAN,
duration_ms INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_sync_log_created ON sync_log(created_at DESC);
-- ============================================================
-- 9. ⚙️ LOCAL CONFIG (Workstation Settings)
-- ============================================================
-- Key-value store for workstation-specific settings.
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT,
description TEXT,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- ============================================================
-- 📦 SAMPLE DATA: Machines & Dictionary
-- ============================================================
-- Sample Machines
INSERT INTO machines (name, manufacturer, model, driver_file, connection_type, connection_config) VALUES
('Sysmex XN-1000', 'Sysmex', 'XN-1000', 'driver-sysmex-xn1000.js', 'RS232', '{"port": "COM3", "baudRate": 9600}'),
('Mindray BC-6800', 'Mindray', 'BC-6800', 'driver-mindray-bc6800.js', 'TCP', '{"host": "192.168.1.50", "port": 5000}');
-- Sample Test Dictionary (The Translator)
INSERT INTO test_dictionary (machine_id, raw_code, standard_code, standard_name, raw_unit, standard_unit, unit_conversion_factor, reference_low, reference_high) VALUES
-- Sysmex mappings (machine_id = 1)
(1, 'WBC', 'WBC_TOTAL', 'White Blood Cell Count', '10^3/uL', '10^3/uL', 1.0, 4.0, 11.0),
(1, 'RBC', 'RBC_TOTAL', 'Red Blood Cell Count', '10^6/uL', '10^6/uL', 1.0, 4.5, 5.5),
(1, 'HGB', 'HGB', 'Hemoglobin', 'g/dL', 'g/L', 10.0, 120, 170),
(1, 'PLT', 'PLT_TOTAL', 'Platelet Count', '10^3/uL', '10^3/uL', 1.0, 150, 400),
-- Mindray mappings (machine_id = 2) - Different naming!
(2, 'Leukocytes', 'WBC_TOTAL', 'White Blood Cell Count', 'x10^9/L', '10^3/uL', 1.0, 4.0, 11.0),
(2, 'Erythrocytes', 'RBC_TOTAL', 'Red Blood Cell Count', 'x10^12/L', '10^6/uL', 1.0, 4.5, 5.5),
(2, 'Hb', 'HGB', 'Hemoglobin', 'g/L', 'g/L', 1.0, 120, 170),
(2, 'Thrombocytes', 'PLT_TOTAL', 'Platelet Count', 'x10^9/L', '10^3/uL', 1.0, 150, 400),
-- Universal mappings (machine_id = NULL)
(NULL, 'W.B.C', 'WBC_TOTAL', 'White Blood Cell Count', NULL, '10^3/uL', 1.0, 4.0, 11.0),
(NULL, 'White_Cells', 'WBC_TOTAL', 'White Blood Cell Count', NULL, '10^3/uL', 1.0, 4.0, 11.0);
-- Sample Config
INSERT INTO config (key, value, description) VALUES
('workstation_id', 'LAB-WS-001', 'Unique identifier for this workstation'),
('workstation_name', 'Hematology Station 1', 'Human-readable name'),
('server_url', 'https://clqms-core.example.com/api', 'Core Server API endpoint'),
('cache_days', '7', 'Number of days to keep cached orders'),
('auto_validate', 'false', 'Auto-validate results within normal range'),
('last_sync', NULL, 'Timestamp of last successful sync');
-- Sample Order (for testing)
INSERT INTO orders (server_order_id, patient_id, patient_name, patient_dob, patient_gender, order_date, priority, barcode) VALUES
('ORD-2025-001234', 'PAT-00001', 'John Smith', '1980-01-15', 'M', '2025-12-19 08:00:00', 'routine', 'LAB2025001234');
INSERT INTO order_tests (order_id, test_code, test_name) VALUES
(1, 'WBC_TOTAL', 'White Blood Cell Count'),
(1, 'RBC_TOTAL', 'Red Blood Cell Count'),
(1, 'HGB', 'Hemoglobin'),
(1, 'PLT_TOTAL', 'Platelet Count');
-- ============================================================
-- ✅ DONE! Your Edge Workstation database is ready.
-- ============================================================

524
public/assets/css/app.css Normal file
View File

@ -0,0 +1,524 @@
/* ========================================
CLQMS Frontend - Fun & Light Theme
======================================== */
/* ---------- CSS Variables ---------- */
:root {
/* Fun Color Palette */
--primary: #6366f1; /* Indigo */
--primary-light: #818cf8;
--primary-dark: #4f46e5;
--secondary: #f472b6; /* Pink */
--secondary-light: #f9a8d4;
--accent: #34d399; /* Emerald */
--accent-light: #6ee7b7;
--warning: #fbbf24; /* Amber */
--danger: #f87171; /* Red */
--info: #38bdf8; /* Sky */
/* Neutrals */
--bg-primary: #fefefe;
--bg-secondary: #f8fafc;
--bg-card: #ffffff;
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-muted: #94a3b8;
--border-color: #e2e8f0;
--border-radius: 16px;
--border-radius-sm: 10px;
--border-radius-lg: 24px;
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-md: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-lg: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
--shadow-glow: 0 0 40px -10px var(--primary);
/* Transitions */
--transition-fast: 150ms ease;
--transition-normal: 250ms ease;
--transition-slow: 350ms ease;
}
/* ---------- Reset & Base ---------- */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-size: 16px;
scroll-behavior: smooth;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg-secondary);
color: var(--text-primary);
line-height: 1.6;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ---------- Typography ---------- */
h1, h2, h3, h4, h5, h6 {
font-weight: 700;
line-height: 1.2;
color: var(--text-primary);
}
h1 { font-size: 2.5rem; }
h2 { font-size: 2rem; }
h3 { font-size: 1.5rem; }
h4 { font-size: 1.25rem; }
p {
color: var(--text-secondary);
}
a {
color: var(--primary);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--primary-dark);
}
/* ---------- Fun Background Patterns ---------- */
.bg-pattern {
background-color: var(--bg-secondary);
background-image:
radial-gradient(circle at 20% 80%, rgba(99, 102, 241, 0.08) 0%, transparent 50%),
radial-gradient(circle at 80% 20%, rgba(244, 114, 182, 0.08) 0%, transparent 50%),
radial-gradient(circle at 40% 40%, rgba(52, 211, 153, 0.06) 0%, transparent 40%);
}
.bg-gradient-fun {
background: linear-gradient(135deg,
rgba(99, 102, 241, 0.1) 0%,
rgba(244, 114, 182, 0.1) 50%,
rgba(52, 211, 153, 0.1) 100%);
}
/* ---------- Card Component ---------- */
.card {
background: var(--bg-card);
border-radius: var(--border-radius);
box-shadow: var(--shadow-md);
padding: 2rem;
transition: transform var(--transition-normal), box-shadow var(--transition-normal);
}
.card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.card-glass {
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.5);
}
/* ---------- Form Elements ---------- */
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.form-input {
width: 100%;
padding: 0.875rem 1rem;
font-size: 1rem;
font-family: inherit;
color: var(--text-primary);
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: var(--border-radius-sm);
transition: all var(--transition-fast);
outline: none;
}
.form-input:focus {
border-color: var(--primary);
background: var(--bg-card);
box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.15);
}
.form-input:hover:not(:focus) {
border-color: var(--text-muted);
}
.form-input::placeholder {
color: var(--text-muted);
}
.form-input-icon {
position: relative;
}
.form-input-icon .icon {
position: absolute;
left: 1rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
transition: color var(--transition-fast);
}
.form-input-icon .form-input {
padding-left: 2.75rem;
}
.form-input-icon:focus-within .icon {
color: var(--primary);
}
/* Password toggle */
.password-toggle {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 0.25rem;
transition: color var(--transition-fast);
}
.password-toggle:hover {
color: var(--primary);
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.875rem 1.5rem;
font-size: 1rem;
font-weight: 600;
font-family: inherit;
border: none;
border-radius: var(--border-radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
outline: none;
text-decoration: none;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
color: white;
box-shadow: 0 4px 14px -3px rgba(99, 102, 241, 0.5);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px -3px rgba(99, 102, 241, 0.6);
}
.btn-primary:active:not(:disabled) {
transform: translateY(0);
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 2px solid var(--border-color);
}
.btn-secondary:hover:not(:disabled) {
background: var(--bg-card);
border-color: var(--primary);
color: var(--primary);
}
.btn-ghost {
background: transparent;
color: var(--text-secondary);
}
.btn-ghost:hover {
background: var(--bg-secondary);
color: var(--primary);
}
.btn-block {
width: 100%;
}
.btn-lg {
padding: 1rem 2rem;
font-size: 1.125rem;
border-radius: var(--border-radius);
}
/* ---------- Checkbox ---------- */
.checkbox-wrapper {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
}
.checkbox-input {
width: 1.25rem;
height: 1.25rem;
accent-color: var(--primary);
cursor: pointer;
}
.checkbox-label {
font-size: 0.875rem;
color: var(--text-secondary);
user-select: none;
}
/* ---------- Alerts ---------- */
.alert {
padding: 1rem 1.25rem;
border-radius: var(--border-radius-sm);
font-size: 0.875rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.alert-error {
background: rgba(248, 113, 113, 0.15);
color: #dc2626;
border: 1px solid rgba(248, 113, 113, 0.3);
}
.alert-success {
background: rgba(52, 211, 153, 0.15);
color: #059669;
border: 1px solid rgba(52, 211, 153, 0.3);
}
.alert-info {
background: rgba(56, 189, 248, 0.15);
color: #0284c7;
border: 1px solid rgba(56, 189, 248, 0.3);
}
/* ---------- Spinner ---------- */
.spinner {
width: 1.25rem;
height: 1.25rem;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ---------- Login Page Specific ---------- */
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.login-card {
width: 100%;
max-width: 420px;
}
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.login-logo {
width: 80px;
height: 80px;
margin: 0 auto 1.5rem;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
color: white;
box-shadow: 0 10px 30px -10px rgba(99, 102, 241, 0.5);
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-10px); }
}
.login-title {
font-size: 1.75rem;
margin-bottom: 0.5rem;
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.login-subtitle {
color: var(--text-muted);
font-size: 0.95rem;
}
.login-footer {
text-align: center;
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color);
}
.login-footer p {
font-size: 0.875rem;
}
/* ---------- Decorative Elements ---------- */
.floating-shapes {
position: fixed;
inset: 0;
pointer-events: none;
overflow: hidden;
z-index: -1;
}
.shape {
position: absolute;
border-radius: 50%;
opacity: 0.5;
animation: floatShape 20s ease-in-out infinite;
}
.shape-1 {
width: 300px;
height: 300px;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, transparent 70%);
top: -100px;
right: -100px;
animation-delay: 0s;
}
.shape-2 {
width: 400px;
height: 400px;
background: linear-gradient(135deg, rgba(244, 114, 182, 0.12) 0%, transparent 70%);
bottom: -150px;
left: -150px;
animation-delay: -7s;
}
.shape-3 {
width: 200px;
height: 200px;
background: linear-gradient(135deg, rgba(52, 211, 153, 0.12) 0%, transparent 70%);
top: 40%;
left: 10%;
animation-delay: -14s;
}
@keyframes floatShape {
0%, 100% { transform: translate(0, 0) rotate(0deg); }
25% { transform: translate(20px, -30px) rotate(5deg); }
50% { transform: translate(-10px, 20px) rotate(-5deg); }
75% { transform: translate(30px, 10px) rotate(3deg); }
}
/* ---------- Utilities ---------- */
.text-center { text-align: center; }
.text-muted { color: var(--text-muted); }
.text-primary { color: var(--primary); }
.text-sm { font-size: 0.875rem; }
.mt-1 { margin-top: 0.25rem; }
.mt-2 { margin-top: 0.5rem; }
.mt-4 { margin-top: 1rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-4 { margin-bottom: 1rem; }
.flex { display: flex; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.gap-2 { gap: 0.5rem; }
/* ---------- Animations ---------- */
.fade-in {
animation: fadeIn 0.5s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.shake {
animation: shake 0.5s ease-in-out;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20%, 60% { transform: translateX(-5px); }
40%, 80% { transform: translateX(5px); }
}
/* ---------- Responsive ---------- */
@media (max-width: 480px) {
.login-container {
padding: 1rem;
}
.card {
padding: 1.5rem;
}
.login-logo {
width: 64px;
height: 64px;
font-size: 1.5rem;
}
.login-title {
font-size: 1.5rem;
}
}

186
public/assets/js/app.js Normal file
View File

@ -0,0 +1,186 @@
/**
* CLQMS Frontend - Global Alpine.js Components & Utilities
*/
// Wait for Alpine to be ready
document.addEventListener('alpine:init', () => {
/**
* Global Auth Store
* Manages authentication state across the app
*/
Alpine.store('auth', {
user: null,
isAuthenticated: false,
setUser(userData) {
this.user = userData;
this.isAuthenticated = !!userData;
},
clearUser() {
this.user = null;
this.isAuthenticated = false;
}
});
/**
* Toast Notification Store
*/
Alpine.store('toast', {
messages: [],
show(message, type = 'info', duration = 4000) {
const id = Date.now();
this.messages.push({ id, message, type });
setTimeout(() => {
this.dismiss(id);
}, duration);
},
dismiss(id) {
this.messages = this.messages.filter(m => m.id !== id);
},
success(message) { this.show(message, 'success'); },
error(message) { this.show(message, 'error', 6000); },
info(message) { this.show(message, 'info'); }
});
/**
* Login Component
*/
Alpine.data('loginForm', () => ({
username: '',
password: '',
rememberMe: false,
showPassword: false,
isLoading: false,
error: null,
async submitLogin() {
// Reset error
this.error = null;
// Validation
if (!this.username.trim()) {
this.error = 'Please enter your username';
this.shakeForm();
return;
}
if (!this.password) {
this.error = 'Please enter your password';
this.shakeForm();
return;
}
// Start loading
this.isLoading = true;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'include', // Important for cookies
body: JSON.stringify({
username: this.username.trim(),
password: this.password
})
});
const data = await response.json();
if (response.ok && data.status === 'success') {
// Store user data
Alpine.store('auth').setUser(data.data);
// Show success feedback
Alpine.store('toast').success('Login successful! Redirecting...');
// Redirect to dashboard
setTimeout(() => {
window.location.href = '/dashboard';
}, 500);
} else {
// Handle error
this.error = data.message || 'Invalid username or password';
this.shakeForm();
}
} catch (err) {
console.error('Login error:', err);
this.error = 'Connection error. Please try again.';
this.shakeForm();
} finally {
this.isLoading = false;
}
},
shakeForm() {
const form = this.$refs.loginCard;
if (form) {
form.classList.add('shake');
setTimeout(() => form.classList.remove('shake'), 500);
}
},
togglePassword() {
this.showPassword = !this.showPassword;
}
}));
});
/**
* Utility Functions
*/
const Utils = {
// Format date to locale string
formatDate(dateString) {
return new Date(dateString).toLocaleDateString('id-ID', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
},
// Debounce function
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
// API helper with credentials
async api(endpoint, options = {}) {
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
credentials: 'include'
};
const response = await fetch(endpoint, { ...defaultOptions, ...options });
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || 'API request failed');
}
return data;
}
};
// Expose Utils globally
window.Utils = Utils;