feat: Complete Phase 0 foundation

- Initialize SvelteKit project with Tailwind CSS and DaisyUI
- Configure API base URL and environment variables
- Create base API client with JWT token handling (src/lib/api/client.js)
- Implement login/logout flow (src/lib/api/auth.js, src/routes/login/+page.svelte)
- Create root layout with navigation (src/routes/+layout.svelte)
- Set up protected route group with auth checks (src/routes/(app)/+layout.svelte)
- Create dashboard homepage (src/routes/(app)/dashboard/+page.svelte)
- Add auth state store with localStorage persistence (src/lib/stores/auth.js)

All Phase 0 foundation items completed per implementation plan.
This commit is contained in:
mahdahar 2026-02-09 21:39:14 +07:00
parent d0350388a0
commit 6a270e181c
23 changed files with 2810 additions and 34 deletions

1
.gitignore vendored
View File

@ -22,4 +22,5 @@ Thumbs.db
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
/.claude
/.serena

2
.npmrc Normal file
View File

@ -0,0 +1,2 @@
engine-strict=true
package-manager=pnpm@10.29.1

42
README.md Normal file
View File

@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv create --template minimal --no-types --install npm .
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

View File

@ -0,0 +1,572 @@
# CLQMS Frontend Implementation Plan
## Project Overview
**CLQMS** (Clinical Laboratory Quality Management System) frontend built with SvelteKit using the KISS (Keep It Simple) principle.
## Architecture Decisions (KISS Principles)
- **No TypeScript** - Plain JavaScript to keep it simple
- **Tailwind CSS & DaisyUI** - Utility classes, minimal custom CSS
- **Simple Fetch API** - Manual wrapper functions, no codegen
- **SvelteKit File-based Routing** - Standard patterns
- **Server-side auth checking** - SvelteKit hooks for session validation
## Project Structure
```
src/
├── lib/
│ ├── api/ # API service functions
│ │ ├── client.js # Base fetch wrapper with auth
│ │ ├── auth.js # Auth endpoints
│ │ ├── valuesets.js # ValueSet endpoints
│ │ ├── masterdata.js # Master data endpoints
│ │ ├── patients.js # Patient endpoints
│ │ └── ...
│ ├── components/ # Reusable Svelte components
│ │ ├── FormInput.svelte
│ │ ├── DataTable.svelte
│ │ ├── SelectDropdown.svelte
│ │ └── Layout.svelte
│ └── stores/ # Svelte stores
│ ├── auth.js # Auth state
│ └── valuesets.js # Cached lookup values
├── routes/
│ ├── +layout.svelte # Root layout with nav
│ ├── +page.svelte # Dashboard (home)
│ ├── login/
│ │ └── +page.svelte
│ ├── (app)/ # Group: protected routes
│ │ ├── +layout.svelte # Auth check
│ │ ├── patients/
│ │ ├── valuesets/
│ │ ├── masterdata/
│ │ └── ...
│ └── api/ # Internal API routes (if needed)
└── app.html
```
## Implementation Phases
### Phase 0: Foundation ✅ COMPLETED
**Priority:** High | **Time Estimate:** 1-2 hours | **Status:** Done
- [x] Initialize SvelteKit project with Tailwind CSS
- [x] Configure API base URL and environment variables
- [x] Create base API client with JWT token handling
- [x] Implement login/logout flow
- [x] Create root layout with navigation
- [x] Set up protected route group with auth checks
- [x] Create dashboard homepage
**Key Files:**
- `src/lib/api/client.js` - Base fetch wrapper
- `src/lib/api/auth.js` - Auth endpoints
- `src/lib/stores/auth.js` - Auth state management
- `src/routes/+layout.svelte` - Root layout
- `src/routes/(app)/+layout.svelte` - Protected layout
- `src/routes/login/+page.svelte` - Login page
---
### Phase 1: Foundation Data
**Priority:** High | **Time Estimate:** 4-6 hours
**Why First:** These are prerequisites for all other modules. ValueSets provide dropdown options; Master Data provides reference entities.
#### 1a. ValueSets Module
- [ ] ValueSet definitions list page
- [ ] ValueSet definitions create/edit form
- [ ] ValueSet items management (CRUD)
- [ ] Cache frequently used value sets in stores
**API Endpoints:**
- `GET /api/valueset` - List value set definitions
- `POST /api/valuesetdef` - Create value set definition
- `PUT /api/valuesetdef/{id}` - Update value set definition
- `DELETE /api/valuesetdef/{id}` - Delete value set definition
- `GET /api/valueset/items` - List value set items
- `POST /api/valueset/items` - Create value set item
- `PUT /api/valueset/items/{id}` - Update value set item
- `DELETE /api/valueset/items/{id}` - Delete value set item
- `POST /api/valueset/refresh` - Refresh cache
#### 1b. Master Data - Locations & Contacts
- [ ] Locations list page with search
- [ ] Locations create/edit form
- [ ] Contacts (Physicians) list page
- [ ] Contacts create/edit form
**API Endpoints:**
- `GET /api/location` - List locations
- `POST /api/location` - Create location
- `PATCH /api/location` - Update location
- `DELETE /api/location` - Delete location
- `GET /api/location/{id}` - Get location details
- `GET /api/contact` - List contacts
- `POST /api/contact` - Create contact
- `PATCH /api/contact` - Update contact
- `DELETE /api/contact` - Delete contact
- `GET /api/contact/{id}` - Get contact details
#### 1c. Master Data - Supporting Entities
- [ ] Occupations management
- [ ] Medical Specialties management
- [ ] Counters management (for ID generation)
**API Endpoints:**
- `GET /api/occupation` - List occupations
- `POST /api/occupation` - Create occupation
- `PATCH /api/occupation` - Update occupation
- `GET /api/occupation/{id}` - Get occupation details
- `GET /api/medicalspecialty` - List specialties
- `POST /api/medicalspecialty` - Create specialty
- `PATCH /api/medicalspecialty` - Update specialty
- `GET /api/medicalspecialty/{id}` - Get specialty details
- `GET /api/counter` - List counters
- `POST /api/counter` - Create counter
- `PATCH /api/counter` - Update counter
- `DELETE /api/counter` - Delete counter
- `GET /api/counter/{id}` - Get counter details
#### 1d. Master Data - Geography
- [ ] Provinces list (read-only dropdown)
- [ ] Cities list with province filter
**API Endpoints:**
- `GET /api/areageo/provinces` - List provinces
- `GET /api/areageo/cities` - List cities (with province_id filter)
---
### Phase 2: Patient Management
**Priority:** High | **Time Estimate:** 3-4 hours
**Dependencies:** Master Data (locations, contacts, occupations, provinces/cities)
#### 2a. Patient CRUD
- [ ] Patients list page with pagination and search
- [ ] Patient create form with validation
- [ ] Patient edit form
- [ ] Patient detail view
- [ ] Patient delete with confirmation
**API Endpoints:**
- `GET /api/patient` - List patients (with pagination, search)
- `POST /api/patient` - Create patient
- `PATCH /api/patient` - Update patient
- `DELETE /api/patient` - Delete patient
- `GET /api/patient/{id}` - Get patient by ID
- `GET /api/patient/check` - Check if patient exists
#### 2b. Advanced Patient Features
- [ ] Patient identifier management (KTP, PASS, SSN, etc.)
- [ ] Patient linking (family relationships)
- [ ] Custodian/guardian assignment
- [ ] Patient address management
**Fields to Implement:**
- PatientID, AlternatePID
- Prefix, NameFirst, NameMiddle, NameLast, NameMaiden, Suffix
- Sex, Birthdate, PlaceOfBirth, Citizenship
- Address fields (Street_1/2/3, ZIP, Province, City, Country)
- Contact (Phone, MobilePhone, EmailAddress1/2)
- Identifiers (PatIdt - type and number)
- Demographics (Race, MaritalStatus, Religion, Ethnic)
- Linked patients (LinkTo)
- Custodian
- DeathIndicator, TimeOfDeath
- Comments (PatCom)
---
### Phase 3: Patient Visits
**Priority:** High | **Time Estimate:** 2-3 hours
**Dependencies:** Patients, Master Data (locations, contacts)
- [ ] Visits list page with filters
- [ ] Create visit form
- [ ] Edit visit form
- [ ] View visits by patient
- [ ] ADT workflow (Admit/Discharge/Transfer)
**API Endpoints:**
- `GET /api/patvisit` - List visits
- `POST /api/patvisit` - Create visit
- `PATCH /api/patvisit` - Update visit
- `DELETE /api/patvisit` - Delete visit
- `GET /api/patvisit/{id}` - Get visit by ID
- `GET /api/patvisit/patient/{patientId}` - Get visits by patient
- `POST /api/patvisitadt` - Create ADT visit
- `PATCH /api/patvisitadt` - Update ADT visit
**Fields:**
- VisitID, PatientID, VisitDate, VisitType
- SiteID, LocationID, DepartmentID
- AttendingPhysician, ReferringPhysician
---
### Phase 4: Specimen Management
**Priority:** Medium | **Time Estimate:** 2-3 hours
**Dependencies:** ValueSets (specimen types, collection methods, statuses)
- [ ] Specimens list page
- [ ] Specimen create/edit forms
- [ ] Container definitions management
- [ ] Specimen preparation methods
- [ ] Specimen statuses
- [ ] Collection methods
**API Endpoints:**
- `GET /api/specimen` - List specimens
- `POST /api/specimen` - Create specimen
- `PATCH /api/specimen` - Update specimen
- `GET /api/specimen/{id}` - Get specimen details
- `GET /api/specimen/container` - List containers
- `POST /api/specimen/container` - Create container
- `PATCH /api/specimen/container` - Update container
- `GET /api/specimen/container/{id}` - Get container details
- `GET /api/specimen/prep` - List preparations
- `POST /api/specimen/prep` - Create preparation
- `PATCH /api/specimen/prep` - Update preparation
- `GET /api/specimen/prep/{id}` - Get preparation details
- `GET /api/specimen/status` - List statuses
- `POST /api/specimen/status` - Create status
- `PATCH /api/specimen/status` - Update status
- `GET /api/specimen/status/{id}` - Get status details
- `GET /api/specimen/collection` - List collection methods
- `POST /api/specimen/collection` - Create collection method
- `PATCH /api/specimen/collection` - Update collection method
- `GET /api/specimen/collection/{id}` - Get collection method details
---
### Phase 5: Test Catalog
**Priority:** Medium | **Time Estimate:** 2-3 hours
**Dependencies:** Organization (disciplines, departments), ValueSets
- [ ] Test definitions list with filtering
- [ ] Create/edit test definitions
- [ ] Test mapping management (host/client codes)
**API Endpoints:**
- `GET /api/tests` - List tests (with filters)
- `POST /api/tests` - Create test
- `PATCH /api/tests` - Update test
- `GET /api/tests/{id}` - Get test details
**Test Types:** TEST, PARAM, CALC, GROUP, TITLE
---
### Phase 6: Orders
**Priority:** High | **Time Estimate:** 3-4 hours
**Dependencies:** Patients, Visits, Tests, Specimen, ValueSets (priorities, statuses)
- [ ] Orders list with status filtering
- [ ] Create order with test selection
- [ ] Order detail view
- [ ] Update order status
- [ ] Order items management
**API Endpoints:**
- `GET /api/ordertest` - List orders (with filters)
- `POST /api/ordertest` - Create order
- `PATCH /api/ordertest` - Update order
- `DELETE /api/ordertest` - Delete order
- `GET /api/ordertest/{id}` - Get order details
- `POST /api/ordertest/status` - Update order status
---
### Phase 7: Results & Dashboard
**Priority:** High | **Time Estimate:** 2-3 hours
**Dependencies:** Orders
- [ ] Dashboard with summary cards
- [ ] Results list with patient filtering
- [ ] Sample tracking view
**API Endpoints:**
- `GET /api/dashboard` - Get dashboard summary
- `GET /api/result` - Get patient results
- `GET /api/sample` - Get samples
**Dashboard Metrics:**
- pendingOrders
- todayResults
- criticalResults
- activePatients
---
### Phase 8: Organization Structure
**Priority:** Medium | **Time Estimate:** 2-3 hours
- [ ] Accounts management
- [ ] Sites management
- [ ] Disciplines management
- [ ] Departments management
- [ ] Workstations management
**API Endpoints:**
- `GET /api/organization/account` - List accounts
- `POST /api/organization/account` - Create account
- `PATCH /api/organization/account` - Update account
- `DELETE /api/organization/account` - Delete account
- `GET /api/organization/account/{id}` - Get account details
- `GET /api/organization/site` - List sites
- `POST /api/organization/site` - Create site
- `PATCH /api/organization/site` - Update site
- `DELETE /api/organization/site` - Delete site
- `GET /api/organization/site/{id}` - Get site details
- `GET /api/organization/discipline` - List disciplines
- `POST /api/organization/discipline` - Create discipline
- `PATCH /api/organization/discipline` - Update discipline
- `DELETE /api/organization/discipline` - Delete discipline
- `GET /api/organization/discipline/{id}` - Get discipline details
- `GET /api/organization/department` - List departments
- `POST /api/organization/department` - Create department
- `PATCH /api/organization/department` - Update department
- `DELETE /api/organization/department` - Delete department
- `GET /api/organization/department/{id}` - Get department details
- `GET /api/organization/workstation` - List workstations
- `POST /api/organization/workstation` - Create workstation
- `PATCH /api/organization/workstation` - Update workstation
- `DELETE /api/organization/workstation` - Delete workstation
- `GET /api/organization/workstation/{id}` - Get workstation details
---
### Phase 9: Edge API (Instrument Integration)
**Priority:** Low | **Time Estimate:** 2-3 hours
- [ ] Edge results viewer
- [ ] Pending orders for instruments
- [ ] Instrument status monitoring
**API Endpoints:**
- `GET /api/edge/orders` - Fetch pending orders
- `POST /api/edge/orders/{orderId}/ack` - Acknowledge order
- `POST /api/edge/status` - Log instrument status
---
## Reusable Components to Build
### 1. FormInput.svelte
Text input with label, validation, and error display.
```svelte
<FormInput
label="Patient ID"
name="patientId"
value={patientId}
required
pattern="[A-Za-z0-9]+"
/>
```
### 2. SelectDropdown.svelte
Dropdown populated from ValueSets or API data.
```svelte
<SelectDropdown
label="Gender"
name="sex"
value={sex}
options={genderOptions}
/>
```
### 3. DataTable.svelte
Sortable, paginated table with actions.
```svelte
<DataTable
columns={['ID', 'Name', 'Status']}
data={patients}
onEdit={handleEdit}
onDelete={handleDelete}
pagination={true}
/>
```
### 4. SearchBar.svelte
Search input with debounce.
### 5. Modal.svelte
Reusable modal for confirmations and forms.
---
## Code Patterns
### API Client Pattern
```javascript
// lib/api/client.js
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost/clqms01';
export async function apiFetch(endpoint, options = {}) {
const token = get(authToken);
const res = await fetch(`${API_BASE}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` }),
...options.headers
}
});
if (res.status === 401) {
// Handle unauthorized
authToken.set(null);
goto('/login');
return;
}
return res.json();
}
```
### Form Handling Pattern
```svelte
<script>
let formData = { name: '', email: '' };
let errors = {};
let loading = false;
async function handleSubmit() {
loading = true;
errors = {};
const result = await createItem(formData);
if (result.status === 'error') {
errors = result.errors || { general: result.message };
} else {
// Success - redirect or show message
}
loading = false;
}
</script>
<form on:submit|preventDefault={handleSubmit}>
<FormInput
label="Name"
bind:value={formData.name}
error={errors.name}
/>
<button type="submit" disabled={loading}>
{loading ? 'Saving...' : 'Save'}
</button>
</form>
```
### Store Pattern for ValueSets
```javascript
// lib/stores/valuesets.js
import { writable } from 'svelte/store';
export const valueSets = writable({});
export async function loadValueSet(code) {
const res = await fetch(`/api/valueset?VSetCode=${code}`);
const data = await res.json();
valueSets.update(vs => ({
...vs,
[code]: data.data || []
}));
}
// Usage in component:
// onMount(() => loadValueSet('GENDER'));
```
---
## Environment Setup
Create `.env` file:
```
VITE_API_URL=http://localhost/clqms01
```
---
## Getting Started
1. **Initialize SvelteKit:**
```bash
npm create svelte@latest clqms-fe
cd clqms-fe
npm install
npx svelte-add@latest tailwindcss
npm install
```
2. **Configure tailwind.config.js:**
```javascript
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
theme: { extend: {} },
plugins: []
};
```
3. **Start with Phase 0:**
- Create API client
- Set up auth stores
- Build login page
- Create protected layout
---
## Navigation Structure
```
Dashboard (Home)
├── Master Data
│ ├── ValueSets
│ ├── Locations
│ ├── Contacts
│ ├── Occupations
│ ├── Medical Specialties
│ └── Counters
├── Organization
│ ├── Accounts
│ ├── Sites
│ ├── Disciplines
│ ├── Departments
│ └── Workstations
├── Patients
│ ├── All Patients
│ └── Patient Visits
├── Laboratory
│ ├── Specimens
│ ├── Tests
│ ├── Orders
│ └── Results
└── Edge
└── Instrument Status
```
---
## Notes
- Use `+layout.server.js` for server-side auth checks
- Use SvelteKit's `invalidateAll()` after mutations
- Cache ValueSets in localStorage for better UX
- Implement optimistic updates where appropriate
- Use loading states for all async operations
- Add toast notifications for success/error feedback

View File

@ -6,11 +6,42 @@
<title>CLQMS - Login</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="stylesheet" href="shared.css">
<style>
@keyframes gradient-shift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.animated-bg {
background: linear-gradient(-45deg, #57534e, #78716c, #a8a29e, #d6d3d1);
background-size: 400% 400%;
animation: gradient-shift 15s ease infinite;
}
.input-focus:focus-within {
border-color: #10b981;
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.2);
}
.error-shake {
animation: shake 0.5s ease-in-out;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
</style>
</head>
<body class="min-h-screen bg-gradient-to-br from-base-200 via-base-100 to-emerald-50/20 flex items-center justify-center">
<body class="min-h-screen bg-white flex items-center justify-center">
<div class="hero min-h-screen">
<div class="hero-content flex-col">
<div class="hero-content flex-col w-full max-w-lg">
<!-- Logo Section -->
<div class="text-center mb-6">
<div class="w-20 h-20 mx-auto rounded-2xl bg-emerald-100 flex items-center justify-center mb-4 shadow-lg border-2 border-emerald-200">
@ -23,45 +54,36 @@
</div>
<!-- Login Card -->
<div class="card bg-base-100 w-full max-w-sm shadow-2xl border-t-4 border-emerald-500">
<div class="card bg-base-200 w-full max-w-xl shadow-2xl border-t-4 border-emerald-500">
<div class="card-body p-8">
<h2 class="text-2xl font-bold text-center text-emerald-700 mb-6">Welcome Back</h2>
<form>
<form id="loginForm">
<!-- Username Field -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-medium">Username</span>
<div class="mb-4">
<label class="input w-full input-focus transition-all duration-200">
<span class="label"><i class="fa-solid fa-user text-emerald-500"></i></span>
<input type="text" id="username" name="username" placeholder="Username" autocomplete="username" />
</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="w-5 h-5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
</svg>
</span>
<input type="text" placeholder="Enter your username" class="input input-bordered w-full pl-10 focus:border-emerald-500 focus:ring-emerald-500" />
</div>
<p id="usernameError" class="text-red-500 text-sm mt-1 hidden"></p>
</div>
<!-- Password Field -->
<div class="form-control mb-4">
<label class="label">
<span class="label-text font-medium">Password</span>
<div class="mb-4">
<label class="input w-full input-focus transition-all duration-200">
<span class="label"><i class="fa-solid fa-lock text-emerald-500"></i></span>
<input type="password" id="password" name="password" placeholder="Password" autocomplete="current-password" />
<button type="button" id="togglePassword" class="btn btn-ghost btn-sm btn-circle">
<i class="fa-solid fa-eye text-emerald-500"></i>
</button>
</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="w-5 h-5 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
</span>
<input type="password" placeholder="Enter your password" class="input input-bordered w-full pl-10 focus:border-emerald-500 focus:ring-emerald-500" />
</div>
<p id="passwordError" class="text-red-500 text-sm mt-1 hidden"></p>
</div>
<!-- Remember Me & Forgot Password -->
<div class="flex items-center justify-between mb-6">
<label class="label cursor-pointer flex items-center gap-2">
<input type="checkbox" class="checkbox checkbox-sm checkbox-emerald" />
<input type="checkbox" id="rememberMe" class="checkbox checkbox-sm checkbox-emerald" />
<span class="label-text text-sm">Remember me</span>
</label>
<a href="#" class="text-sm text-emerald-600 hover:text-emerald-700 hover:underline">Forgot password?</a>
@ -69,11 +91,15 @@
<!-- Login Button -->
<div class="form-control">
<button class="btn bg-emerald-600 hover:bg-emerald-700 text-white shadow-lg hover:shadow-xl transition-all">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"/>
</svg>
Sign In
<button type="submit" id="loginBtn" class="btn bg-emerald-600 hover:bg-emerald-700 text-white shadow-lg hover:shadow-xl transition-all w-full">
<span id="btnText">
<i class="fa-solid fa-right-to-bracket mr-2"></i>
Sign In
</span>
<span id="btnSpinner" class="hidden">
<i class="fa-solid fa-circle-notch spinner mr-2"></i>
Signing in...
</span>
</button>
</div>
</form>
@ -90,9 +116,150 @@
<!-- Footer -->
<div class="mt-8 text-center">
<p class="text-sm text-base-content/40">© 2024 CLQMS. All rights reserved.</p>
<p class="text-sm text-base-content/40">© 2026 CLQMS. All rights reserved.</p>
</div>
</div>
</div>
<script>
// DOM Elements
const form = document.getElementById('loginForm');
const usernameInput = document.getElementById('username');
const passwordInput = document.getElementById('password');
const usernameError = document.getElementById('usernameError');
const passwordError = document.getElementById('passwordError');
const togglePassword = document.getElementById('togglePassword');
const rememberMe = document.getElementById('rememberMe');
const loginBtn = document.getElementById('loginBtn');
const btnText = document.getElementById('btnText');
const btnSpinner = document.getElementById('btnSpinner');
// Load saved credentials
window.addEventListener('DOMContentLoaded', () => {
const savedUsername = localStorage.getItem('clqms_username');
const savedRemember = localStorage.getItem('clqms_remember') === 'true';
if (savedRemember && savedUsername) {
usernameInput.value = savedUsername;
rememberMe.checked = true;
}
});
// Toggle password visibility
togglePassword.addEventListener('click', () => {
const type = passwordInput.getAttribute('type') === 'password' ? 'text' : 'password';
passwordInput.setAttribute('type', type);
const icon = togglePassword.querySelector('i');
icon.classList.toggle('fa-eye');
icon.classList.toggle('fa-eye-slash');
});
// Form validation
function validateForm() {
let isValid = true;
// Reset errors
usernameError.classList.add('hidden');
passwordError.classList.add('hidden');
usernameInput.parentElement.classList.remove('border-red-500', 'error-shake');
passwordInput.parentElement.classList.remove('border-red-500', 'error-shake');
// Validate username
if (!usernameInput.value.trim()) {
usernameError.textContent = 'Username is required';
usernameError.classList.remove('hidden');
usernameInput.parentElement.classList.add('border-red-500', 'error-shake');
isValid = false;
} else if (usernameInput.value.length < 3) {
usernameError.textContent = 'Username must be at least 3 characters';
usernameError.classList.remove('hidden');
usernameInput.parentElement.classList.add('border-red-500', 'error-shake');
isValid = false;
}
// Validate password
if (!passwordInput.value) {
passwordError.textContent = 'Password is required';
passwordError.classList.remove('hidden');
passwordInput.parentElement.classList.add('border-red-500', 'error-shake');
isValid = false;
} else if (passwordInput.value.length < 6) {
passwordError.textContent = 'Password must be at least 6 characters';
passwordError.classList.remove('hidden');
passwordInput.parentElement.classList.add('border-red-500', 'error-shake');
isValid = false;
}
// Remove shake animation after it completes
setTimeout(() => {
usernameInput.parentElement.classList.remove('error-shake');
passwordInput.parentElement.classList.remove('error-shake');
}, 500);
return isValid;
}
// Handle form submission
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!validateForm()) return;
// Show loading state
loginBtn.disabled = true;
btnText.classList.add('hidden');
btnSpinner.classList.remove('hidden');
// Save/Remove credentials based on Remember Me
if (rememberMe.checked) {
localStorage.setItem('clqms_username', usernameInput.value);
localStorage.setItem('clqms_remember', 'true');
} else {
localStorage.removeItem('clqms_username');
localStorage.setItem('clqms_remember', 'false');
}
// Simulate API call (replace with actual login logic)
try {
await new Promise(resolve => setTimeout(resolve, 2000));
// Simulate successful login
console.log('Login successful:', {
username: usernameInput.value,
rememberMe: rememberMe.checked
});
// Reset form
form.reset();
// Here you would typically redirect
// window.location.href = '/dashboard';
} catch (error) {
console.error('Login error:', error);
} finally {
// Reset button state
loginBtn.disabled = false;
btnText.classList.remove('hidden');
btnSpinner.classList.add('hidden');
}
});
// Real-time validation on input
usernameInput.addEventListener('input', () => {
if (usernameInput.value.trim()) {
usernameError.classList.add('hidden');
usernameInput.parentElement.classList.remove('border-red-500');
}
});
passwordInput.addEventListener('input', () => {
if (passwordInput.value) {
passwordError.classList.add('hidden');
passwordInput.parentElement.classList.remove('border-red-500');
}
});
</script>
</body>
</html>

13
jsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": false,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

24
package.json Normal file
View File

@ -0,0 +1,24 @@
{
"name": "fe",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''"
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.18",
"autoprefixer": "^10.4.24",
"daisyui": "^5.5.18",
"postcss": "^8.5.6",
"svelte": "^5.49.2",
"tailwindcss": "^4.1.18",
"vite": "^7.3.1"
}
}

1387
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
src/app.css Normal file
View File

@ -0,0 +1,6 @@
@import 'tailwindcss';
@plugin 'daisyui';
@theme {
/* Custom theme variables can be added here */
}

11
src/app.html Normal file
View File

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

37
src/lib/api/auth.js Normal file
View File

@ -0,0 +1,37 @@
import { post } from './client.js';
/**
* Authentication API endpoints
*/
/**
* Login user
* @param {string} email
* @param {string} password
* @returns {Promise<{token: string, user: Object}>}
*/
export async function login(email, password) {
return post('/api/auth/login', { email, password });
}
/**
* Logout user (client-side only, server may also invalidate token)
* @returns {Promise<void>}
*/
export async function logout() {
// Optionally notify server to invalidate token
try {
await post('/api/auth/logout', {});
} catch (error) {
// Ignore server errors on logout
console.log('Server logout error (ignored):', error);
}
}
/**
* Get current user profile
* @returns {Promise<Object>}
*/
export async function getCurrentUser() {
return post('/api/auth/me', {});
}

104
src/lib/api/client.js Normal file
View File

@ -0,0 +1,104 @@
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth.js';
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
/**
* Base API client with JWT handling
* @param {string} endpoint - API endpoint (without base URL)
* @param {Object} options - Fetch options
* @returns {Promise<any>} - JSON response
*/
export async function apiClient(endpoint, options = {}) {
// Get token from store
let token = null;
auth.subscribe((authState) => {
token = authState.token;
})();
// Build headers
const headers = {
'Content-Type': 'application/json',
...options.headers,
};
// Add Authorization header if token exists
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// Build full URL
const url = `${API_URL}${endpoint}`;
try {
const response = await fetch(url, {
...options,
headers,
});
// Handle 401 Unauthorized
if (response.status === 401) {
auth.logout();
goto('/login');
throw new Error('Unauthorized');
}
// Handle other errors
if (!response.ok) {
const error = await response.json().catch(() => ({ message: 'An error occurred' }));
throw new Error(error.message || `HTTP error! status: ${response.status}`);
}
// Parse JSON response
return await response.json();
} catch (error) {
console.error('API Error:', error);
throw error;
}
}
/**
* GET request helper
* @param {string} endpoint
* @param {Object} options
*/
export function get(endpoint, options = {}) {
return apiClient(endpoint, { ...options, method: 'GET' });
}
/**
* POST request helper
* @param {string} endpoint
* @param {Object} body
* @param {Object} options
*/
export function post(endpoint, body, options = {}) {
return apiClient(endpoint, {
...options,
method: 'POST',
body: JSON.stringify(body),
});
}
/**
* PUT request helper
* @param {string} endpoint
* @param {Object} body
* @param {Object} options
*/
export function put(endpoint, body, options = {}) {
return apiClient(endpoint, {
...options,
method: 'PUT',
body: JSON.stringify(body),
});
}
/**
* DELETE request helper
* @param {string} endpoint
* @param {Object} options
*/
export function del(endpoint, options = {}) {
return apiClient(endpoint, { ...options, method: 'DELETE' });
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

1
src/lib/index.js Normal file
View File

@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.

60
src/lib/stores/auth.js Normal file
View File

@ -0,0 +1,60 @@
import { writable } from 'svelte/store';
import { browser } from '$app/environment';
const STORAGE_KEY = 'auth_token';
/**
* Create auth store with localStorage persistence
*/
function createAuthStore() {
// Get initial state from localStorage (only in browser)
const getInitialState = () => {
if (!browser) {
return { token: null, user: null, isAuthenticated: false };
}
const token = localStorage.getItem(STORAGE_KEY);
return {
token,
user: null,
isAuthenticated: !!token,
};
};
const { subscribe, set, update } = writable(getInitialState());
return {
subscribe,
/**
* Set authentication data after login
* @param {string} token - JWT token
* @param {Object} user - User object
*/
login: (token, user) => {
if (browser) {
localStorage.setItem(STORAGE_KEY, token);
}
set({ token, user, isAuthenticated: true });
},
/**
* Clear authentication data on logout
*/
logout: () => {
if (browser) {
localStorage.removeItem(STORAGE_KEY);
}
set({ token: null, user: null, isAuthenticated: false });
},
/**
* Update user data without changing token
* @param {Object} user - Updated user object
*/
setUser: (user) => {
update((state) => ({ ...state, user }));
},
};
}
export const auth = createAuthStore();

View File

@ -0,0 +1,57 @@
<script>
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth.js';
import { onMount } from 'svelte';
let { children } = $props();
let checking = $state(true);
onMount(() => {
// Check authentication
if (!$auth.isAuthenticated) {
goto('/login');
} else {
checking = false;
}
});
</script>
{#if checking}
<div class="flex min-h-screen items-center justify-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else}
<div class="min-h-screen">
<!-- Navigation -->
<nav class="navbar bg-base-200 px-4">
<div class="flex-1">
<a href="/dashboard" class="btn btn-ghost text-xl">MyApp</a>
</div>
<div class="flex-none gap-2">
<div class="dropdown dropdown-end">
<button tabindex="0" class="btn btn-ghost btn-circle avatar">
<div class="w-10 rounded-full bg-primary text-primary-content flex items-center justify-center">
<span class="text-lg">{$auth.user?.name?.[0] || 'U'}</span>
</div>
</button>
<ul tabindex="0" class="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-100 rounded-box w-52">
<li>
<span class="text-sm opacity-70">{$auth.user?.email || 'user@example.com'}</span>
</li>
<li class="menu-divider"></li>
<li>
<button onclick={() => auth.logout() || goto('/login')}>
Logout
</button>
</li>
</ul>
</div>
</div>
</nav>
<!-- Main content -->
<main class="p-4">
{@render children()}
</main>
</div>
{/if}

View File

@ -0,0 +1,42 @@
<script>
import { auth } from '$lib/stores/auth.js';
</script>
<div class="container mx-auto max-w-4xl">
<h1 class="text-3xl font-bold mb-6">Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Welcome Card -->
<div class="card bg-primary text-primary-content">
<div class="card-body">
<h2 class="card-title">Welcome back!</h2>
<p>Hello, {$auth.user?.name || 'User'}!</p>
<p class="text-sm opacity-80">{$auth.user?.email || ''}</p>
</div>
</div>
<!-- Stats Card -->
<div class="card bg-base-200">
<div class="card-body">
<h2 class="card-title">Quick Stats</h2>
<div class="stats stats-vertical">
<div class="stat">
<div class="stat-title">Status</div>
<div class="stat-value text-success text-2xl">Active</div>
</div>
</div>
</div>
</div>
<!-- Info Card -->
<div class="card bg-base-200">
<div class="card-body">
<h2 class="card-title">Getting Started</h2>
<p class="text-sm">Your protected dashboard is now ready. Add more features and components as needed.</p>
<div class="card-actions justify-end mt-4">
<a href="/" class="btn btn-sm btn-primary">Go Home</a>
</div>
</div>
</div>
</div>
</div>

14
src/routes/+layout.svelte Normal file
View File

@ -0,0 +1,14 @@
<script>
import '../app.css';
import favicon from '$lib/assets/favicon.svg';
let { children } = $props();
</script>
<svelte:head>
<link rel="icon" href={favicon} />
</svelte:head>
<div class="min-h-screen bg-base-100">
{@render children()}
</div>

18
src/routes/+page.svelte Normal file
View File

@ -0,0 +1,18 @@
<script>
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth.js';
import { onMount } from 'svelte';
onMount(() => {
// Redirect based on auth status
if ($auth.isAuthenticated) {
goto('/dashboard');
} else {
goto('/login');
}
});
</script>
<div class="flex min-h-screen items-center justify-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>

View File

@ -0,0 +1,194 @@
<script>
import { goto } from '$app/navigation';
import { auth } from '$lib/stores/auth.js';
import { login } from '$lib/api/auth.js';
import { onMount } from 'svelte';
let username = '';
let password = '';
let error = '';
let loading = false;
let showPassword = false;
let rememberMe = false;
let usernameError = '';
let passwordError = '';
onMount(() => {
const savedUsername = localStorage.getItem('clqms_username');
const savedRemember = localStorage.getItem('clqms_remember') === 'true';
if (savedRemember && savedUsername) {
username = savedUsername;
rememberMe = true;
}
});
function togglePassword() {
showPassword = !showPassword;
}
function validateForm() {
let isValid = true;
usernameError = '';
passwordError = '';
if (!username.trim()) {
usernameError = 'Username is required';
isValid = false;
} else if (username.length < 3) {
usernameError = 'Username must be at least 3 characters';
isValid = false;
}
if (!password) {
passwordError = 'Password is required';
isValid = false;
} else if (password.length < 6) {
passwordError = 'Password must be at least 6 characters';
isValid = false;
}
return isValid;
}
async function handleSubmit() {
error = '';
if (!validateForm()) return;
loading = true;
try {
const response = await login(username, password);
auth.login(response.token, response.user);
if (rememberMe) {
localStorage.setItem('clqms_username', username);
localStorage.setItem('clqms_remember', 'true');
} else {
localStorage.removeItem('clqms_username');
localStorage.setItem('clqms_remember', 'false');
}
goto('/dashboard');
} catch (err) {
error = err.message || 'Login failed. Please try again.';
} finally {
loading = false;
}
}
</script>
<svelte:head>
<title>CLQMS - Login</title>
</svelte:head>
<div class="hero min-h-screen bg-white">
<div class="hero-content flex-col w-full max-w-lg">
<!-- Logo Section -->
<div class="text-center mb-6">
<div class="w-20 h-20 mx-auto rounded-2xl bg-emerald-100 flex items-center justify-center mb-4 shadow-lg border-2 border-emerald-200">
<svg class="w-12 h-12 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"/>
</svg>
</div>
<h1 class="text-4xl font-bold text-emerald-600 mb-2">CLQMS</h1>
<p class="text-gray-600">Clinical Laboratory Quality Management System</p>
</div>
<!-- Login Card -->
<div class="card bg-white w-full max-w-xl shadow-2xl border border-gray-200 border-t-4 border-t-emerald-500">
<div class="card-body p-8">
<h2 class="text-2xl font-bold text-center text-emerald-700 mb-6">Welcome Back</h2>
<form on:submit|preventDefault={handleSubmit}>
<!-- Username Field -->
<div class="mb-4">
<label class="input w-full input-bordered bg-white border-gray-300 flex items-center gap-2 {usernameError ? 'border-red-500' : ''}">
<span class="label"><i class="fa-solid fa-user text-emerald-500"></i></span>
<input
type="text"
id="username"
name="username"
placeholder="Username"
autocomplete="username"
bind:value={username}
disabled={loading}
class="grow text-gray-900 placeholder-gray-400"
/>
</label>
{#if usernameError}
<p class="text-red-500 text-sm mt-1">{usernameError}</p>
{/if}
</div>
<!-- Password Field -->
<div class="mb-4">
<label class="input w-full input-bordered bg-white border-gray-300 flex items-center gap-2 {passwordError ? 'border-red-500' : ''}">
<span class="label"><i class="fa-solid fa-lock text-emerald-500"></i></span>
<input
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
placeholder="Password"
autocomplete="current-password"
bind:value={password}
disabled={loading}
class="grow text-gray-900 placeholder-gray-400"
/>
<button type="button" class="btn btn-ghost btn-sm btn-circle" on:click={togglePassword} disabled={loading}>
<i class="fa-solid {showPassword ? 'fa-eye-slash' : 'fa-eye'} text-emerald-500"></i>
</button>
</label>
{#if passwordError}
<p class="text-red-500 text-sm mt-1">{passwordError}</p>
{/if}
</div>
<!-- Remember Me & Forgot Password -->
<div class="flex items-center justify-between mb-6">
<label class="label cursor-pointer flex items-center gap-2">
<input type="checkbox" bind:checked={rememberMe} class="checkbox checkbox-sm checkbox-emerald" disabled={loading} />
<span class="label-text text-sm">Remember me</span>
</label>
<a href="#" class="text-sm text-emerald-600 hover:text-emerald-700 hover:underline">Forgot password?</a>
</div>
<!-- Error Message -->
{#if error}
<div class="alert alert-error text-sm mb-4">
<span>{error}</span>
</div>
{/if}
<!-- Login Button -->
<div class="form-control">
<button type="submit" class="btn bg-emerald-600 hover:bg-emerald-700 text-white shadow-lg hover:shadow-xl transition-all w-full" disabled={loading}>
{#if loading}
<span class="loading loading-spinner loading-sm mr-2"></span>
Signing in...
{:else}
<i class="fa-solid fa-right-to-bracket mr-2"></i>
Sign In
{/if}
</button>
</div>
</form>
<!-- Divider -->
<div class="divider text-sm text-gray-400">or</div>
<!-- Help Text -->
<p class="text-center text-sm text-gray-500">
Need help? Contact your system administrator
</p>
</div>
</div>
<!-- Footer -->
<div class="mt-8 text-center">
<p class="text-sm text-gray-400">© 2026 CLQMS. All rights reserved.</p>
</div>
</div>
</div>

3
static/robots.txt Normal file
View File

@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

13
svelte.config.js Normal file
View File

@ -0,0 +1,13 @@
import adapter from '@sveltejs/adapter-auto';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

7
vite.config.js Normal file
View File

@ -0,0 +1,7 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [tailwindcss(), sveltekit()]
});