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:
parent
d0350388a0
commit
6a270e181c
1
.gitignore
vendored
1
.gitignore
vendored
@ -22,4 +22,5 @@ Thumbs.db
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
/.claude
|
||||
/.serena
|
||||
42
README.md
Normal file
42
README.md
Normal 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.
|
||||
572
docs/frontend-implementation-plan.md
Normal file
572
docs/frontend-implementation-plan.md
Normal 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
|
||||
231
docs/templates/login.html
vendored
231
docs/templates/login.html
vendored
@ -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>
|
||||
<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
13
jsconfig.json
Normal 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
24
package.json
Normal 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
1387
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
src/app.css
Normal file
6
src/app.css
Normal file
@ -0,0 +1,6 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin 'daisyui';
|
||||
|
||||
@theme {
|
||||
/* Custom theme variables can be added here */
|
||||
}
|
||||
11
src/app.html
Normal file
11
src/app.html
Normal 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
37
src/lib/api/auth.js
Normal 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
104
src/lib/api/client.js
Normal 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' });
|
||||
}
|
||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal 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
1
src/lib/index.js
Normal 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
60
src/lib/stores/auth.js
Normal 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();
|
||||
57
src/routes/(app)/+layout.svelte
Normal file
57
src/routes/(app)/+layout.svelte
Normal 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}
|
||||
42
src/routes/(app)/dashboard/+page.svelte
Normal file
42
src/routes/(app)/dashboard/+page.svelte
Normal 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
14
src/routes/+layout.svelte
Normal 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
18
src/routes/+page.svelte
Normal 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>
|
||||
194
src/routes/login/+page.svelte
Normal file
194
src/routes/login/+page.svelte
Normal 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
3
static/robots.txt
Normal file
@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
13
svelte.config.js
Normal file
13
svelte.config.js
Normal 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
7
vite.config.js
Normal 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()]
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user