feat: Add ADT history, specimens API, and enhance master data pages

- Add VisitADTHistoryModal for ADT tracking
- Create specimens API client
- Add HelpTooltip component
- Enhance all master data pages with improved UI
- Update patient pages and visit management
- Add implementation plans and API docs
This commit is contained in:
mahdahar 2026-02-15 17:58:42 +07:00
parent 278498123d
commit f7a884577f
27 changed files with 5868 additions and 961 deletions

View File

@ -1,42 +1,72 @@
# sv
# CLQMS Frontend
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
Clinical Laboratory Quality Management System (CLQMS) - SvelteKit frontend application.
## Creating a project
## Tech Stack
If you're seeing this, you've probably already done this step. Congrats!
- **Framework**: SvelteKit 2.50.2 with Svelte 5 (runes)
- **Styling**: Tailwind CSS 4 + DaisyUI 5
- **Icons**: Lucide Svelte
- **Build Tool**: Vite 7.3.1
- **Package Manager**: pnpm
```sh
# create a new project
npx sv create my-app
## Prerequisites
- Node.js 18+
- pnpm (`npm install -g pnpm`)
- Backend API running on `http://localhost:8000`
## Setup
```bash
# Install dependencies
pnpm install
# Create environment file
cp .env.example .env
```
To recreate this project with the same configuration:
## Development Commands
```sh
# recreate this project
npx sv create --template minimal --no-types --install npm .
```bash
# Start development server
pnpm run dev
# Build for production
pnpm run build
# Preview production build
pnpm run preview
# Sync SvelteKit (runs automatically on install)
pnpm run prepare
```
## Developing
## Project Structure
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
```
src/
lib/
api/ # API client and endpoints
stores/ # Svelte stores (auth, valuesets)
components/ # Reusable components
utils/ # Utility functions
routes/ # SvelteKit routes
(app)/ # Authenticated routes
dashboard/
patients/
master-data/
login/ # Public routes
```
## Building
## API Configuration
To create a production version of your app:
API requests to `/api` are proxied to `http://localhost:8000` in development (configured in `vite.config.js`).
```sh
npm run build
```
## Authentication
You can preview the production build with `npm run preview`.
JWT-based authentication with automatic redirect to `/login` on 401 responses.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
## Code Style
See [AGENTS.md](./AGENTS.md) for detailed coding guidelines.

View File

@ -0,0 +1,613 @@
# Comprehensive Implementation Plan - CLQMS Frontend
**Goal:** Complete implementation of all use cases
**Estimated Time:** 6-8 weeks (after MVP)
**This document:** All features beyond MVP scope
---
## Phase 1: Authentication Enhancements (UC-01)
**Use Case:** UC-01 - Complete authentication flow
**Priority:** HIGH - Security & Multi-tenancy
**Dependencies:** Backend support for multi-site, password policies
### 1.1 Multi-Site Selection
**Current:** Login with single site
**Required:** Support for users with access to multiple sites
#### API Changes
```
POST /api/auth/login
Response changes:
{
"token": "...",
"user": { ... },
"sites": [
{"SiteID": "1", "SiteName": "Lab Jakarta", "SiteCode": "JK01"},
{"SiteID": "2", "SiteName": "Lab Bandung", "SiteCode": "BD01"}
],
"requiresSiteSelection": true
}
```
#### Frontend Implementation
**New Files:**
- `src/routes/login/SiteSelectionModal.svelte`
- `src/lib/stores/site.js` - Current site store
**Modify:**
- `src/routes/login/+page.svelte` - Add site selection step
**Features:**
- If personal email used: Show site selection dialog
- If site email used: Auto-select matching site
- Remember site selection in localStorage
- Site switcher in header/navigation
- Site context shown in UI (current site name)
### 1.2 Password Expiration
**Requirements:**
- Track password expiry date
- Force password change when expired
- Grace period warning (7 days before expiry)
#### API Changes
```
POST /api/auth/login
Response includes:
{
"passwordStatus": "valid" | "expired" | "expiring",
"passwordExpiryDate": "2025-03-01",
"daysUntilExpiry": 5
}
POST /api/auth/change-password
```
#### Frontend Implementation
**New Files:**
- `src/routes/login/PasswordExpiredModal.svelte`
- `src/routes/login/ChangePasswordModal.svelte`
**Features:**
- Check password status on login
- If expired: Block access, show change password modal
- If expiring: Show warning banner with countdown
- Password strength requirements display
- Confirm password match validation
### 1.3 Account Lockout
**Requirements:**
- Count failed login attempts
- Lock account after X failed attempts (configurable)
- Lockout duration: X hours (configurable)
- Clear attempts on successful login
#### API Changes
```
POST /api/auth/login
Error responses:
{
"error": "ACCOUNT_LOCKED",
"message": "Please try again in 4 hours or contact system administrator",
"lockoutEndsAt": "2025-02-14T18:00:00Z",
"hoursRemaining": 4
}
POST /api/auth/login
On invalid password:
{
"error": "INVALID_LOGIN",
"attemptsRemaining": 2
}
```
#### Frontend Implementation
**Modify:**
- `src/routes/login/+page.svelte` - Handle lockout states
**Features:**
- Display attempts remaining
- Show lockout timer with countdown
- "Contact administrator" link/info
- Visual indicator of account status
### 1.4 Audit Logging Display
**Requirements:**
- View own login history
- Admin view: All user login history
- Filter by date, user, action
#### API Changes
```
GET /api/audit/login-logs?userId=&from=&to=&page=
Response:
{
"logs": [
{
"AuditID": 1,
"UserID": "admin",
"Action": "LOGIN_SUCCESS",
"Device": "Chrome 120.0 / Windows 10",
"IPAddress": "192.168.1.1",
"Timestamp": "2025-02-14T10:30:00Z"
}
]
}
```
#### Frontend Implementation
**New Files:**
- `src/routes/(app)/admin/audit-logs/+page.svelte`
- `src/lib/api/audit.js`
**Features:**
- Paginated log table
- Filters: Date range, User, Action type
- Export to CSV
- Device/IP details
---
## Phase 2: Advanced Patient Management
### 2.1 Enhanced Patient Registration (UC-02a Enhancements)
**Missing Features:**
- Duplicate identifier detection with "Multiple IDs found" handling
- UC-02b link suggestion when duplicates detected
#### API Changes
```
POST /api/patient/check-duplicate
{
"IdentifierType": "KTP",
"Identifier": "1234567890"
}
Response:
{
"hasDuplicates": true,
"duplicates": [
{"PID": "P001", "Name": "John Doe", "DOB": "1990-01-01"}
]
}
```
#### Frontend Implementation
**Modify:**
- `src/routes/(app)/patients/PatientFormModal.svelte`
**New Files:**
- `src/routes/(app)/patients/DuplicateWarningModal.svelte`
**Features:**
- Check for duplicates on identifier blur
- Show "Multiple IDs found" warning
- Options:
1. Continue (create new, ignore duplicate)
2. Link patients (redirect to UC-02b)
3. Cancel
- Display duplicate patient details for comparison
### 2.2 Patient Unlink (UC-02c)
**Requirements:**
- Reverse patient link operation
- Only Supervisor Lab, Manajer Lab
- Restore source PID to active status
#### API Changes
```
POST /api/patient/unlink
{
"SourcePID": "P001",
"DestinationPID": "P002"
}
Creates ADT: A37 (Unlink Patient Information)
```
#### Frontend Implementation
**New Files:**
- `src/routes/(app)/patients/unlink/+page.svelte`
**Features:**
- Search by PID to find linked patients
- Display destination with all linked sources
- Uncheck source PIDs to unlink
- Confirmation dialog
- Restore source PID edit/order capabilities
---
## Phase 3: Advanced Visit Management
### 3.1 Cancel Admission (UC-03b)
**Requirements:**
- Cancel patient admission
- Only Supervisor Lab, Manajer Lab
- Reason for cancellation
- ADT Code: A11 (Cancel Admit)
#### API Changes
```
POST /api/patvisit/:pvid/cancel-admission
{
"Reason": "Insurance invalid",
"Notes": "..."
}
Creates ADT: A11 (Cancel Admit)
```
#### Frontend Implementation
**New Files:**
- `src/routes/(app)/patients/CancelAdmissionModal.svelte`
**Features:**
- Available in visit actions (supervisor only)
- Reason dropdown + notes field
- Confirm cancellation
- Mark visit as cancelled
- Prevent new orders on cancelled visits
### 3.2 Change Attending Doctor (UC-03c)
**Requirements:**
- Change primary doctor (DPJP)
- ADT Code: A54 (Change Attending Doctor)
- Track doctor history
#### API Changes
```
POST /api/patvisit/:pvid/change-attending-doctor
{
"NewAttendingDoctorID": "DOC001"
}
Creates ADT: A54
```
#### Frontend Implementation
**Modify:**
- `src/routes/(app)/patients/VisitFormModal.svelte`
**Features:**
- Doctor dropdown with search
- Show current attending doctor
- Change history in ADT log
- Validation: Doctor must exist in contacts
### 3.3 Change Consulting Doctor (UC-03d)
**Requirements:**
- Change consulting doctor (konsulen)
- ADT Code: A61 (Change Consulting Doctor)
#### API Changes
```
POST /api/patvisit/:pvid/change-consulting-doctor
{
"NewConsultingDoctorID": "DOC002"
}
Creates ADT: A61
```
#### Frontend Implementation
**Modify:**
- `src/routes/(app)/patients/VisitFormModal.svelte`
**Features:**
- Similar to attending doctor change
- Separate field for consulting doctor
- History tracking
---
## Phase 4: Test Ordering Enhancements
### 4.1 Future Orders
**Requirements:**
- Schedule test order for future date
- Effective date validation
#### Frontend Implementation
**Modify:**
- `src/routes/(app)/orders/new/+page.svelte`
**Features:**
- "Effective Date" field (default: today)
- Future date validation (max 30 days?)
- Separate OID generation for future orders?
- View scheduled orders separately
### 4.2 Order from PID (Alternative Flow)
**Requirements:**
- Create order using PID instead of PVID
- Show list of active PVIDs for patient
- Select PVID then proceed
#### Frontend Implementation
**Modify:**
- `src/routes/(app)/orders/new/+page.svelte`
**Features:**
- Toggle: "Use PID instead of PVID"
- PID search
- Display patient's active visits (not discharged)
- Select PVID to proceed
### 4.3 Non-Patient Orders
**Requirements:**
- Orders without patient (QC, calibration, research)
- Different order type/flow
#### API Changes
```
POST /api/orders/non-patient
{
"OrderType": "QC",
"Description": "...",
"Tests": [...]
}
```
#### Frontend Implementation
**New Files:**
- `src/routes/(app)/orders/non-patient/+page.svelte`
---
## Phase 5: Specimen Management
### 5.1 Specimen Tracking
**Requirements:**
- Full specimen lifecycle tracking
- Collection, receipt, rejection, disposal
- Barcode/label generation
#### API Changes
```
GET /api/specimens/:sid/status-history
PATCH /api/specimens/:sid/status
{
"Status": "COLLECTED", // COLLECTED, RECEIVED, REJECTED, DISPOSED
"Notes": "..."
}
```
#### Frontend Implementation
**New Files:**
- `src/routes/(app)/specimens/+page.svelte` - Specimen list
- `src/routes/(app)/specimens/[sid]/+page.svelte` - Specimen detail
- `src/lib/api/specimens.js` (extend)
**Features:**
- Specimen search (SID, OID, Patient)
- Status timeline/history
- Collection/receipt timestamps
- Rejection reason logging
- Label printing (integration with printer)
### 5.2 Label Printing Integration
**Requirements:**
- Print patient labels
- Print order labels
- Print specimen labels
- Support for label printers
#### Frontend Implementation
**New Files:**
- `src/lib/utils/labelPrinting.js`
- `src/routes/(app)/print/labels/+page.svelte`
**Features:**
- Label templates (patient, order, specimen)
- Print dialog with printer selection
- Preview before print
- Batch printing
- Integration with browser print API or direct printer communication
---
## Phase 6: Reporting & Analytics
### 6.1 Cumulative Patient Reports
**Requirements:**
- View all test results across visits for linked patients
- Chronological view
- Filter by test type, date range
#### API Changes
```
GET /api/reports/patient-cumulative/:pid?from=&to=&tests=
Response:
{
"patient": {...},
"visits": [...],
"results": [
{
"Date": "...",
"Test": "Glucose",
"Result": "120",
"Unit": "mg/dL",
"ReferenceRange": "70-100"
}
]
}
```
#### Frontend Implementation
**New Files:**
- `src/routes/(app)/reports/patient-cumulative/+page.svelte`
**Features:**
- Patient search
- Date range filter
- Test type filter
- Table view with trend indicators
- Export to PDF/Excel
### 6.2 Workload Statistics
**Requirements:**
- Orders by time period
- Tests performed
- Turnaround time analysis
#### Frontend Implementation
**New Files:**
- `src/routes/(app)/reports/workload/+page.svelte`
---
## Phase 7: Administration Features
### 7.1 User Management
**Requirements:**
- Create/edit users
- Role assignment
- Site access management
- Password reset
#### Frontend Implementation
**New Files:**
- `src/routes/(app)/admin/users/+page.svelte`
- `src/routes/(app)/admin/users/UserFormModal.svelte`
### 7.2 Role & Permission Management
**Requirements:**
- Define roles
- Configure menu access per role
- Action-level permissions
### 7.3 System Configuration
**Requirements:**
- Global settings
- ADT code configuration
- Password policy settings
- Lockout configuration
---
## Phase 8: Integration Features
### 8.1 HL7/ADT Message Integration
**Requirements:**
- Receive ADT messages from HIS
- Send order updates to HIS
- Message queue management
### 8.2 Instrument Integration
**Requirements:**
- Bidirectional communication with lab instruments
- Auto-result entry
- QC data import
---
## Implementation Priority Matrix
### Critical (Month 1-2)
1. Authentication: Multi-site selection
2. Patient: Enhanced duplicate detection
3. Visit: Cancel admission, doctor changes
4. Orders: Future orders, PID-based ordering
### High (Month 2-3)
1. Authentication: Password expiration
2. Authentication: Account lockout
3. Patient: Unlink functionality
4. Specimens: Full tracking
5. Reports: Cumulative patient view
### Medium (Month 3-4)
1. Authentication: Audit logs
2. Specimens: Label printing integration
3. Administration: User management
4. Administration: Role management
### Low (Month 4+)
1. Non-patient orders
2. Workload statistics
3. System configuration UI
4. Instrument integration
---
## Technical Considerations
### Performance
- Implement pagination for all list endpoints
- Use virtual scrolling for large tables
- Cache patient/order data appropriately
- Lazy load audit logs
### Security
- All admin actions require elevated permissions
- Audit all sensitive operations
- Encrypt sensitive data at rest
- Secure password storage (backend)
### Scalability
- Support for multiple sites/locations
- Concurrent user handling
- Large dataset performance (100k+ patients)
---
## Dependencies
### Backend Must Support:
- [ ] Multi-site user management
- [ ] Password expiration tracking
- [ ] Failed login attempt tracking
- [ ] All ADT codes (A11, A54, A61, A24, A37)
- [ ] Advanced patient search (duplicate detection)
- [ ] Specimen status workflow
- [ ] Audit logging for all operations
- [ ] Role-based access control (RBAC)
- [ ] Label generation endpoints
### Infrastructure:
- [ ] Label printer setup
- [ ] Barcode scanner integration
- [ ] HIS/HL7 integration (if needed)
- [ ] Backup and disaster recovery
---
## Success Criteria
### Phase 1 Complete:
- [ ] Users can select site on login
- [ ] Password expiration enforced
- [ ] Account lockout working
- [ ] Multi-site data isolation
### Phase 2 Complete:
- [ ] Duplicate detection on registration
- [ ] Patient link/unlink functional
- [ ] Link validation (no multi-level)
### Phase 3 Complete:
- [ ] All visit operations functional
- [ ] Complete ADT history for all actions
- [ ] Doctor change tracking
### Phase 4 Complete:
- [ ] Future order scheduling
- [ ] PID-based ordering
- [ ] Non-patient order support
### Phase 5 Complete:
- [ ] Full specimen lifecycle tracking
- [ ] Label printing operational
- [ ] Specimen status history
### Phase 6 Complete:
- [ ] Cumulative reports for linked patients
- [ ] Workload statistics
- [ ] Export functionality
### Phase 7 Complete:
- [ ] User management UI
- [ ] Role configuration
- [ ] System settings
### Phase 8 Complete:
- [ ] HIS integration
- [ ] Instrument integration
- [ ] Message queue management

View File

@ -0,0 +1,266 @@
# MVP Implementation Plan - CLQMS Frontend
**Goal:** Deliver core functionality for daily laboratory operations within shortest timeframe.
**Estimated Time:** 2-3 weeks
**Priority:** Critical features for production use
---
## Phase 1: Test Ordering System (Week 1)
**Use Cases:** UC-5a, UC-5b
**Priority:** HIGHEST - Core lab functionality
### API Endpoints Needed (Backend)
```
POST /api/orders # Create new test order
GET /api/orders/:oid # Get order details
PATCH /api/orders/:oid # Update order
GET /api/orders # List/search orders
POST /api/specimens # Create specimen records
GET /api/specimens/:sid # Get specimen details
GET /api/orders/:oid/specimens # List specimens for order
```
### Frontend Implementation
#### 1.1 Create Order Management Module
**New Files:**
- `src/routes/(app)/orders/+page.svelte` - Order list/search page
- `src/routes/(app)/orders/new/+page.svelte` - New order creation
- `src/routes/(app)/orders/[oid]/+page.svelte` - Order detail/edit
- `src/lib/api/orders.js` - Order API client
- `src/lib/api/specimens.js` - Specimen API client
#### 1.2 Order List Page Features
- Search by OID, Patient ID, Patient Name
- Filter by status (SC, IP, CM, CL)
- Date range filter
- Quick actions: View, Edit, Print labels
- Table columns: OID, Patient, Date, Tests, Status
#### 1.3 New Order Page Features
- Patient/PVID selection (search existing or quick admit)
- Test selection with search
- Specimen requirements display
- Comments/attachments
- Print label options (checkboxes):
- Print Patient Label
- Print Order Label
- Print Specimen Label
- Auto-generate OID and SID on save
#### 1.4 Order Status Validation
- Prevent editing if status = CL (Closed)
- Prevent editing if status = AC (Archived)
- Prevent editing if status = DL (Deleted)
- Prevent removing tests that have results
#### 1.5 Specimen Management
- Auto-create specimen records per test
- Display SID list after order creation
- Basic specimen tracking view
---
## Phase 2: Patient Discharge (Week 1-2)
**Use Cases:** UC-04a, UC-04b
**Priority:** HIGH - Essential for visit lifecycle
### API Endpoints Needed (Backend)
```
POST /api/patvisit/:pvid/discharge # Create discharge ADT (A03)
POST /api/patvisit/:pvid/cancel-discharge # Cancel discharge (A13)
GET /api/orders/pvid/:pvid/open # Check for open orders
```
### Frontend Implementation
#### 2.1 Update Visit Management
**Modify:**
- `src/routes/(app)/patients/+page.svelte` - Add discharge actions
- `src/routes/(app)/patients/VisitFormModal.svelte` - Add discharge button
**New Files:**
- `src/routes/(app)/patients/DischargeModal.svelte` - Discharge confirmation
- `src/routes/(app)/patients/CancelDischargeModal.svelte` - Cancel discharge
#### 2.2 Discharge Features
- Discharge button on active visits
- Validation: Check for open test orders (status: A, IP, SC, HD)
- If open orders exist: Show warning, block discharge
- ADT Code: A03 (Discharge)
- Required fields: Discharge date
- After discharge: Visit marked as closed (no more edits)
#### 2.3 Cancel Discharge Features
- Available only for Supervisor Lab, Manajer Lab
- ADT Code: A13 (Cancel Discharge)
- Re-opens visit for editing
- Available in visit actions menu
---
## Phase 3: Patient Transfer (Week 2)
**Use Cases:** UC-04
**Priority:** MEDIUM-HIGH - Common daily operation
### API Endpoints Needed (Backend)
```
POST /api/patvisit/:pvid/transfer # Create transfer ADT (A02)
```
### Frontend Implementation
#### 3.1 Transfer Feature
**Modify:**
- `src/routes/(app)/patients/VisitFormModal.svelte` - Add transfer section
**New Files:**
- `src/routes/(app)/patients/TransferModal.svelte` - Location transfer
#### 3.2 Transfer Features
- Transfer button on active visits
- Location dropdown (from master data)
- ADT Code: A02 (Patient Transfer)
- Update visit LocationID
- Track location history via ADT
---
## Phase 4: Patient Link (Basic) (Week 2-3)
**Use Cases:** UC-02b
**Priority:** MEDIUM - Data quality improvement
### API Endpoints Needed (Backend)
```
GET /api/patient/search-duplicates # Find potential duplicates
POST /api/patient/link # Link patients (A24)
GET /api/patient/:pid/linked # Get linked patients
```
### Frontend Implementation
#### 4.1 Patient Link Module
**New Files:**
- `src/routes/(app)/patients/link/+page.svelte` - Patient link page
- `src/lib/api/patient-links.js` - Link API client
#### 4.2 Link Features
- Search by: Identifier (KTP/Passport), Name + DOB + Address
- Display matching patients
- Select destination (surviving) PID
- Select source PID(s) to link
- Confirmation dialog
- ADT Code: A24 (Link Patient)
- Validation: Prevent multi-level links, prevent linking already-linked sources
#### 4.3 Visual Indicator
- Show "Linked" badge on patient records
- Display linked PIDs in patient detail
---
## Navigation Updates
### Sidebar Changes
Add to Laboratory section:
```
Laboratory
├── Patients (existing)
├── Orders (NEW)
├── Specimens (NEW)
└── Patient Links (NEW)
```
---
## Database Schema Requirements (Backend)
### Orders Table
```sql
ordertest:
- OrderID (OID)
- InternalOID
- PVID
- EffDate (future orders)
- EndDate (closed)
- ArchiveDate
- DelDate
- Comments
- CreatedBy, CreatedDate
orderstatus:
- InternalOID
- OrderStatus (SC, IP, CM, CL, AC, DL)
- StatusDate
ordertestdetail:
- InternalOID
- TestSiteID
- TestSequence
```
### Specimens Table
```sql
specimens:
- SpecimenID (SID)
- InternalSID
- InternalOID
- TestSiteID
- CollectionDate
- ReceivedDate
- ContainerID
- Status
```
---
## Testing Checklist
### Test Ordering
- [ ] Create new order with multiple tests
- [ ] Search orders by patient name
- [ ] Edit pending order (SC status)
- [ ] Attempt to edit closed order (should fail)
- [ ] Print labels (UI only)
- [ ] View specimen list
### Discharge
- [ ] Discharge patient with no open orders
- [ ] Attempt discharge with open orders (should block)
- [ ] Cancel discharge (supervisor only)
- [ ] Verify visit locked after discharge
### Transfer
- [ ] Transfer patient to new location
- [ ] Verify location history updated
### Patient Link
- [ ] Search duplicates by identifier
- [ ] Link two patients
- [ ] Verify destination patient shows cumulative data
- [ ] Verify source patient marked as linked
---
## Out of Scope (MVP)
The following features are **NOT** included in MVP but documented for future phases:
- Multi-site authentication (UC-01 enhancements)
- Password expiration/lockout
- Patient Unlink (UC-02c)
- Cancel Admission (UC-03b)
- Change Attending/Consulting Doctor (UC-03c, UC-03d)
- Advanced duplicate detection with address matching
- Audit log viewing
- Specimen label printing integration
- Result entry integration
---
## Success Criteria
1. Users can create test orders linked to patients
2. Users can discharge patients (when no open orders)
3. Users can transfer patients between locations
4. Users can link duplicate patient records
5. Order status validation prevents invalid edits
6. All operations create proper ADT audit records

View File

@ -281,14 +281,24 @@ components:
items:
$ref: '#/components/schemas/PatAttEntry'
Province:
type: integer
description: Province AreaGeoID (foreign key to areageo table)
ProvinceLabel:
type: string
description: Province area code
description: Province name (resolved from areageo)
City:
type: integer
description: City AreaGeoID (foreign key to areageo table)
CityLabel:
type: string
description: City area code
description: City name (resolved from areageo)
Country:
type: string
maxLength: 100
maxLength: 10
description: Country ISO 3-letter code (e.g., IDN, USA)
CountryLabel:
type: string
description: Country name (resolved from valueset)
Race:
type: string
maxLength: 100
@ -655,6 +665,276 @@ components:
type: string
Formula:
type: string
refnum:
type: array
description: Numeric reference ranges (optional). Mutually exclusive with reftxt - a test can only have ONE reference type.
items:
type: object
properties:
RefNumID:
type: integer
NumRefType:
type: string
enum: [NMRC, THOLD]
description: NMRC=Numeric range, THOLD=Threshold
NumRefTypeLabel:
type: string
RangeType:
type: string
RangeTypeLabel:
type: string
Sex:
type: string
SexLabel:
type: string
LowSign:
type: string
LowSignLabel:
type: string
HighSign:
type: string
HighSignLabel:
type: string
High:
type: number
format: float
Low:
type: number
format: float
AgeStart:
type: integer
AgeEnd:
type: integer
Flag:
type: string
Interpretation:
type: string
reftxt:
type: array
description: Text reference ranges (optional). Mutually exclusive with refnum - a test can only have ONE reference type.
items:
type: object
properties:
RefTxtID:
type: integer
TxtRefType:
type: string
enum: [TEXT, VSET]
description: TEXT=Free text, VSET=Value set
TxtRefTypeLabel:
type: string
Sex:
type: string
SexLabel:
type: string
AgeStart:
type: integer
AgeEnd:
type: integer
RefTxt:
type: string
Flag:
type: string
examples:
TEST_simple:
summary: Technical test - no reference range
value:
id: 1
TestCode: GLU
TestName: Glucose
TestType: TEST
DisciplineID: 1
DepartmentID: 1
SpecimenType: SER
Unit: mg/dL
TEST_numeric_nmrc:
summary: Technical test - numeric reference (NMRC)
value:
id: 1
TestCode: GLU
TestName: Glucose
TestType: TEST
DisciplineID: 1
DepartmentID: 1
SpecimenType: SER
Unit: mg/dL
refnum:
- RefNumID: 1
NumRefType: NMRC
NumRefTypeLabel: Numeric
RangeType: REF
RangeTypeLabel: Reference Range
Sex: '2'
SexLabel: Male
LowSign: GE
LowSignLabel: '>=
HighSign: LE
HighSignLabel: '<='
Low: 70
High: 100
AgeStart: 18
AgeEnd: 99
Flag: N
Interpretation: Normal
TEST_numeric_thold:
summary: Technical test - threshold reference (THOLD)
value:
id: 1
TestCode: GLU
TestName: Glucose
TestType: TEST
DisciplineID: 1
DepartmentID: 1
SpecimenType: SER
Unit: mg/dL
refnum:
- RefNumID: 2
NumRefType: THOLD
NumRefTypeLabel: Threshold
RangeType: PANIC
RangeTypeLabel: Panic Range
Sex: '1'
SexLabel: Female
LowSign: LT
LowSignLabel: '<'
High: 40
AgeStart: 0
AgeEnd: 120
Flag: L
Interpretation: Critical Low
TEST_text_text:
summary: Technical test - text reference (TEXT)
value:
id: 1
TestCode: RBC_MORPH
TestName: RBC Morphology
TestType: TEST
DisciplineID: 1
DepartmentID: 1
SpecimenType: BLD
Unit: null
reftxt:
- RefTxtID: 1
TxtRefType: TEXT
TxtRefTypeLabel: Text
Sex: '2'
SexLabel: Male
AgeStart: 18
AgeEnd: 99
RefTxt: 'NORM=Normal;HYPO=Hypochromic;MACRO=Macrocytic'
Flag: N
TEST_text_vset:
summary: Technical test - text reference (VSET)
value:
id: 1
TestCode: STAGE
TestName: Disease Stage
TestType: TEST
DisciplineID: 1
DepartmentID: 1
SpecimenType: null
Unit: null
reftxt:
- RefTxtID: 2
TxtRefType: VSET
TxtRefTypeLabel: Value Set
Sex: '1'
SexLabel: Female
AgeStart: 0
AgeEnd: 120
RefTxt: 'STG1=Stage 1;STG2=Stage 2;STG3=Stage 3;STG4=Stage 4'
Flag: N
PARAM:
summary: Parameter - no reference range allowed
value:
id: 2
TestCode: GLU_FAST
TestName: Fasting Glucose
TestType: PARAM
DisciplineID: 1
DepartmentID: 1
SpecimenType: SER
Unit: mg/dL
CALC_numeric_nmrc:
summary: Calculated test - numeric reference (NMRC)
value:
id: 3
TestCode: BUN_CR_RATIO
TestName: BUN/Creatinine Ratio
TestType: CALC
DisciplineID: 1
DepartmentID: 1
SpecimenType: SER
Unit: null
Formula: "BUN / Creatinine"
refnum:
- RefNumID: 5
NumRefType: NMRC
NumRefTypeLabel: Numeric
RangeType: REF
RangeTypeLabel: Reference Range
Sex: '1'
SexLabel: Female
LowSign: GE
LowSignLabel: '>=
HighSign: LE
HighSignLabel: '<='
Low: 10
High: 20
AgeStart: 18
AgeEnd: 120
Flag: N
Interpretation: Normal
CALC_numeric_thold:
summary: Calculated test - threshold reference (THOLD)
value:
id: 3
TestCode: BUN_CR_RATIO
TestName: BUN/Creatinine Ratio
TestType: CALC
DisciplineID: 1
DepartmentID: 1
SpecimenType: SER
Unit: null
Formula: "BUN / Creatinine"
refnum:
- RefNumID: 6
NumRefType: THOLD
NumRefTypeLabel: Threshold
RangeType: PANIC
RangeTypeLabel: Panic Range
Sex: '1'
SexLabel: Female
LowSign: GT
LowSignLabel: '>'
Low: 20
AgeStart: 18
AgeEnd: 120
Flag: H
Interpretation: Elevated - possible prerenal cause
GROUP:
summary: Panel/Profile - no reference range allowed
value:
id: 4
TestCode: LIPID
TestName: Lipid Panel
TestType: GROUP
DisciplineID: 1
DepartmentID: 1
SpecimenType: SER
Unit: null
TITLE:
summary: Section header - no reference range allowed
value:
id: 5
TestCode: CHEM_HEADER
TestName: '--- CHEMISTRY ---'
TestType: TITLE
DisciplineID: 1
DepartmentID: 1
SpecimenType: null
Unit: null
TestMap:
type: object
@ -1672,52 +1952,23 @@ paths:
items:
$ref: '#/components/schemas/PatientVisit'
/api/patvisitadt:
post:
/api/patvisitadt/visit/{visitId}:
get:
tags: [Patient Visits]
summary: Create ADT visit (Admission/Discharge/Transfer)
summary: Get ADT history by visit ID
description: Retrieve the complete Admission/Discharge/Transfer history for a visit, including all locations and doctors
security:
- bearerAuth: []
requestBody:
parameters:
- name: visitId
in: path
required: true
content:
application/json:
schema:
type: object
required:
- InternalPVID
- ADTCode
properties:
InternalPVID:
type: integer
description: Internal Visit ID from patvisit table (required)
ADTCode:
type: string
enum: [A01, A02, A03, A04, A08]
description: |
A01: Admit
A02: Transfer
A03: Discharge
A04: Register
A08: Update
LocationID:
type: integer
description: Location/ward ID
AttDoc:
type: integer
description: Attending physician ContactID
RefDoc:
type: integer
description: Referring physician ContactID
AdmDoc:
type: integer
description: Admitting physician ContactID
CnsDoc:
type: integer
description: Consulting physician ContactID
description: Internal Visit ID (InternalPVID)
responses:
'201':
description: ADT visit created
'200':
description: ADT history retrieved successfully
content:
application/json:
schema:
@ -1725,17 +1976,61 @@ paths:
properties:
status:
type: string
example: success
message:
type: string
example: ADT history retrieved
data:
type: array
items:
type: object
properties:
PVADTID:
type: integer
InternalPVID:
type: integer
ADTCode:
type: string
enum: [A01, A02, A03, A04, A08]
LocationID:
type: integer
LocationName:
type: string
AttDoc:
type: integer
AttDocFirstName:
type: string
AttDocLastName:
type: string
RefDoc:
type: integer
RefDocFirstName:
type: string
RefDocLastName:
type: string
AdmDoc:
type: integer
AdmDocFirstName:
type: string
AdmDocLastName:
type: string
CnsDoc:
type: integer
CnsDocFirstName:
type: string
CnsDocLastName:
type: string
CreateDate:
type: string
format: date-time
EndDate:
type: string
format: date-time
patch:
$1
delete:
tags: [Patient Visits]
summary: Update ADT visit
summary: Delete ADT visit (soft delete)
security:
- bearerAuth: []
requestBody:
@ -1749,135 +2044,85 @@ paths:
properties:
PVADTID:
type: integer
description: ADT record ID (required)
description: ADT record ID to delete
responses:
'200':
description: ADT visit deleted successfully
/api/patvisitadt/{id}:
get:
tags: [Patient Visits]
summary: Get ADT record by ID
description: Retrieve a single ADT record by its ID, including location and doctor details
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: ADT record ID (PVADTID)
responses:
'200':
description: ADT record retrieved successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
example: ADT record retrieved
data:
type: object
properties:
PVADTID:
type: integer
InternalPVID:
type: integer
description: Internal Visit ID from patvisit table
ADTCode:
type: string
enum: [A01, A02, A03, A04, A08]
description: |
A01: Admit
A02: Transfer
A03: Discharge
A04: Register
A08: Update
LocationID:
type: integer
description: Location/ward ID
LocationName:
type: string
AttDoc:
type: integer
description: Attending physician ContactID
AttDocFirstName:
type: string
AttDocLastName:
type: string
RefDoc:
type: integer
description: Referring physician ContactID
RefDocFirstName:
type: string
RefDocLastName:
type: string
AdmDoc:
type: integer
description: Admitting physician ContactID
AdmDocFirstName:
type: string
AdmDocLastName:
type: string
CnsDoc:
type: integer
description: Consulting physician ContactID
responses:
'200':
description: ADT visit updated
content:
application/json:
schema:
type: object
properties:
status:
CnsDocFirstName:
type: string
message:
CnsDocLastName:
type: string
data:
type: object
CreateDate:
type: string
format: date-time
EndDate:
type: string
format: date-time
# ========================================
# Organization - Account Routes
# ========================================
/api/organization/account:
get:
tags: [Organization]
summary: List accounts
security:
- bearerAuth: []
responses:
'200':
description: List of accounts
content:
application/json:
schema:
type: object
properties:
status:
type: string
data:
type: array
items:
$ref: '#/components/schemas/Account'
post:
tags: [Organization]
summary: Create account
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Account'
responses:
'201':
description: Account created
patch:
tags: [Organization]
summary: Update account
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
type: integer
AccountName:
type: string
AccountCode:
type: string
AccountType:
type: string
responses:
'200':
description: Account updated
delete:
tags: [Organization]
summary: Delete account
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- id
properties:
id:
type: integer
responses:
'200':
description: Account deleted
/api/organization/account/{id}:
$2/account/{id}:
get:
tags: [Organization]
summary: Get account by ID

514
docs/use_case_260214.md Normal file
View File

@ -0,0 +1,514 @@
Here is the converted Markdown format of the Use Case document. I have structured it with headers and lists to make it easily parsable by an AI agent.
```markdown
# Use Case Document
## Use Case Authentication
| **Field** | **Description** |
| :--- | :--- |
| **Use Case ID** | UC-01 |
| **Use Case Name** | Authentication |
| **Aktor Utama** | Admin Lab / Clerk, Analis Lab, Analis Lab Senior, Super User, Phlebotomist Lab, Perawat, DSPK/Konsulen, Supervisor Lab, Manajer Lab, Database Administrator, System Administrator |
| **Aktor Sekunder** | - |
| **Tujuan** | Verifikasi identitas pengguna, yang mencoba mengakses sistem, memastikan bahwa mereka adalah orang yang mereka klaim. Bertindak sebagai mekanisme keamanan primer untuk mencegah akses tidak sah, melindungi data, dan mengurangi risiko seperti pencurian identitas dan pelanggaran keamanan. |
| **Prasyarat** | Data pengguna sudah terdefinisi dalam sistem sebagai User atau Contact. |
### Alur Utama
1. Aktor klik tombol Login.
2. System menampilkan login dialog yang terdiri dari User ID dan Password.
3. Aktor memasukkan email address sebagai User ID.
4. System memeriksa email address di table User, SiteStatus, Contact dan ContactDetail:
- Jika Aktor menggunakan email pribadi, maka System menampilkan pilihan sites dimana Aktor memiliki akses. Aktor, kemudian memilih salah satu site.
- Jika Aktor menggunakan email site, maka System langsung mengarahkan ke site yang bersangkutan.
5. Aktor memasukkan password.
6. System memeriksa kebenaran User ID dan password.
7. System memeriksa role dan menu apa saja yang bisa diakses Aktor.
8. System menampilkan halaman utama dengan menu sesuai role Aktor.
### Alur Alternatif
-
### Alur Pengecualian
* **Aktor tidak terdaftar:**
* System menampilkan pesan: “Unregistered user, please contact system administrator”.
* **Aktor ditemukan tetapi:**
* **Disabled:** System menampilkan pesan: “Disabled user, please contact system administrator”.
* **Password expired:** System menampilkan pesan: “Your password is expired, please contact system administrator”.
* **Password salah:**
* System menampilkan pesan: “Invalid login”.
* System menghitung jumlah percobaan login password yang gagal dan mencatat dalam Audit log (device dimana login attempt dilakukan, waktu).
* System menghentikan proses login untuk User ID tersebut selama x jam dan menampilkan pesan, ”Please try again in x hours or contact system administrator”.
### Kondisi Akhir
* Aktor masuk ke halaman utama dan mendapat akses menu-menu system yang sesuai.
* Audit mencatat User ID, waktu, device dimana Aktor melakukan login.
---
## Use Case Patient Registration
| **Field** | **Description** |
| :--- | :--- |
| **Use Case ID** | UC-02a |
| **Use Case Name** | Patient Registration |
| **Aktor Utama** | Admin Lab / Clerk, Analis Lab, Analis Lab Senior, Super User, Supervisor Lab, Manajer Lab |
| **Aktor Sekunder** | Pasien |
| **Tujuan** | Mencatatkan data demografi pasien baru ke oleh System |
| **Prasyarat** | 1. System beroperasi dan dapat diakses.<br>2. Petugas pendaftaran telah terautentikasi dalam System.<br>3. Pasien menunjukkan identitas yang sah (kartu identitas, atau rujukan). |
### Alur Utama
1. Aktor membuka halaman Patient Management Patient Registration.
2. Aktor memasukkan PID.
3. System memeriksa apakah PID sudah ada.
4. System meminta detail pasien (nama, tanggal lahir, jenis kelamin, informasi kontak, nomor identitas, dst).
5. Aktor memasukkan informasi demografis pasien, dengan mandatory data:
- patient.NameFirst
- patient.Gender
- patient.Birthdate
6. Jika Aktor memasukkan `patidt.IdentifierType` dan `patidt.Identifier`, maka System memeriksa apakah sudah ada record pasien yang menggunakan Identifier yang sama.
7. System membuat record baru di `patvisitadt` dengan `patvisitadt.Code: A04` (Register), mengkonfirmasi registrasi berhasil dan menampilkan ringkasan pasien.
8. Aktor memberikan konfirmasi pendaftaran kepada Pasien (misalnya, slip cetak atau ID digital barcode, QRIS, dll).
### Alur Alternatif
* **Record pasien sudah ada:**
1. Aktor memasukkan PID.
2. System mengambil record yang sudah ada dan menampilkan data di halaman Patient Management Patient Search & Update.
3. Aktor memperbarui data jika diperlukan.
4. Sistem menyimpan perubahan dan membuat record baru di `patvisitadt` dengan `patvisitadt.Code: A08` (Update patient information).
### Alur Pengecualian
* **Mandatory data tidak ada:**
* System menolak menyimpan record.
* Aktor diminta untuk melengkapi, setidaknya mandatory data.
* **Record pasien tidak ada tetapi ditemukan record yang menggunakan `patidt.IdentifierType` dan `patidt.Identifier` yang sama:**
* System menampilkan pesan Multiple IDs found”.
* System menampilkan records dengan `patidt.IdentifierType` dan `patidt.Identifier` yang sama.
* Aktor melakukan review.
* Aktor memilih salah satu dari kemungkinan berikut:
1. Melanjutkan membuat record pasien baru, mengabaikan record ganda.
2. Melanjutkan membuat record pasien baru, kemudian menggabungkan record pasien (lihat UC-02b).
3. Membatalkan pendaftaran.
### Kondisi Akhir
* Record pasien dibuat atau diperbarui di System.
* PID pasien tersedia untuk test ordering & tracing.
* Audit mencatat bahwa record dibuat/diperbarui secara manual, User ID yang mendaftarkan/memperbarui data pasien, device dimana, kapan, dan data apa yang dimasukkan.
---
## Use Case Patient Link
| **Field** | **Description** |
| :--- | :--- |
| **Use Case ID** | UC-02b |
| **Use Case Name** | Patient Link |
| **Aktor Utama** | Supervisor Lab, Manajer Lab |
| **Aktor Sekunder** | Pasien |
| **Tujuan** | Link (menghubungkan) satu atau beberapa record (PID) pasien (source) dengan record pasien lainnya (destination). PatientID destination adalah surviving entity, yang akan digunakan dalam semua aktivitas laboratorium. |
| **Prasyarat** | 1. System beroperasi dan dapat diakses.<br>2. Petugas pendaftaran telah terautentikasi dalam System.<br>3. PID source dan destination telah tercatat dalam System.<br>4. PID source dan destination memiliki `patidt.IdentifierType` dan `patidt.Identifier` yang sama atau nama, alamat dan tanggal lahir yang sama. |
### Alur Utama
1. Aktor membuka halaman Patient Management - Patient Link.
2. Aktor mencari PID menggunakan:
- `patidt.IdentifierType` dan `patidt.Identifier`
- Nama, alamat dan tanggal lahir
3. System menampilkan semua PID dengan `patidt.IdentifierType` dan `patidt.Identifier` dan/atau nama, alamat dan tanggal lahir yang sama.
4. Aktor memilih dan menentukan satu PID untuk menjadi destination. (Lihat Alur Pengecualian).
5. Aktor memilih dan menentukan satu atau lebih, PID yang menjadi source. (Lihat Alur Pengecualian).
6. Aktor menghubungkan PID-PID tersebut dengan menekan tombol Link.
7. System meminta konfirmasi dari Aktor dengan menampilkan pesan,” Please confirm to link these patient records”. Disertai pilihan “Confirm” dan “Cancel”.
8. Aktor mengkonfirmasi Patient Link dengan menekan tombol Confirm.
9. System melakukan:
- Membuat record baru di `patvisitadt` dengan `patvisitadt.Code: A24` (Link Patient Information).
- Mengkonfirmasi Patient Link berhasil.
- Menampilkan ringkasan PID yang dihubungkan.
### Alur Alternatif
-
### Alur Pengecualian
* **System menemukan bahwa suatu record pasien telah menjadi source, ditandai dengan field `patient.LinkTo` telah terisi dengan PID dari record lain (ditampilkan):**
* **Multiple link:**
* Aktor memilih dan menunjuk record tersebut sebagai source bagi PID yang berbeda.
* System menampilkan peringatan “Multiple link” di samping PID tersebut dan pilihan (check mark) tidak bisa dilakukan.
* **Multi-level Link:**
* Aktor memilih dan menunjuk record tersebut sebagai destination bagi PID yang berbeda.
* System menampilkan peringatan “Multi-level link” di samping PID tersebut dan pilihan (check mark) tidak bisa dilakukan.
* Jika semua atau satu-satunya PID mendapat peringatan tersebut maka, proses Patient Link sama sekali tidak bisa dilanjutkan.
* Jika ada PID lain yang tidak mendapat peringatan, maka proses Patient Link dilanjutkan atas PID tanpa peringatan.
### Kondisi Akhir
* PID source terhubung dengan PID destination.
* Relasi source dengan test order dan lain-lain tidak berubah sebelum dan sesudah proses Patient Link.
* Semua test order milik PID source dan destination bisa ditampilkan dalam satu cumulative view/report.
* PID destination tersedia untuk test ordering & tracing.
* PID source tetap bisa dicari tetapi tidak bisa di-edit maupun digunakan untuk test ordering.
* Audit mencatat Patient Link dilakukan secara manual, waktu, User ID yang melakukan Patient Link serta device dimana aktivitas tersebut dilakukan.
---
## Use Case Patient Unlink
| **Field** | **Description** |
| :--- | :--- |
| **Use Case ID** | UC-02c |
| **Use Case Name** | Patient Unlink |
| **Aktor Utama** | Supervisor Lab, Manajer Lab |
| **Aktor Sekunder** | Pasien |
| **Tujuan** | Melepaskan link antara source PID dan destination PID. |
| **Prasyarat** | 1. System beroperasi dan dapat diakses.<br>2. Petugas pendaftaran telah terautentikasi dalam System.<br>3. Pasien sudah pernah registrasi di System, ditandai dengan adanya PID dengan `patidt.IdentifierType` dan `patidt.Identifier` yang sama. |
### Alur Utama
1. Aktor membuka halaman Patient Management Patient Unlink.
2. Aktor mencari record pasien menggunakan PID.
3. System menampilkan PID berikut data demografinya dan semua linked PID.
4. Aktor uncheck source PID(s) yang hendak dilepaskan dari destination PID.
5. Aktor melepas hubungan PID-PID tersebut dengan menekan tombol Unlink.
6. System meminta konfirmasi dari Aktor dengan menampilkan pesan,” Please confirm to unlink these patient records”. Disertai pilihan “Confirm” dan “Cancel”.
7. Aktor mengkonfirmasi Patient Unink dengan menekan tombol Confirm.
8. System melakukan:
- Mengosongkan field `patient.LinkTo` dari source PID.
- Membuat record baru di `patvisitadt` dengan `patvisitadt.Code: A37` (Unlink Patient Information).
- Mengkonfirmasi Patient Unlink berhasil.
- Menampilkan ringkasan destination dan source PID yang unlinked.
### Alur Alternatif
-
### Alur Pengecualian
-
### Kondisi Akhir
* Source PID aktif kembali, bisa diedit dan tersedia untuk test ordering & tracing.
* Unlink source terjadi bisa isi field LinkTo dikosongkan Kembali.
* Audit mencatat Patient Unlink dilakukan secara manual, waktu, User ID yang melakukan Patient Unlink dan device dimana aktivitas tersebut dilakukan.
---
## Use Case Patient Admission
| **Field** | **Description** |
| :--- | :--- |
| **Use Case ID** | UC-03a |
| **Use Case Name** | Patient Admission |
| **Aktor Utama** | Admin Lab / Clerk, Analis Lab, Analis Lab Senior, Super User, Supervisor Lab, Manajer Lab |
| **Aktor Sekunder** | Pasien |
| **Tujuan** | Menerima pasien di fasyankes untuk perawatan atau observasi. |
| **Prasyarat** | 1. System beroperasi dan dapat diakses.<br>2. Petugas pendaftaran telah terautentikasi oleh System.<br>3. Record pasien tersedia di System, ditandai dengan adanya PID. |
### Alur Utama
1. Aktor membuka halaman Patient Visit Management Patient Admission.
2. Aktor memasukkan PID.
3. System memeriksa apakah PID ada.
4. System menampilkan data demografi pasien dan meminta data-data:
- **Mandatory data:** PVID, dokter, location.
- **Optional data:** EpisodeID, diagnosis (bisa lebih dari satu), lampiran-lampiran.
5. System membuat record baru di `patvisitadt` dengan `patvisitadt.Code: A01` (Admit), mengkonfirmasi admission berhasil dan menampilkan ringkasan admission.
6. Aktor memberikan konfirmasi admission kepada Pasien (misalnya, slip cetak atau ID digital barcode, QRIS, dll).
### Alur Alternatif
* **PID tidak ada:**
1. System menampilkan pesan, “PID does not exist. Proceed to Patient Registration?”.
2. Aktor memilih “Yes” dan System membuka halaman Patient Management Patient Registration.
3. Aktor melakukan activity patient registration dilanjutkan patient admission.
* **Pembaruan optional data:**
1. Aktor membuka halaman Patient Visit Management Admission Search & Update.
2. Aktor memasukkan PVID.
3. System mengambil record yang sudah ada.
4. Aktor memperbarui data jika diperlukan.
5. Sistem menyimpan perubahan.
### Alur Pengecualian
* **Mandatory data tidak ada:**
* System menolak menyimpan record.
* Aktor diminta untuk melengkapi, setidaknya mandatory data.
### Kondisi Akhir
* Kunjungan pasien ke fasyankes tercatat (patvisit records) di System, ditandai dengan adanya PVID dan direlasikan dengan dokter dan ruangan di fasyankes.
* Audit mencatat admission/perubahannya dilakukan secara manual, User ID yang melakukan, device dimana, kapan, dan data apa saja yang dimasukkan.
---
## Use Case Cancel Admission
| **Field** | **Description** |
| :--- | :--- |
| **Use Case ID** | UC-03b |
| **Use Case Name** | Cancel Patient Admission |
| **Aktor Utama** | Supervisor Lab, Manajer Lab |
| **Aktor Sekunder** | Pasien |
| **Tujuan** | Membatalkan penerimaan pasien di fasyankes. Pembatalan bisa disebabkan: Data registrasi salah atau tidak lengkap, Cakupan asuransi tidak valid, Pasien menolak rawat inap, Pasien dialihkan, Permintaan rawat inap salah, Kondisi pasien berubah, Permintaan pasien, dll. |
| **Prasyarat** | 1. System beroperasi dan dapat diakses.<br>2. Petugas pendaftaran telah terautentikasi oleh System.<br>3. Patient Visit Record tersedia di System, ditandai dengan adanya PVID. |
### Alur Utama
1. Aktor membuka halaman Patient Visit Management Admission Search & Update.
2. Aktor memasukkan PVID.
3. System menampilkan data admission pasien.
4. Aktor mengkonfirmasi pembatalan admission ke pihak terkait dan melakukan pembatalan.
5. System membuat record baru di `patvisitadt` dengan `patvisitadt.Code: A11` (Cancel Admit), mengkonfirmasi cancel patient admission berhasil dan menampilkan ringkasan cancel patient admission.
6. Aktor memberikan konfirmasi cancel patient admission kepada pihak terkait (misalnya, slip cetak atau ID digital barcode, QRIS, dll).
### Alur Alternatif
-
### Alur Pengecualian
-
### Kondisi Akhir
* Cancel patient admission tercatat (patvisit records) di System, ditandai dengan record di `patvisitadt` dengan `patvisitadt.Code: A11`.
* Audit mencatat cancel patient admission dilakukan secara manual, User ID yang melakukan, device dimana, kapan, dan data apa yang dimasukkan.
---
## Use Case Change Attending Doctor
| **Field** | **Description** |
| :--- | :--- |
| **Use Case ID** | UC-03c |
| **Use Case Name** | Change Attending Doctor |
| **Aktor Utama** | Admin Lab / Clerk, Analis Lab, Analis Lab Senior, Super User, Supervisor Lab, Manajer Lab |
| **Aktor Sekunder** | Pasien |
| **Tujuan** | Mengganti dokter yang bertanggung jawab atas pengobatan pasien (DPJP). |
| **Prasyarat** | 1. System beroperasi dan dapat diakses.<br>2. Petugas pendaftaran telah terautentikasi oleh System.<br>3. Patient Visit Record tersedia di System, ditandai dengan adanya PVID dan telah memiliki data Attending Doctor (`patvisitadt.AttDoc`). |
### Alur Utama
1. Aktor membuka halaman Patient Visit Management Admission Search & Update.
2. Aktor memasukkan PVID.
3. System menampilkan data admission pasien.
4. Aktor mengganti Attending Doctor.
5. System membuat record baru di `patvisitadt` dengan `patvisitadt.Code: A54` (Change Attending Doctor), mengkonfirmasi penggantian dokter berhasil dan menampilkan data admission yang telah diperbarui.
### Alur Alternatif
-
### Alur Pengecualian
-
### Kondisi Akhir
* Penggantian Attending Doctor di System sehingga bisa dilakukan pelacakan Attending Doctor sekarang dan sebelumnya.
* Audit mencatat User ID yang melakukan perubahan Attending Doctor, device dimana perubahan dilakukan, kapan.
---
## Use Case Change Consulting Doctor
| **Field** | **Description** |
| :--- | :--- |
| **Use Case ID** | UC-03d |
| **Use Case Name** | Change Consulting Doctor |
| **Aktor Utama** | Admin Lab / Clerk, Analis Lab, Analis Lab Senior, Super User, Supervisor Lab, Manajer Lab |
| **Aktor Sekunder** | Pasien |
| **Tujuan** | Mengganti dokter konsulen. |
| **Prasyarat** | 1. System beroperasi dan dapat diakses.<br>2. Petugas pendaftaran telah terautentikasi oleh System.<br>3. Patient Visit Record tersedia di System, ditandai dengan adanya PVID dan telah memiliki data Consulting Doctor (`patvisitadt.CnsDoc`). |
### Alur Utama
1. Aktor membuka halaman Patient Visit Management Admission Search & Update.
2. Aktor memasukkan PVID.
3. System menampilkan data admission pasien.
4. Aktor mengganti Consulting Doctor.
5. System membuat record baru di `patvisitadt` dengan `patvisitadt.Code: A61` (Change Consulting Doctor), mengkonfirmasi penggantian dokter berhasil dan menampilkan data admission yang telah diperbarui.
### Alur Alternatif
-
### Alur Pengecualian
-
### Kondisi Akhir
* Penggantian Consulting Doctor di System sehingga bisa dilakukan pelacakan Consulting Doctor sekarang dan sebelumnya.
* Audit mencatat User ID yang melakukan perubahan Consulting Doctor, device dimana perubahan dilakukan, kapan.
---
## Use Case Patient Transfer
| **Field** | **Description** |
| :--- | :--- |
| **Use Case ID** | UC-04 |
| **Use Case Name** | Patient Transfer |
| **Aktor Utama** | Admin Lab / Clerk, Analis Lab, Analis Lab Senior, Super User, Supervisor Lab, Manajer Lab |
| **Aktor Sekunder** | Pasien |
| **Tujuan** | Memindahkan pasien dari satu lokasi ke lokasi lainnya. |
| **Prasyarat** | 1. System beroperasi dan dapat diakses.<br>2. Petugas pendaftaran telah terautentikasi oleh System.<br>3. Patient Visit Record tersedia di System, ditandai dengan adanya PVID dan telah memiliki data Location ID (`patvisitadt.LocationID`). |
### Alur Utama
1. Aktor membuka halaman Patient Visit Management Transfer.
2. Aktor memasukkan PVID.
3. System menampilkan data admission pasien.
4. Aktor mengganti Location ID.
5. System membuat record baru di `patvisitadt` dengan `patvisitadt.Code: A02` (Patient Transfer), mengkonfirmasi perpindahan lokasi berhasil dan menampilkan data admission yang telah diperbarui.
### Alur Alternatif
-
### Alur Pengecualian
-
### Kondisi Akhir
* Penggantian Location ID di System sehingga bisa dilakukan pelacakan Location ID sekarang dan sebelumnya.
* Audit mencatat User ID yang melakukan perubahan Location ID, device dimana perubahan dilakukan dan kapan.
---
## Use Case Patient Discharge
| **Field** | **Description** |
| :--- | :--- |
| **Use Case ID** | UC-04a |
| **Use Case Name** | Patient Discharge |
| **Aktor Utama** | Admin Lab / Clerk, Analis Lab, Analis Lab Senior, Super User, Supervisor Lab, Manajer Lab |
| **Aktor Sekunder** | Pasien |
| **Tujuan** | Mengakhiri kunjungan pasien. Close billing. |
| **Prasyarat** | 1. System beroperasi dan dapat diakses.<br>2. Petugas pendaftaran telah terautentikasi oleh System.<br>3. Patient Visit Record tersedia di System, ditandai dengan adanya PVID. |
### Alur Utama
1. Aktor membuka halaman Patient Visit Management Discharge.
2. Aktor memasukkan PVID.
3. System memeriksa apakah PVID tersebut memiliki test order.
4. System memeriksa `orderstatus.OrderStatus` dari test order tsb.
5. System menampilkan data admission pasien.
6. Aktor mengisikan tanggal discharge.
7. System membuat record baru di `patvisitadt` dengan `patvisitadt.Code: A03` (Discharge), mengkonfirmasi discharge/end visit berhasil dan menampilkan data admission yang telah di-discharge.
### Alur Alternatif
-
### Alur Pengecualian
* **Open test order:**
* System menolak discharge, jika menemukan `orderstatus.OrderStatus` bernilai ”A” atau “IP” atau “SC” atau “HD”.
* Aktor diminta untuk menyelesaikan test order terkait.
### Kondisi Akhir
* Discharge visit di System.
* Audit mencatat User ID yang melakukan discharge, device dimana discharge dilakukan dan kapan.
* Semua record terkait visit tersebut tidak bisa diedit/update lagi data-data pada `patvisit`, `patdiag`, `patvisitbill`. Hal-hal berikut tidak bisa dilakukan lagi:
- Perpindahan lokasi dan/atau dokter.
- Test order.
- Billing is closed.
* **Cancel discharge:**
- Bisa dilakukan atas instruksi dari HIS, misalnya berupa ADT message.
- Oleh orang tertentu saja di lab.
- Tidak meng-update existing record tetapi men-trigger tambahan `patvisitadt` record dengan Code: A13 (cancel discharge).
---
## Use Case Cancel Discharge
| **Field** | **Description** |
| :--- | :--- |
| **Use Case ID** | UC-04b |
| **Use Case Name** | Cancel Patient Discharge |
| **Aktor Utama** | Supervisor Lab, Manajer Lab |
| **Aktor Sekunder** | Pasien |
| **Tujuan** | Membatalkan Patient Discharge. Open billing. |
| **Prasyarat** | 1. System beroperasi dan dapat diakses.<br>2. Petugas pendaftaran telah terautentikasi oleh System.<br>3. Patient Visit Record tersedia di System, ditandai dengan adanya PVID dan telah discharge. |
### Alur Utama
1. Aktor membuka halaman Patient Visit Management Cancel Patient Discharge.
2. Aktor memasukkan PVID.
3. System menampilkan data admission pasien.
4. Aktor membatalkan discharge dengan menekan tombol Cancel Discharge.
5. System membuat record baru di `patvisitadt` dengan `patvisitadt.Code: A13` (Cancel Discharge), mengkonfirmasi cancel discharge berhasil dan menampilkan data admission yang telah dibatalkan discharge-nya.
### Alur Alternatif
-
### Alur Pengecualian
-
### Kondisi Akhir
* Pembatalan discharge di System.
* Audit mencatat cancel discharge dilakukan secara manual, User ID yang melakukan, device dimana activity dilakukan dan kapan.
* Semua record terkait visit tersebut kembali bisa diedit/update lagi data-data pada `patvisit`, `patdiag`, `patvisitbill`. Hal-hal berikut bisa dilakukan lagi:
- Perpindahan lokasi dan/atau dokter.
- Test order.
- Billing is re-open.
---
## Use Case Test Ordering
| **Field** | **Description** |
| :--- | :--- |
| **Use Case ID** | UC-5a |
| **Use Case Name** | Test Ordering |
| **Aktor Utama** | Admin Lab / Clerk, Analis Lab, Analis Lab Senior, Super User, Supervisor Lab, Manajer Lab |
| **Aktor Sekunder** | Pasien |
| **Tujuan** | Membuat test order untuk pasien. |
| **Prasyarat** | 1. System beroperasi dan dapat diakses.<br>2. Petugas pendaftaran telah terautentikasi oleh System.<br>3. Patient Visit Record tersedia di System, ditandai dengan adanya PVID. |
### Alur Utama
1. Aktor membuka halaman Test Ordering New Test Order.
2. Aktor memasukkan PVID.
3. System menampilkan data demografi, daftar PVID pasien berikut daftar OrderID (OID) yang telah dibuat sebelumnya (menghindari test order berlebihan).
4. Aktor bisa menambahkan komentar dan/atau lampiran ke test order.
5. Aktor memilih test yang diperlukan.
6. Aktor bisa memilih mencetak labels segera setelah klik tombol Save, dengan mencentang Print Patient Label, Print Order Label, Print Specimen Label check boxes.
7. Aktor menyimpan test order dengan menekan tombol Save.
8. System secara otomatis memberi OID.
9. System otomatis membuat records di table specimens.
10. System, mengkonfirmasi test ordering berhasil dan menampilkan data test order berikut daftar SID.
### Alur Alternatif
* **PVID belum ada:**
1. System mengarahkan Aktor ke halaman Patient Visit Management Patient Admission.
2. Aktor melakukan activity patient admission.
3. Aktir kembali ke test ordering.
* **Test ordering menggunakan PID:**
1. Aktor membuka halaman Test Ordering New Test Order.
2. Aktor memasukkan PID.
3. System menampilkan daftar PVID yang belum discharge.
4. Aktor memilih salah satu PVID dan melanjutkan activity test ordering.
* **Future Order:**
1. Aktor mengisi Effective Date (`ordertest.EffDate`) untuk menjadwalkan kapan test order mulai dikerjakan.
2. Aktor menyimpan test order dengan menekan tombol Save.
3. System memberikan OID yang sesuai dengan Effective Date.
* **OID sudah ada:**
1. Aktor membuka halaman Test Ordering Test Order Search & Update.
2. Aktor memasukan OID.
3. System menampilkan data-data test order.
4. Aktor melakukan update dan menyimpannya.
* **Non patient option** (Not detailed in text).
### Alur Pengecualian
-
### Kondisi Akhir
* Test order terbentuk di System ditandai dengan adanya OID dengan status (`orderstatus.OrderStatus`) ”SC” (In process, scheduled).
* SID terbentuk dan specimen label bisa dicetak atau tercetak otomatis.
* Audit mencatat test order dilakukan secara manual, User ID yang melakukan, device dimana activity dilakukan dan kapan.
---
## Use Case Update Test Order
| **Field** | **Description** |
| :--- | :--- |
| **Use Case ID** | UC-5b |
| **Use Case Name** | Update Test Order |
| **Aktor Utama** | Admin Lab / Clerk, Analis Lab, Analis Lab Senior, Super User, Supervisor Lab, Manajer Lab |
| **Aktor Sekunder** | Pasien |
| **Tujuan** | Memperbarui test order untuk pasien. |
| **Prasyarat** | 1. System beroperasi dan dapat diakses.<br>2. Petugas pendaftaran telah terautentikasi oleh System.<br>3. Test order tersedia di System, ditandai dengan adanya OID. |
### Alur Utama
1. Aktor membuka halaman Test Ordering Test Order Search & Update.
2. Aktor memasukkan OID.
3. System menampilkan data-data test order.
4. Aktor melakukan update dan menyimpannya.
5. System, mengkonfirmasi update test order berhasil dan menampilkan data test order berikut daftar SID.
### Alur Alternatif
* **PVID belum ada:**
1. System mengarahkan Aktor ke halaman Patient Visit Management Patient Admission.
2. Aktor melakukan activity patient admission.
3. Aktir kembali ke test ordering.
* **Non patient option** (Not detailed in text).
### Alur Pengecualian
* **Test order tidak ada:**
* System menolak melakukan update dan menampilkan pesan,” Test order does not exists”.
* **Test order berstatus closed:** Ditandai dengan `ordertest.EndDate` memiliki value dan `orderstatus.OrderStatus` bernilai CL (Closed).
* System menolak melakukan update dan menampilkan pesan,” This test order is inaccessible”.
* **Test order berstatus archived:** Ditandai dengan `ordertest.ArchiveDate` memiliki value dan `orderstatus.OrderStatus` bernilai AC (Archived).
* System menolak melakukan update dan menampilkan pesan,” This test order is already archived”.
* **Test order berstatus deleted:** Ditandai dengan `ordertest.DelDate` memiliki value dan `orderstatus.OrderStatus` bernilai DL (Deleted).
* System menolak melakukan update dan menampilkan pesan,” This test order is already deleted”.
* **Update dilakukan dengan menghapus test yang telah ada hasilnya:**
* System menampilkan data-data test order.
* Aktor mengganti test yang telah ada hasilnya.
* System menolak melakukan update dan menampilkan pesan,” This test order is inaccessible”.
### Kondisi Akhir
* Test order terbentuk di System ditandai dengan adanya OID dengan status (`orderstatus.OrderStatus`) ”SC” (In process, scheduled).
* SID terbentuk dan specimen label bisa dicetak atau tercetak otomatis.
* Audit mencatat test order dilakukan secara manual, User ID yang melakukan, device dimana activity dilakukan dan kapan.
```

View File

@ -1,8 +1,85 @@
@import 'tailwindcss';
@plugin 'daisyui' {
themes: light --default, emerald, forest, dark;
@plugin 'daisyui';
@plugin 'daisyui/theme' {
name: 'clqms';
default: true;
prefersdark: false;
color-scheme: light;
--color-base-100: oklch(98% 0.01 240);
--color-base-200: oklch(96% 0.015 240);
--color-base-300: oklch(93% 0.02 240);
--color-base-content: oklch(25% 0.04 240);
/* Primary: Emerald Green */
--color-primary: oklch(60% 0.16 150);
--color-primary-content: oklch(98% 0.01 150);
/* Secondary: Dark Blue */
--color-secondary: oklch(35% 0.08 250);
--color-secondary-content: oklch(98% 0.01 250);
/* Accent: Royal Blue */
--color-accent: oklch(55% 0.22 250);
--color-accent-content: oklch(98% 0.01 250);
--color-neutral: oklch(50% 0.05 240);
--color-neutral-content: oklch(98% 0.01 240);
/* Semantic Colors */
--color-info: oklch(70% 0.15 230);
--color-info-content: oklch(25% 0.04 230);
--color-success: oklch(65% 0.18 140);
--color-success-content: oklch(98% 0.01 140);
--color-warning: oklch(80% 0.16 85);
--color-warning-content: oklch(25% 0.04 85);
--color-error: oklch(60% 0.25 25);
--color-error-content: oklch(98% 0.01 25);
/* Border radius */
--radius-selector: 0.5rem;
--radius-field: 0.375rem;
--radius-box: 0.5rem;
/* Base sizes */
--size-selector: 0.25rem;
--size-field: 0.25rem;
/* Border size */
--border: 1px;
/* Effects */
--depth: 1;
--noise: 0;
}
@theme {
/* Custom theme variables can be added here */
/* Custom color helpers */
--color-emerald-50: #ecfdf5;
--color-emerald-100: #d1fae5;
--color-emerald-200: #a7f3d0;
--color-emerald-300: #6ee7b7;
--color-emerald-400: #34d399;
--color-emerald-500: #10b981;
--color-emerald-600: #059669;
--color-emerald-700: #047857;
--color-emerald-800: #065f46;
--color-emerald-900: #064e3b;
--color-blue-50: #eff6ff;
--color-blue-100: #dbeafe;
--color-blue-200: #bfdbfe;
--color-blue-300: #93c5fd;
--color-blue-400: #60a5fa;
--color-blue-500: #3b82f6;
--color-blue-600: #2563eb;
--color-blue-700: #1d4ed8;
--color-blue-800: #1e40af;
--color-blue-900: #1e3a8a;
--color-navy-600: #1e3a5f;
--color-navy-700: #1a2f4a;
--color-navy-800: #0f1f33;
--color-navy-900: #0a1628;
}

View File

@ -1,5 +1,5 @@
<!doctype html>
<html lang="en" data-theme="emerald">
<html lang="en" data-theme="clqms">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

15
src/lib/api/specimens.js Normal file
View File

@ -0,0 +1,15 @@
import { get } from './client.js';
export async function fetchSpecimenTypes(params = {}) {
const query = new URLSearchParams(params).toString();
return get(query ? `/api/specimen/type?${query}` : '/api/specimen/type');
}
export async function fetchSpecimenType(id) {
return get(`/api/specimen/type/${id}`);
}
export async function fetchSpecimenCollections(params = {}) {
const query = new URLSearchParams(params).toString();
return get(query ? `/api/specimen/collection?${query}` : '/api/specimen/collection');
}

View File

@ -20,6 +20,13 @@ export async function createTest(data) {
SeqRpt: data.SeqRpt,
VisibleScr: data.VisibleScr ? '1' : '0',
VisibleRpt: data.VisibleRpt ? '1' : '0',
// Type-specific fields
SpecimenType: data.SpecimenType,
Unit: data.Unit,
Formula: data.Formula,
// Reference ranges (only for TEST and CALC)
refnum: data.refnum,
reftxt: data.reftxt,
};
return post('/api/tests', payload);
}
@ -36,6 +43,21 @@ export async function updateTest(data) {
SeqRpt: data.SeqRpt,
VisibleScr: data.VisibleScr ? '1' : '0',
VisibleRpt: data.VisibleRpt ? '1' : '0',
// Type-specific fields
SpecimenType: data.SpecimenType,
Unit: data.Unit,
Formula: data.Formula,
// Reference ranges (only for TEST and CALC)
refnum: data.refnum,
reftxt: data.reftxt,
};
return patch('/api/tests', payload);
}
export async function deleteTest(id) {
// Soft delete - set IsActive to '0'
return patch('/api/tests', {
TestSiteID: id,
IsActive: '0',
});
}

View File

@ -32,3 +32,7 @@ export async function createADT(data) {
export async function updateADT(data) {
return patch('/api/patvisitadt', data);
}
export async function fetchVisitADTHistory(visitId) {
return get(`/api/patvisitadt/visit/${encodeURIComponent(visitId)}`);
}

View File

@ -0,0 +1,45 @@
<script>
import { Info, HelpCircle } from 'lucide-svelte';
/**
* @typedef {Object} Props
* @property {string} text - Help text to display
* @property {string} [title] - Optional title for the tooltip
* @property {'info' | 'help'} [icon] - Icon type to display
* @property {'top' | 'bottom' | 'left' | 'right'} [position] - Tooltip position
* @property {string} [class] - Additional CSS classes
*/
/** @type {Props} */
let {
text,
title = '',
icon = 'help',
position = 'top',
class: className = '',
} = $props();
const positionClasses = {
top: 'tooltip-top',
bottom: 'tooltip-bottom',
left: 'tooltip-left',
right: 'tooltip-right',
};
let IconComponent = $derived(icon === 'info' ? Info : HelpCircle);
</script>
<div
class="tooltip {positionClasses[position]} {className}"
data-tip={text}
role="tooltip"
>
<button
type="button"
class="btn btn-xs btn-ghost btn-circle"
aria-label={title || 'Help'}
tabindex="-1"
>
<IconComponent class="w-4 h-4 opacity-60" />
</button>
</div>

View File

@ -97,16 +97,16 @@
<!-- Navigation Menu -->
<ul class="menu w-full gap-1" class:menu-collapsed={!isOpen}>
{#if isOpen}
<li class="menu-title uppercase font-bold text-xs text-emerald-600/70 mt-2">Main</li>
<li class="menu-title uppercase font-bold text-xs text-secondary/70 mt-2">Main</li>
{/if}
<li>
<a
href="/dashboard"
class="menu-item text-gray-700 hover:bg-emerald-50 hover:text-emerald-700"
class="menu-item text-gray-700 hover:bg-secondary/10 hover:text-secondary-focus"
class:centered={!isOpen}
title={!isOpen ? 'Dashboard' : ''}
>
<LayoutDashboard class="w-5 h-5 text-emerald-600 flex-shrink-0" />
<LayoutDashboard class="w-5 h-5 text-secondary flex-shrink-0" />
{#if isOpen}
<span class="menu-text whitespace-nowrap overflow-hidden">Dashboard</span>
{/if}
@ -115,12 +115,12 @@
<li class="collapsible-section">
<button
onclick={toggleMasterData}
class="menu-item text-gray-700 hover:bg-emerald-50 hover:text-emerald-700 w-full text-left justify-between"
class="menu-item text-gray-700 hover:bg-secondary/10 hover:text-secondary-focus w-full text-left justify-between"
class:centered={!isOpen}
title={!isOpen ? 'Master Data' : ''}
>
<div class="flex items-center gap-2">
<Database class="w-5 h-5 text-emerald-600 flex-shrink-0" />
<Database class="w-5 h-5 text-secondary flex-shrink-0" />
{#if isOpen}
<span class="menu-text whitespace-nowrap overflow-hidden">Master Data</span>
{/if}
@ -134,7 +134,7 @@
<li>
<a
href="/master-data/containers"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-emerald-50 hover:text-emerald-700"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<FlaskConical class="w-4 h-4 flex-shrink-0" />
<span>Containers</span>
@ -143,7 +143,7 @@
<li>
<a
href="/master-data/tests"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-emerald-50 hover:text-emerald-700"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<TestTube class="w-4 h-4 flex-shrink-0" />
<span>Test Definitions</span>
@ -152,7 +152,7 @@
<li>
<a
href="/master-data/valuesets"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-emerald-50 hover:text-emerald-700"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<List class="w-4 h-4 flex-shrink-0" />
<span>ValueSets</span>
@ -161,7 +161,7 @@
<li>
<a
href="/master-data/locations"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-emerald-50 hover:text-emerald-700"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<MapPin class="w-4 h-4 flex-shrink-0" />
<span>Locations</span>
@ -170,7 +170,7 @@
<li>
<a
href="/master-data/contacts"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-emerald-50 hover:text-emerald-700"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<Users class="w-4 h-4 flex-shrink-0" />
<span>Contacts</span>
@ -179,7 +179,7 @@
<li>
<a
href="/master-data/specialties"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-emerald-50 hover:text-emerald-700"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<Stethoscope class="w-4 h-4 flex-shrink-0" />
<span>Specialties</span>
@ -188,7 +188,7 @@
<li>
<a
href="/master-data/occupations"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-emerald-50 hover:text-emerald-700"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<Briefcase class="w-4 h-4 flex-shrink-0" />
<span>Occupations</span>
@ -197,7 +197,7 @@
<li>
<a
href="/master-data/counters"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-emerald-50 hover:text-emerald-700"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<Hash class="w-4 h-4 flex-shrink-0" />
<span>Counters</span>
@ -206,7 +206,7 @@
<li>
<a
href="/master-data/geography"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-emerald-50 hover:text-emerald-700"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<Globe class="w-4 h-4 flex-shrink-0" />
<span>Geography</span>
@ -218,11 +218,11 @@
<li>
<a
href="/result-entry"
class="menu-item text-gray-700 hover:bg-emerald-50 hover:text-emerald-700"
class="menu-item text-gray-700 hover:bg-secondary/10 hover:text-secondary-focus"
class:centered={!isOpen}
title={!isOpen ? 'Result Entry' : ''}
>
<FileText class="w-5 h-5 text-emerald-600 flex-shrink-0" />
<FileText class="w-5 h-5 text-secondary flex-shrink-0" />
{#if isOpen}
<span class="menu-text whitespace-nowrap overflow-hidden">Result Entry</span>
{/if}
@ -231,11 +231,11 @@
<li>
<a
href="/reports"
class="menu-item text-gray-700 hover:bg-emerald-50 hover:text-emerald-700"
class="menu-item text-gray-700 hover:bg-secondary/10 hover:text-secondary-focus"
class:centered={!isOpen}
title={!isOpen ? 'Reports' : ''}
>
<Printer class="w-5 h-5 text-emerald-600 flex-shrink-0" />
<Printer class="w-5 h-5 text-secondary flex-shrink-0" />
{#if isOpen}
<span class="menu-text whitespace-nowrap overflow-hidden">Reports</span>
{/if}
@ -245,12 +245,12 @@
<li class="collapsible-section">
<button
onclick={toggleLaboratory}
class="menu-item text-gray-700 hover:bg-emerald-50 hover:text-emerald-700 w-full text-left justify-between"
class="menu-item text-gray-700 hover:bg-secondary/10 hover:text-secondary-focus w-full text-left justify-between"
class:centered={!isOpen}
title={!isOpen ? 'Laboratory' : ''}
>
<div class="flex items-center gap-2">
<FlaskConical class="w-5 h-5 text-emerald-600 flex-shrink-0" />
<FlaskConical class="w-5 h-5 text-secondary flex-shrink-0" />
{#if isOpen}
<span class="menu-text whitespace-nowrap overflow-hidden">Laboratory</span>
{/if}
@ -264,7 +264,7 @@
<li>
<a
href="/patients"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-emerald-50 hover:text-emerald-700"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<Users class="w-4 h-4 flex-shrink-0" />
<span>Patients</span>
@ -273,7 +273,7 @@
<li>
<a
href="/orders"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-emerald-50 hover:text-emerald-700"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<ClipboardList class="w-4 h-4 flex-shrink-0" />
<span>Orders</span>
@ -282,7 +282,7 @@
<li>
<a
href="/specimens"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-emerald-50 hover:text-emerald-700"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<FlaskConical class="w-4 h-4 flex-shrink-0" />
<span>Specimens</span>
@ -291,7 +291,7 @@
<li>
<a
href="/results"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-emerald-50 hover:text-emerald-700"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<CheckCircle2 class="w-4 h-4 flex-shrink-0" />
<span>Results</span>
@ -304,12 +304,12 @@
<li class="collapsible-section">
<button
onclick={toggleAdministration}
class="menu-item text-gray-700 hover:bg-emerald-50 hover:text-emerald-700 w-full text-left justify-between"
class="menu-item text-gray-700 hover:bg-secondary/10 hover:text-secondary-focus w-full text-left justify-between"
class:centered={!isOpen}
title={!isOpen ? 'Administration' : ''}
>
<div class="flex items-center gap-2">
<Building2 class="w-5 h-5 text-emerald-600 flex-shrink-0" />
<Building2 class="w-5 h-5 text-secondary flex-shrink-0" />
{#if isOpen}
<span class="menu-text whitespace-nowrap overflow-hidden">Administration</span>
{/if}
@ -323,7 +323,7 @@
<li>
<a
href="/organization"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-emerald-50 hover:text-emerald-700"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<Building2 class="w-4 h-4 flex-shrink-0" />
<span>Organization</span>
@ -332,7 +332,7 @@
<li>
<a
href="/users"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-emerald-50 hover:text-emerald-700"
class="menu-item py-1 px-2 text-sm text-gray-600 hover:bg-secondary/10 hover:text-secondary-focus"
>
<UserCircle class="w-4 h-4 flex-shrink-0" />
<span>Users</span>

View File

@ -12,112 +12,112 @@
</script>
<div class="p-4">
<!-- Summary Stats -->
<!-- Summary Stats -->
<div class="stats stats-vertical lg:stats-horizontal shadow w-full mb-4 bg-base-100/80 backdrop-blur">
<div class="stat border-l-4 border-emerald-500 hover:bg-emerald-50/50 transition-colors py-2">
<div class="stat-figure text-emerald-500">
<div class="stat border-l-4 border-primary hover:bg-primary/5 transition-colors py-2">
<div class="stat-figure text-primary">
<Clock class="w-6 h-6" />
</div>
<div class="stat-title text-emerald-700 text-sm">Pending Orders</div>
<div class="stat-value text-emerald-600 text-2xl">24</div>
<div class="stat-desc text-emerald-600/70 text-xs">Jan 1 - Feb 8</div>
<div class="stat-title text-primary font-medium text-sm">Pending Orders</div>
<div class="stat-value text-primary text-2xl">24</div>
<div class="stat-desc text-primary/70 text-xs">Jan 1 - Feb 8</div>
</div>
<div class="stat border-l-4 border-green-500 hover:bg-green-50/50 transition-colors py-2">
<div class="stat-figure text-green-500">
<div class="stat border-l-4 border-secondary hover:bg-secondary/5 transition-colors py-2">
<div class="stat-figure text-secondary">
<CheckCircle2 class="w-6 h-6" />
</div>
<div class="stat-title text-green-700 text-sm">Today's Results</div>
<div class="stat-value text-green-600 text-2xl">156</div>
<div class="stat-desc text-green-600/70 text-xs">↗︎ 14% more than yesterday</div>
<div class="stat-title text-secondary font-medium text-sm">Today's Results</div>
<div class="stat-value text-secondary text-2xl">156</div>
<div class="stat-desc text-secondary/70 text-xs">↗︎ 14% more than yesterday</div>
</div>
<div class="stat border-l-4 border-red-500 hover:bg-red-50/50 transition-colors py-2">
<div class="stat-figure text-red-500">
<div class="stat border-l-4 border-error hover:bg-error/5 transition-colors py-2">
<div class="stat-figure text-error">
<AlertTriangle class="w-6 h-6" />
</div>
<div class="stat-title text-red-700 text-sm">Critical Results</div>
<div class="stat-value text-red-600 text-2xl">3</div>
<div class="stat-desc text-red-600/70 text-xs">Requires attention</div>
<div class="stat-title text-error font-medium text-sm">Critical Results</div>
<div class="stat-value text-error text-2xl">3</div>
<div class="stat-desc text-error/70 text-xs">Requires attention</div>
</div>
<div class="stat border-l-4 border-teal-500 hover:bg-teal-50/50 transition-colors py-2">
<div class="stat-figure text-teal-500">
<div class="stat border-l-4 border-accent hover:bg-accent/5 transition-colors py-2">
<div class="stat-figure text-accent">
<Users class="w-6 h-6" />
</div>
<div class="stat-title text-teal-700 text-sm">Active Patients</div>
<div class="stat-value text-teal-600 text-2xl">89</div>
<div class="stat-desc text-teal-600/70 text-xs">Currently in system</div>
<div class="stat-title text-accent font-medium text-sm">Active Patients</div>
<div class="stat-value text-accent text-2xl">89</div>
<div class="stat-desc text-accent/70 text-xs">Currently in system</div>
</div>
</div>
<!-- Charts Section -->
<!-- Charts Section -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<div class="card bg-base-100 shadow border-t-4 border-emerald-500 hover:shadow-lg transition-shadow">
<div class="card bg-base-100 shadow border-t-4 border-primary hover:shadow-lg transition-shadow">
<div class="card-body p-4">
<h2 class="card-title text-emerald-700 text-base">
<h2 class="card-title text-primary text-base">
<TrendingUp class="w-4 h-4 mr-2" />
Orders Trend
</h2>
<div class="h-48 flex items-center justify-center bg-gradient-to-br from-emerald-50 to-teal-50 rounded-lg border border-emerald-100">
<p class="text-emerald-600/60">[Chart: Orders over time]</p>
<div class="h-48 flex items-center justify-center bg-gradient-to-br from-primary/10 to-accent/10 rounded-lg border border-primary/20">
<p class="text-primary/60">[Chart: Orders over time]</p>
</div>
</div>
</div>
<div class="card bg-base-100 shadow border-t-4 border-teal-500 hover:shadow-lg transition-shadow">
<div class="card bg-base-100 shadow border-t-4 border-secondary hover:shadow-lg transition-shadow">
<div class="card-body p-4">
<h2 class="card-title text-teal-700 text-base">
<h2 class="card-title text-secondary text-base">
<PieChart class="w-4 h-4 mr-2" />
Results Volume
</h2>
<div class="h-48 flex items-center justify-center bg-gradient-to-br from-teal-50 to-cyan-50 rounded-lg border border-teal-100">
<p class="text-teal-600/60">[Chart: Results by department]</p>
<div class="h-48 flex items-center justify-center bg-gradient-to-br from-secondary/10 to-accent/10 rounded-lg border border-secondary/20">
<p class="text-secondary/60">[Chart: Results by department]</p>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="card bg-base-100 shadow border-t-4 border-green-500 hover:shadow-lg transition-shadow">
<!-- Recent Activity -->
<div class="card bg-base-100 shadow border-t-4 border-primary hover:shadow-lg transition-shadow">
<div class="card-body p-4">
<h2 class="card-title mb-2 text-green-700 text-base">
<h2 class="card-title mb-2 text-primary text-base">
<Clock class="w-4 h-4 mr-2" />
Recent Activity
</h2>
<ul class="timeline timeline-vertical timeline-compact">
<li>
<div class="timeline-middle">
<div class="w-8 h-8 rounded-full bg-emerald-100 flex items-center justify-center">
<Plus class="w-4 h-4 text-emerald-600" />
<div class="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
<Plus class="w-4 h-4 text-primary" />
</div>
</div>
<div class="timeline-start timeline-box bg-gradient-to-r from-emerald-50 to-white border-l-4 border-emerald-500 p-2">
<time class="text-xs font-mono text-emerald-600">09:30 AM</time>
<div class="text-base font-bold text-emerald-800">Order #12345 created</div>
<div class="text-xs text-emerald-600/80">Patient: John Doe (P-1001)</div>
<div class="timeline-start timeline-box bg-gradient-to-r from-primary/5 to-white border-l-4 border-primary p-2">
<time class="text-xs font-mono text-primary">09:30 AM</time>
<div class="text-base font-bold text-primary/90">Order #12345 created</div>
<div class="text-xs text-primary/70">Patient: John Doe (P-1001)</div>
</div>
<hr class="bg-emerald-300"/>
<hr class="bg-primary/30"/>
</li>
<li>
<div class="timeline-middle">
<div class="w-8 h-8 rounded-full bg-green-100 flex items-center justify-center">
<CheckCircle2 class="w-4 h-4 text-green-600" />
<div class="w-8 h-8 rounded-full bg-secondary/10 flex items-center justify-center">
<CheckCircle2 class="w-4 h-4 text-secondary" />
</div>
</div>
<div class="timeline-start timeline-box bg-gradient-to-r from-green-50 to-white border-l-4 border-green-500 p-2">
<time class="text-xs font-mono text-green-600">09:15 AM</time>
<div class="text-base font-bold text-green-800">Result received</div>
<div class="text-xs text-green-600/80">Sample: ABC123 - Instrument: CBC-M01</div>
<div class="timeline-start timeline-box bg-gradient-to-r from-secondary/5 to-white border-l-4 border-secondary p-2">
<time class="text-xs font-mono text-secondary">09:15 AM</time>
<div class="text-base font-bold text-secondary/90">Result received</div>
<div class="text-xs text-secondary/70">Sample: ABC123 - Instrument: CBC-M01</div>
</div>
<hr class="bg-green-300"/>
<hr class="bg-secondary/30"/>
</li>
<li>
<div class="timeline-middle">
<div class="w-8 h-8 rounded-full bg-teal-100 flex items-center justify-center">
<UserCircle class="w-4 h-4 text-teal-600" />
<div class="w-8 h-8 rounded-full bg-accent/10 flex items-center justify-center">
<UserCircle class="w-4 h-4 text-accent" />
</div>
</div>
<div class="timeline-start timeline-box bg-gradient-to-r from-teal-50 to-white border-l-4 border-teal-500 p-2">
<time class="text-xs font-mono text-teal-600">09:00 AM</time>
<div class="text-base font-bold text-teal-800">Patient registered</div>
<div class="text-xs text-teal-600/80">Patient ID: P-1001 - Jane Smith</div>
<div class="timeline-start timeline-box bg-gradient-to-r from-accent/5 to-white border-l-4 border-accent p-2">
<time class="text-xs font-mono text-accent">09:00 AM</time>
<div class="text-base font-bold text-accent/90">Patient registered</div>
<div class="text-xs text-accent/70">Patient ID: P-1001 - Jane Smith</div>
</div>
</li>
</ul>

View File

@ -6,14 +6,17 @@
import DataTable from '$lib/components/DataTable.svelte';
import Modal from '$lib/components/Modal.svelte';
import SelectDropdown from '$lib/components/SelectDropdown.svelte';
import { Plus, Edit2, Trash2, ArrowLeft } from 'lucide-svelte';
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
import { Plus, Edit2, Trash2, ArrowLeft, Search, Users, Loader2 } from 'lucide-svelte';
let loading = $state(false);
let saving = $state(false);
let deleting = $state(false);
let contacts = $state([]);
let specialties = $state([]);
let searchQuery = $state('');
let modalOpen = $state(false);
let modalMode = $state('create');
let saving = $state(false);
let formData = $state({ ContactID: null, NameFirst: '', NameLast: '', Title: '', Initial: '', Specialty: '' });
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
@ -26,10 +29,20 @@
Object.fromEntries(specialties.map((s) => [s.SpecialtyID, s.SpecialtyText]))
);
const filteredContacts = $derived.by(() => {
if (!searchQuery.trim()) return contacts;
const query = searchQuery.toLowerCase().trim();
return contacts.filter(contact => {
const fullName = `${contact.NameFirst || ''} ${contact.NameLast || ''}`.trim().toLowerCase();
const initial = (contact.Initial || '').toLowerCase();
return fullName.includes(query) || initial.includes(query);
});
});
const columns = [
{ key: 'Initial', label: 'Initial', class: 'font-medium' },
{ key: 'FullName', label: 'Name' },
{ key: 'Title', label: 'Title' },
{ key: 'Initial', label: 'Initial', class: 'font-medium w-24' },
{ key: 'FullName', label: 'Full Name' },
{ key: 'Title', label: 'Title', class: 'w-32' },
{ key: 'SpecialtyLabel', label: 'Specialty' },
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
];
@ -80,14 +93,39 @@
modalOpen = true;
}
function validateForm() {
const initial = (formData.Initial || '').trim().toUpperCase();
// Check for duplicate initials (excluding current contact when editing)
const duplicate = contacts.find(c =>
c.Initial?.toUpperCase() === initial &&
c.ContactID !== formData.ContactID
);
if (duplicate) {
toastError(`Initial "${initial}" is already used by "${duplicate.NameFirst} ${duplicate.NameLast}". Please choose a different initial.`);
return false;
}
return true;
}
async function handleSave() {
if (!validateForm()) return;
saving = true;
try {
// Normalize the initial to uppercase before saving
const dataToSave = {
...formData,
Initial: (formData.Initial || '').trim().toUpperCase()
};
if (modalMode === 'create') {
await createContact(formData);
await createContact(dataToSave);
toastSuccess('Contact created successfully');
} else {
await updateContact(formData);
await updateContact(dataToSave);
toastSuccess('Contact updated successfully');
}
modalOpen = false;
@ -105,6 +143,7 @@
}
async function handleDelete() {
deleting = true;
try {
await deleteContact(deleteItem.ContactID);
toastSuccess('Contact deleted successfully');
@ -112,8 +151,19 @@
await loadContacts();
} catch (err) {
toastError(err.message || 'Failed to delete contact');
} finally {
deleting = false;
}
}
function getDisplayName(contact) {
const firstName = contact.NameFirst?.trim();
const lastName = contact.NameLast?.trim();
if (firstName && lastName) {
return `${firstName} ${lastName}`;
}
return firstName || lastName || 'Unnamed Contact';
}
</script>
<div class="p-6">
@ -131,15 +181,63 @@
</button>
</div>
<!-- Search Bar -->
<div class="mb-4">
<div class="relative max-w-md">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
class="input input-bordered w-full pl-10"
placeholder="Search by name or initial..."
bind:value={searchQuery}
/>
{#if searchQuery}
<button
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
onclick={() => searchQuery = ''}
>
×
</button>
{/if}
</div>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if loading}
<div class="flex items-center justify-center py-16">
<Loader2 class="w-8 h-8 animate-spin text-primary mr-3" />
<span class="text-gray-600">Loading contacts...</span>
</div>
{:else if filteredContacts().length === 0}
<!-- Empty State -->
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="bg-base-200 rounded-full p-6 mb-4">
<Users class="w-12 h-12 text-gray-400" />
</div>
<h3 class="text-lg font-semibold text-gray-700 mb-1">
{searchQuery ? 'No contacts found' : 'No contacts yet'}
</h3>
<p class="text-gray-500 text-center max-w-sm mb-4">
{searchQuery
? `No contacts matching "${searchQuery}". Try a different search term.`
: 'Get started by adding your first physician or contact.'}
</p>
{#if !searchQuery}
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add First Contact
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={contacts.map((c) => ({
data={filteredContacts().map((c) => ({
...c,
FullName: `${c.NameFirst || ''} ${c.NameLast || ''}`.trim(),
FullName: getDisplayName(c),
SpecialtyLabel: specialtyMap[c.Specialty] || '-',
}))}
{loading}
loading={false}
emptyMessage="No contacts found"
hover={true}
bordered={false}
@ -147,19 +245,19 @@
{#snippet cell({ column, row, value })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)}>
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)} title="Edit contact">
<Edit2 class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)}>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)} title="Delete contact">
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else}
{value}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
</div>
@ -168,7 +266,14 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="initial">
<span class="label-text font-medium">Initial</span>
<span class="label-text font-medium flex items-center gap-2">
Initial
<HelpTooltip
text="A unique short code used to identify this doctor (e.g., 'JS' for John Smith). This will be used in reports and quick reference."
title="Doctor's Short Code"
position="right"
/>
</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
@ -176,9 +281,13 @@
type="text"
class="input input-bordered w-full"
bind:value={formData.Initial}
placeholder="Enter initial"
placeholder="e.g., JS, AB, MK"
maxlength="10"
required
/>
<label class="label" for="initial">
<span class="label-text-alt text-gray-500">Unique identifier for this doctor</span>
</label>
</div>
<div class="form-control">
<label class="label" for="title">
@ -189,7 +298,7 @@
type="text"
class="input input-bordered w-full"
bind:value={formData.Title}
placeholder="Dr, Prof, etc."
placeholder="e.g., Dr., Prof., Mr., Ms."
/>
</div>
</div>
@ -224,11 +333,11 @@
name="specialty"
bind:value={formData.Specialty}
options={specialtyOptions}
placeholder="Select specialty..."
placeholder="Select a medical specialty..."
/>
</form>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (modalOpen = false)} type="button">Cancel</button>
<button class="btn btn-ghost" onclick={() => (modalOpen = false)} type="button" disabled={saving}>Cancel</button>
<button class="btn btn-primary" onclick={handleSave} disabled={saving} type="button">
{#if saving}
<span class="loading loading-spinner loading-sm mr-2"></span>
@ -241,12 +350,20 @@
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete" size="sm">
<div class="py-2">
<p class="text-base-content/80">
Are you sure you want to delete <strong class="text-base-content">{deleteItem?.Initial}</strong>?
Are you sure you want to delete <strong class="text-base-content">{getDisplayName(deleteItem || {})}</strong>?
</p>
<p class="text-sm text-error mt-2">This action cannot be undone.</p>
{#if deleteItem?.Initial}
<p class="text-sm text-gray-500 mt-1">Initial: {deleteItem.Initial}</p>
{/if}
<p class="text-sm text-error mt-3">This action cannot be undone.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} type="button">Cancel</button>
<button class="btn btn-error" onclick={handleDelete} type="button">Delete</button>
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} type="button" disabled={deleting}>Cancel</button>
<button class="btn btn-error" onclick={handleDelete} disabled={deleting} type="button">
{#if deleting}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{deleting ? 'Deleting...' : 'Delete'}
</button>
{/snippet}
</Modal>

View File

@ -6,17 +6,22 @@
import DataTable from '$lib/components/DataTable.svelte';
import Modal from '$lib/components/Modal.svelte';
import SelectDropdown from '$lib/components/SelectDropdown.svelte';
import { Plus, Edit2, Trash2, ArrowLeft } from 'lucide-svelte';
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
import { Plus, Edit2, Trash2, ArrowLeft, Filter, Search, FlaskConical } from 'lucide-svelte';
let loading = $state(false);
let containers = $state([]);
let modalOpen = $state(false);
let modalMode = $state('create');
let saving = $state(false);
let deleting = $state(false);
let formData = $state({ ConDefID: null, ConCode: '', ConName: '', ConDesc: '', ConClass: '', Additive: '', Color: '' });
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
// Search and filter states
let searchQuery = $state('');
const columns = [
{ key: 'ConCode', label: 'Code', class: 'font-medium' },
{ key: 'ConName', label: 'Name' },
@ -34,6 +39,19 @@
return item?.label || item?.Label || item?.description || item?.Description || item?.name || item?.Name || value;
}
// Derived filtered containers based on search query
let filteredContainers = $derived(
searchQuery.trim()
? containers.filter(container => {
const query = searchQuery.toLowerCase().trim();
return (
(container.ConCode && container.ConCode.toLowerCase().includes(query)) ||
(container.ConName && container.ConName.toLowerCase().includes(query))
);
})
: containers
);
onMount(async () => {
// Preload valuesets for dropdowns
await Promise.all([
@ -57,6 +75,17 @@
}
}
function handleSearchKeydown(event) {
if (event.key === 'Enter') {
// Search is reactive via $effect, but this allows for future server-side search
event.preventDefault();
}
}
function handleFilter() {
// Filter is reactive via $effect, but this allows for future enhancements
}
function openCreateModal() {
modalMode = 'create';
formData = { ConDefID: null, ConCode: '', ConName: '', ConDesc: '', ConClass: '', Additive: '', Color: '' };
@ -102,13 +131,17 @@
}
async function handleDelete() {
deleting = true;
try {
await deleteContainer(deleteItem.ConDefID);
toastSuccess('Container deleted successfully');
deleteConfirmOpen = false;
deleteItem = null;
await loadContainers();
} catch (err) {
toastError(err.message || 'Failed to delete container');
} finally {
deleting = false;
}
}
</script>
@ -128,10 +161,56 @@
</button>
</div>
<!-- Search and Filter -->
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1 relative">
<Search class="w-5 h-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search by code or name..."
class="input input-bordered w-full pl-10"
bind:value={searchQuery}
onkeydown={handleSearchKeydown}
/>
</div>
<button class="btn btn-outline" onclick={handleFilter}>
<Filter class="w-4 h-4 mr-2" />
Filter
</button>
</div>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if !loading && filteredContainers.length === 0}
<!-- Empty State -->
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="bg-base-200 rounded-full p-6 mb-4">
<FlaskConical class="w-12 h-12 text-gray-400" />
</div>
<h3 class="text-lg font-semibold text-gray-700 mb-2">
{searchQuery.trim() ? 'No containers match your search' : 'No containers found'}
</h3>
<p class="text-gray-500 text-center max-w-md mb-6">
{searchQuery.trim()
? `No containers found matching "${searchQuery}". Try a different search term or clear the filter.`
: 'Get started by adding your first specimen container or tube to the system.'}
</p>
{#if searchQuery.trim()}
<button class="btn btn-outline" onclick={() => searchQuery = ''}>
Clear Search
</button>
{:else}
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Container
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={containers}
data={filteredContainers}
{loading}
emptyMessage="No containers found"
hover={true}
@ -140,10 +219,10 @@
{#snippet cell({ column, row, value })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)}>
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)} title="Edit container">
<Edit2 class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)}>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)} title="Delete container">
<Trash2 class="w-4 h-4" />
</button>
</div>
@ -165,6 +244,7 @@
{/if}
{/snippet}
</DataTable>
{/if}
</div>
</div>
@ -173,7 +253,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="code">
<span class="label-text font-medium">Code</span>
<span class="label-text font-medium">Container Code</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
@ -181,13 +261,14 @@
type="text"
class="input input-bordered w-full"
bind:value={formData.ConCode}
placeholder="e.g., 1"
placeholder="e.g., SST, EDTA, HEP"
required
/>
<span class="label-text-alt text-gray-500">Unique identifier for this container type</span>
</div>
<div class="form-control">
<label class="label" for="name">
<span class="label-text font-medium">Name</span>
<span class="label-text font-medium">Container Name</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
@ -195,9 +276,10 @@
type="text"
class="input input-bordered w-full"
bind:value={formData.ConName}
placeholder="e.g., SST"
placeholder="e.g., Serum Separator Tube"
required
/>
<span class="label-text-alt text-gray-500">Descriptive name displayed in the system</span>
</div>
</div>
<div class="form-control">
@ -209,35 +291,69 @@
type="text"
class="input input-bordered w-full"
bind:value={formData.ConDesc}
placeholder="e.g., Evacuated blood collection tube"
placeholder="e.g., Evacuated blood collection tube with gel separator"
/>
<span class="label-text-alt text-gray-500">Optional detailed description of the container</span>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label" for="class">
<span class="label-text font-medium flex items-center gap-2">
Container Class
<HelpTooltip
text="The general category of this container. Examples: Tube (for blood collection tubes), Cup (for urine or fluid cups), Swab (for specimen swabs)."
title="Container Class Help"
/>
</span>
</label>
<SelectDropdown
label="Class"
name="class"
valueSetKey="container_class"
bind:value={formData.ConClass}
placeholder="Select Class..."
placeholder="Select class..."
/>
<span class="label-text-alt text-gray-500">Category of container</span>
</div>
<div class="form-control">
<label class="label" for="additive">
<span class="label-text font-medium flex items-center gap-2">
Additive
<HelpTooltip
text="Any chemical additive present in the container. Examples: EDTA (anticoagulant for CBC), Heparin (anticoagulant for chemistry), SST (Serum Separator Tube with gel), None (plain tube)."
title="Additive Help"
/>
</span>
</label>
<SelectDropdown
label="Additive"
name="additive"
valueSetKey="additive"
bind:value={formData.Additive}
placeholder="Select Additive..."
placeholder="Select additive..."
/>
<span class="label-text-alt text-gray-500">Chemical additive inside</span>
</div>
<div class="form-control">
<label class="label" for="color">
<span class="label-text font-medium flex items-center gap-2">
Cap Color
<HelpTooltip
text="The color of the container cap or closure. This is an industry standard for identifying container types at a glance (e.g., Lavender = EDTA, Red = Plain serum, Green = Heparin)."
title="Cap Color Help"
/>
</span>
</label>
<SelectDropdown
label="Cap Color"
name="color"
valueSetKey="container_cap_color"
bind:value={formData.Color}
placeholder="Select Color..."
placeholder="Select color..."
/>
<span class="label-text-alt text-gray-500">Visual identification color</span>
</div>
</div>
</form>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (modalOpen = false)} type="button">Cancel</button>
<button class="btn btn-ghost" onclick={() => (modalOpen = false)} type="button" disabled={saving}>Cancel</button>
<button class="btn btn-primary" onclick={handleSave} disabled={saving} type="button">
{#if saving}
<span class="loading loading-spinner loading-sm mr-2"></span>
@ -247,15 +363,35 @@
{/snippet}
</Modal>
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete" size="sm">
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete Container" size="sm">
<div class="py-2">
<p class="text-base-content/80">
Are you sure you want to delete <strong class="text-base-content">{deleteItem?.ConName}</strong>?
Are you sure you want to delete this container?
</p>
<div class="bg-base-200 rounded-lg p-3 mt-3">
<p class="text-sm">
<span class="text-gray-500">Code:</span>
<strong class="text-base-content font-mono">{deleteItem?.ConCode}</strong>
</p>
<p class="text-sm mt-1">
<span class="text-gray-500">Name:</span>
<strong class="text-base-content">{deleteItem?.ConName}</strong>
</p>
</div>
<p class="text-sm text-error mt-3 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
This action cannot be undone.
</p>
<p class="text-sm text-error mt-2">This action cannot be undone.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} type="button">Cancel</button>
<button class="btn btn-error" onclick={handleDelete} type="button">Delete</button>
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} type="button" disabled={deleting}>Cancel</button>
<button class="btn btn-error" onclick={handleDelete} disabled={deleting} type="button">
{#if deleting}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{deleting ? 'Deleting...' : 'Delete'}
</button>
{/snippet}
</Modal>

View File

@ -4,13 +4,27 @@
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import DataTable from '$lib/components/DataTable.svelte';
import Modal from '$lib/components/Modal.svelte';
import { Plus, Edit2, Trash2, ArrowLeft } from 'lucide-svelte';
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
import {
Plus,
Edit2,
Trash2,
ArrowLeft,
Search,
Hash,
AlertCircle,
Loader2,
Info
} from 'lucide-svelte';
let loading = $state(false);
let counters = $state([]);
let searchQuery = $state('');
let modalOpen = $state(false);
let modalMode = $state('create');
let saving = $state(false);
let deleting = $state(false);
let formErrors = $state({});
let formData = $state({
CounterID: null,
CounterDesc: '',
@ -36,15 +50,31 @@
'': 'Manual',
};
const resetBadges = {
'D': { label: 'D', class: 'badge-primary', description: 'Daily Reset' },
'M': { label: 'M', class: 'badge-secondary', description: 'Monthly Reset' },
'Y': { label: 'Y', class: 'badge-accent', description: 'Yearly Reset' },
'': { label: '-', class: 'badge-ghost', description: 'Manual Reset' },
};
const columns = [
{ key: 'CounterDesc', label: 'Description', class: 'font-medium' },
{ key: 'CounterValue', label: 'Current Value', class: 'text-center' },
{ key: 'CounterStart', label: 'Start Value', class: 'text-center' },
{ key: 'CounterEnd', label: 'End Value', class: 'text-center' },
{ key: 'CounterResetLabel', label: 'Reset Pattern' },
{ key: 'CounterResetBadge', label: 'Reset Pattern', class: 'text-center' },
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
];
// Filter counters based on search query
let filteredCounters = $derived(
searchQuery.trim() === ''
? counters
: counters.filter(c =>
(c.CounterDesc || '').toLowerCase().includes(searchQuery.toLowerCase().trim())
)
);
onMount(async () => {
await loadCounters();
});
@ -72,6 +102,7 @@
CounterEnd: null,
CounterReset: ''
};
formErrors = {};
modalOpen = true;
}
@ -85,10 +116,45 @@
CounterEnd: row.CounterEnd || null,
CounterReset: row.CounterReset || '',
};
formErrors = {};
modalOpen = true;
}
function validateForm() {
const errors = {};
// Start value must be less than or equal to end value
if (formData.CounterEnd !== null && formData.CounterEnd !== '' && formData.CounterEnd !== undefined) {
if (formData.CounterStart > formData.CounterEnd) {
errors.CounterStart = 'Start value cannot be greater than end value';
errors.CounterEnd = 'End value must be greater than or equal to start value';
}
}
// Current value must be between start and end (if end is set)
if (formData.CounterEnd !== null && formData.CounterEnd !== '' && formData.CounterEnd !== undefined) {
if (formData.CounterValue < formData.CounterStart) {
errors.CounterValue = `Current value must be at least ${formData.CounterStart}`;
}
if (formData.CounterValue > formData.CounterEnd) {
errors.CounterValue = `Current value cannot exceed ${formData.CounterEnd}`;
}
} else {
// If no end value, current must still be >= start
if (formData.CounterValue < formData.CounterStart) {
errors.CounterValue = `Current value must be at least ${formData.CounterStart}`;
}
}
formErrors = errors;
return Object.keys(errors).length === 0;
}
async function handleSave() {
if (!validateForm()) {
return;
}
saving = true;
try {
if (modalMode === 'create') {
@ -113,6 +179,7 @@
}
async function handleDelete() {
deleting = true;
try {
await deleteCounter(deleteItem.CounterID);
toastSuccess('Counter deleted successfully');
@ -120,11 +187,18 @@
await loadCounters();
} catch (err) {
toastError(err.message || 'Failed to delete counter');
} finally {
deleting = false;
}
}
function getResetBadge(resetValue) {
return resetBadges[resetValue] || resetBadges[''];
}
</script>
<div class="p-6">
<!-- Header -->
<div class="flex items-center gap-4 mb-6">
<a href="/master-data" class="btn btn-ghost btn-circle">
<ArrowLeft class="w-5 h-5" />
@ -139,12 +213,87 @@
</button>
</div>
<!-- Info Card -->
<div class="alert alert-info mb-6">
<Info class="w-5 h-5 shrink-0" />
<div>
<h3 class="font-bold">What are Counters?</h3>
<p class="text-sm">
Counters are used to automatically generate unique laboratory IDs (e.g., sample IDs, order IDs).
They keep track of the next available number and can automatically reset based on a pattern.
</p>
</div>
</div>
<!-- Search Bar -->
<div class="flex items-center gap-4 mb-4">
<div class="form-control flex-1 max-w-md">
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/50" />
<input
type="text"
class="input input-bordered w-full pl-10"
placeholder="Search by counter description..."
bind:value={searchQuery}
/>
{#if searchQuery}
<button
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
onclick={() => searchQuery = ''}
aria-label="Clear search"
>
×
</button>
{/if}
</div>
</div>
{#if searchQuery}
<span class="text-sm text-base-content/70">
{filteredCounters.length} result{filteredCounters.length === 1 ? '' : 's'}
</span>
{/if}
</div>
<!-- Data Table -->
<div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if filteredCounters.length === 0 && !loading}
<!-- Empty State -->
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="bg-base-200 rounded-full p-4 mb-4">
<Hash class="w-8 h-8 text-base-content/40" />
</div>
<h3 class="text-lg font-semibold text-base-content/70 mb-1">
{#if searchQuery}
No counters found
{:else}
No counters yet
{/if}
</h3>
<p class="text-sm text-base-content/50 text-center max-w-sm mb-4">
{#if searchQuery}
No counters match your search "{searchQuery}". Try a different search term.
{:else}
Create your first counter to start generating unique laboratory IDs.
{/if}
</p>
{#if !searchQuery}
<button class="btn btn-primary btn-sm" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Create Counter
</button>
{:else}
<button class="btn btn-ghost btn-sm" onclick={() => searchQuery = ''}>
Clear Search
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={counters.map((c) => ({
data={filteredCounters.map((c) => ({
...c,
CounterResetLabel: resetLabels[c.CounterReset] || c.CounterReset || 'Manual',
CounterResetBadge: c.CounterReset,
CounterEnd: c.CounterEnd || '-',
}))}
{loading}
@ -155,82 +304,150 @@
{#snippet cell({ column, row, value })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)}>
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)} title="Edit">
<Edit2 class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)}>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)} title="Delete">
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else if column.key === 'CounterResetBadge'}
{@const badge = getResetBadge(row.CounterReset)}
<div class="tooltip" data-tip={badge.description}>
<span class="badge {badge.class} badge-sm">{badge.label}</span>
</div>
{:else}
{value}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
</div>
<!-- Create/Edit Modal -->
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Counter' : 'Edit Counter'} size="md">
<form class="space-y-5" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<!-- Description -->
<div class="form-control">
<label class="label" for="counterDesc">
<span class="label-text font-medium">Description</span>
<span class="label-text font-medium">Counter Description</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
id="counterDesc"
type="text"
class="input input-bordered w-full"
class:input-error={formErrors.CounterDesc}
bind:value={formData.CounterDesc}
placeholder="Enter counter description"
placeholder="e.g., Sample ID Counter, Order Number"
required
/>
<label class="label" for="counterDesc">
<span class="label-text-alt text-base-content/60">
A descriptive name for this counter
</span>
</label>
</div>
<!-- Value Fields -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Current Value -->
<div class="form-control">
<label class="label" for="counterValue">
<span class="label-text font-medium">Current Value</span>
<span class="label-text-alt text-error">*</span>
<HelpTooltip
text="The next number that will be assigned. This increments automatically each time an ID is generated."
title="Current Value"
position="top"
class="ml-1"
/>
</label>
<input
id="counterValue"
type="number"
class="input input-bordered w-full"
class:input-error={formErrors.CounterValue}
bind:value={formData.CounterValue}
placeholder="0"
placeholder="e.g., 1000"
min="0"
required
/>
{#if formErrors.CounterValue}
<label class="label" for="counterValue">
<span class="label-text-alt text-error">{formErrors.CounterValue}</span>
</label>
{/if}
</div>
<!-- Start Value -->
<div class="form-control">
<label class="label" for="counterStart">
<span class="label-text font-medium">Start Value</span>
<span class="label-text-alt text-error">*</span>
<HelpTooltip
text="The minimum value this counter can have. When reset, the counter returns to this value."
title="Start Value"
position="top"
class="ml-1"
/>
</label>
<input
id="counterStart"
type="number"
class="input input-bordered w-full"
class:input-error={formErrors.CounterStart}
bind:value={formData.CounterStart}
placeholder="0"
placeholder="e.g., 1"
min="0"
required
/>
{#if formErrors.CounterStart}
<label class="label" for="counterStart">
<span class="label-text-alt text-error">{formErrors.CounterStart}</span>
</label>
{/if}
</div>
<!-- End Value -->
<div class="form-control">
<label class="label" for="counterEnd">
<span class="label-text font-medium">End Value</span>
<HelpTooltip
text="Optional maximum value. When reached, the counter will reset to the start value. Leave empty for no limit."
title="End Value"
position="top"
class="ml-1"
/>
</label>
<input
id="counterEnd"
type="number"
class="input input-bordered w-full"
class:input-error={formErrors.CounterEnd}
bind:value={formData.CounterEnd}
placeholder="Optional"
placeholder="e.g., 9999"
min="0"
/>
{#if formErrors.CounterEnd}
<label class="label" for="counterEnd">
<span class="label-text-alt text-error">{formErrors.CounterEnd}</span>
</label>
{/if}
</div>
</div>
<!-- Reset Pattern -->
<div class="form-control">
<label class="label" for="counterReset">
<span class="label-text font-medium">Reset Pattern</span>
<HelpTooltip
text="Determines when the counter automatically resets to the start value. Daily resets at midnight, monthly on the 1st, yearly on Jan 1st."
title="Reset Pattern"
position="top"
class="ml-1"
/>
</label>
<select
id="counterReset"
@ -238,32 +455,60 @@
class="select select-bordered w-full"
bind:value={formData.CounterReset}
>
{#each resetOptions as option}
{#each resetOptions as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
<label class="label" for="counterReset">
<span class="label-text-alt text-base-content/60">
Choose when the counter automatically resets to the start value
</span>
</label>
</div>
</form>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (modalOpen = false)} type="button">Cancel</button>
<button class="btn btn-primary" onclick={handleSave} disabled={saving} type="button">
{#if saving}
<span class="loading loading-spinner loading-sm mr-2"></span>
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
{/if}
{saving ? 'Saving...' : 'Save'}
</button>
{/snippet}
</Modal>
<!-- Delete Confirmation Modal -->
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete" size="sm">
<div class="py-2">
<p class="text-base-content/80">
Are you sure you want to delete <strong class="text-base-content">{deleteItem?.CounterDesc}</strong>?
<div class="alert alert-warning mb-4">
<AlertCircle class="w-5 h-5 shrink-0" />
<span>This action cannot be undone.</span>
</div>
<p class="text-base-content/80 mb-2">
Are you sure you want to delete this counter?
</p>
<div class="bg-base-200 rounded-lg p-3 mt-3">
<p class="font-semibold text-base-content">{deleteItem?.CounterDesc}</p>
<p class="text-sm text-base-content/60 mt-1">
Current Value: {deleteItem?.CounterValue} |
Pattern: {resetLabels[deleteItem?.CounterReset] || 'Manual'}
</p>
</div>
<p class="text-sm text-error mt-4">
<strong>Warning:</strong> Deleting this counter may cause issues with ID generation if it's currently in use.
</p>
<p class="text-sm text-error mt-2">This action cannot be undone.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} type="button">Cancel</button>
<button class="btn btn-error" onclick={handleDelete} type="button">Delete</button>
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} disabled={deleting} type="button">
Cancel
</button>
<button class="btn btn-error" onclick={handleDelete} disabled={deleting} type="button">
{#if deleting}
<Loader2 class="w-4 h-4 mr-2 animate-spin" />
{:else}
<Trash2 class="w-4 h-4 mr-2" />
{/if}
{deleting ? 'Deleting...' : 'Delete Counter'}
</button>
{/snippet}
</Modal>

View File

@ -4,30 +4,68 @@
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import DataTable from '$lib/components/DataTable.svelte';
import SelectDropdown from '$lib/components/SelectDropdown.svelte';
import { ArrowLeft, MapPin, Globe, Building2 } from 'lucide-svelte';
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
import {
ArrowLeft,
MapPin,
Globe,
Building2,
Search,
Info,
MapPinned,
TreePine,
Mountain,
Waves,
Home,
Building,
LandPlot,
Map,
AlertCircle,
Database
} from 'lucide-svelte';
let loading = $state(false);
let loadingMessage = $state('Loading...');
let activeTab = $state('provinces');
let areas = $state([]);
let provinces = $state([]);
let cities = $state([]);
let selectedProvince = $state('');
// Search states for each tab
let provinceSearch = $state('');
let citySearch = $state('');
let areaSearch = $state('');
// Area class to icon mapping
const areaClassIcons = {
'Province': MapPinned,
'Regency': Building,
'District': LandPlot,
'Village': Home,
'Country': Globe,
'State': Map,
'City': Building2,
'Island': TreePine,
'Mountain': Mountain,
'Coastal': Waves,
};
const areaColumns = [
{ key: 'AreaGeoID', label: 'ID', class: 'font-medium' },
{ key: 'AreaCode', label: 'Code' },
{ key: 'AreaGeoID', label: 'ID', class: 'font-medium w-20' },
{ key: 'AreaCode', label: 'Code', class: 'w-24' },
{ key: 'AreaName', label: 'Name' },
{ key: 'Class', label: 'Class' },
{ key: 'Parent', label: 'Parent' },
{ key: 'Class', label: 'Class', class: 'w-32' },
{ key: 'Parent', label: 'Parent Area' },
];
const provinceColumns = [
{ key: 'value', label: 'ID', class: 'font-medium' },
{ key: 'value', label: 'ID', class: 'font-medium w-24' },
{ key: 'label', label: 'Province Name' },
];
const cityColumns = [
{ key: 'value', label: 'ID', class: 'font-medium' },
{ key: 'value', label: 'ID', class: 'font-medium w-24' },
{ key: 'label', label: 'City Name' },
];
@ -37,6 +75,7 @@
async function loadAreas() {
loading = true;
loadingMessage = 'Loading geographical areas...';
try {
const response = await fetchGeographicalAreas();
areas = response.data || [];
@ -50,6 +89,7 @@
async function loadProvinces() {
loading = true;
loadingMessage = 'Loading provinces...';
try {
const response = await fetchProvinces();
provinces = Array.isArray(response) ? response : (Array.isArray(response.data) ? response.data : []);
@ -63,6 +103,9 @@
async function loadCities() {
loading = true;
loadingMessage = selectedProvince
? 'Loading cities for selected province...'
: 'Loading all cities...';
try {
const provinceId = selectedProvince || null;
const response = await fetchCities(provinceId);
@ -77,6 +120,11 @@
function handleTabChange(tab) {
activeTab = tab;
// Reset search when changing tabs
provinceSearch = '';
citySearch = '';
areaSearch = '';
if (tab === 'areas' && areas.length === 0) {
loadAreas();
} else if (tab === 'cities' && cities.length === 0) {
@ -84,10 +132,49 @@
}
}
function getAreaClassIcon(className) {
if (!className) return MapPin;
const IconComponent = areaClassIcons[className];
return IconComponent || MapPin;
}
const provinceOptions = $derived(
provinces.map((p) => ({ value: p.value, label: p.label }))
);
// Filtered data based on search
const filteredProvinces = $derived(
provinceSearch.trim()
? provinces.filter(p =>
p.label.toLowerCase().includes(provinceSearch.toLowerCase())
)
: provinces
);
const filteredCities = $derived(
citySearch.trim()
? cities.filter(c =>
c.label.toLowerCase().includes(citySearch.toLowerCase())
)
: cities
);
const filteredAreas = $derived(
areaSearch.trim()
? areas.filter(a =>
(a.AreaName && a.AreaName.toLowerCase().includes(areaSearch.toLowerCase())) ||
(a.AreaCode && a.AreaCode.toLowerCase().includes(areaSearch.toLowerCase())) ||
(a.Class && a.Class.toLowerCase().includes(areaSearch.toLowerCase()))
)
: areas
);
const selectedProvinceLabel = $derived(
selectedProvince
? provinces.find(p => String(p.value) === String(selectedProvince))?.label
: null
);
// Reload cities when province filter changes
$effect(() => {
if (activeTab === 'cities') {
@ -102,11 +189,30 @@
<ArrowLeft class="w-5 h-5" />
</a>
<div class="flex-1">
<div class="flex items-center gap-2">
<h1 class="text-3xl font-bold text-gray-800">Geography</h1>
<HelpTooltip
title="Geography Data"
text="Geography data is used for patient address management throughout the system. This includes province, city, and detailed area information."
position="right"
/>
</div>
<p class="text-gray-600">View geographical areas, provinces, and cities</p>
</div>
</div>
<!-- Info Banner -->
<div class="alert alert-info mb-6">
<Info class="w-5 h-5 shrink-0" />
<div>
<span class="font-medium">Reference Data</span>
<p class="text-sm opacity-80">
Geography data is read-only reference information used for patient address management.
This data is maintained by system administrators and synchronized with national standards.
</p>
</div>
</div>
<div class="tabs tabs-boxed mb-6">
<button
class="tab gap-2"
@ -115,6 +221,9 @@
>
<MapPin class="w-4 h-4" />
Provinces
{#if provinces.length > 0}
<span class="badge badge-sm badge-primary">{provinces.length}</span>
{/if}
</button>
<button
class="tab gap-2"
@ -135,48 +244,239 @@
</div>
{#if activeTab === 'provinces'}
<div class="space-y-4">
<!-- Tab Description -->
<div class="flex items-start gap-2 text-sm text-gray-600 bg-base-200 p-3 rounded-lg">
<MapPin class="w-4 h-4 mt-0.5 shrink-0" />
<div class="flex-1">
<span class="font-medium">Provinces</span> are the top-level administrative divisions.
Cities and areas are organized hierarchically under provinces.
<HelpTooltip
text="Provinces form the first level of the geographical hierarchy. Each city belongs to exactly one province."
position="right"
/>
</div>
</div>
<!-- Search -->
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Search provinces by name..."
class="input input-bordered w-full pl-10"
bind:value={provinceSearch}
/>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if loading}
<div class="text-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-gray-500 mt-4">{loadingMessage}</p>
</div>
{:else if filteredProvinces.length === 0}
<div class="text-center py-12">
{#if provinceSearch.trim()}
<Search class="w-12 h-12 mx-auto text-gray-300 mb-3" />
<p class="text-gray-500">No provinces match "{provinceSearch}"</p>
<button class="btn btn-sm btn-ghost mt-2" onclick={() => provinceSearch = ''}>
Clear search
</button>
{:else}
<MapPin class="w-12 h-12 mx-auto text-gray-300 mb-3" />
<p class="text-gray-500">No provinces found</p>
{/if}
</div>
{:else}
<DataTable
columns={provinceColumns}
data={provinces}
{loading}
data={filteredProvinces}
loading={false}
emptyMessage="No provinces found"
hover={true}
bordered={false}
/>
{/if}
</div>
</div>
{:else if activeTab === 'cities'}
<div class="space-y-4">
<!-- Tab Description -->
<div class="flex items-start gap-2 text-sm text-gray-600 bg-base-200 p-3 rounded-lg">
<Building2 class="w-4 h-4 mt-0.5 shrink-0" />
<div class="flex-1">
<span class="font-medium">Cities</span> are the second-level administrative divisions within provinces.
Use the filter below to view cities for a specific province.
<HelpTooltip
text="Cities are organized hierarchically under provinces. When you select a province, only cities within that province are displayed."
position="right"
/>
</div>
</div>
<!-- Province Filter -->
<div class="bg-base-200 p-4 rounded-lg">
<div class="flex items-start gap-4">
<div class="flex-1">
<SelectDropdown
label="Filter by Province"
name="province"
bind:value={selectedProvince}
options={provinceOptions}
placeholder="All Provinces"
placeholder="-- All Provinces --"
/>
</div>
{#if selectedProvince && selectedProvinceLabel}
<div class="pt-8">
<span class="badge badge-primary badge-lg">
{filteredCities.length} cities in {selectedProvinceLabel}
</span>
</div>
{/if}
</div>
<p class="text-xs text-gray-500 mt-2">
Select a province to filter cities, or leave empty to view all cities
</p>
</div>
<!-- Search -->
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder={selectedProvince
? `Search cities in ${selectedProvinceLabel}...`
: "Search cities by name..."
}
class="input input-bordered w-full pl-10"
bind:value={citySearch}
/>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if loading}
<div class="text-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-gray-500 mt-4">{loadingMessage}</p>
</div>
{:else if filteredCities.length === 0}
<div class="text-center py-12">
{#if citySearch.trim()}
<Search class="w-12 h-12 mx-auto text-gray-300 mb-3" />
<p class="text-gray-500">No cities match "{citySearch}"</p>
<button class="btn btn-sm btn-ghost mt-2" onclick={() => citySearch = ''}>
Clear search
</button>
{:else if selectedProvince}
<Building2 class="w-12 h-12 mx-auto text-gray-300 mb-3" />
<p class="text-gray-500">No cities found for {selectedProvinceLabel}</p>
<p class="text-sm text-gray-400 mt-1">Try selecting a different province</p>
{:else}
<Building2 class="w-12 h-12 mx-auto text-gray-300 mb-3" />
<p class="text-gray-500">No cities found</p>
{/if}
</div>
{:else}
<DataTable
columns={cityColumns}
data={cities}
{loading}
data={filteredCities}
loading={false}
emptyMessage="No cities found"
hover={true}
bordered={false}
/>
{/if}
</div>
</div>
{:else if activeTab === 'areas'}
<div class="space-y-4">
<!-- Tab Description -->
<div class="flex items-start gap-2 text-sm text-gray-600 bg-base-200 p-3 rounded-lg">
<Globe class="w-4 h-4 mt-0.5 shrink-0" />
<div class="flex-1">
<span class="font-medium">All Areas</span> shows the complete geographical hierarchy including
provinces, cities, districts, and villages with their relationships.
<HelpTooltip
text="Areas represent the complete geographical hierarchy from provinces down to villages. Each area has a class indicating its level in the hierarchy and may have a parent area."
position="right"
/>
</div>
</div>
<!-- Search -->
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Search areas by name, code, or class..."
class="input input-bordered w-full pl-10"
bind:value={areaSearch}
/>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if loading}
<div class="text-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-gray-500 mt-4">{loadingMessage}</p>
</div>
{:else if filteredAreas.length === 0}
<div class="text-center py-12">
{#if areaSearch.trim()}
<Search class="w-12 h-12 mx-auto text-gray-300 mb-3" />
<p class="text-gray-500">No areas match "{areaSearch}"</p>
<button class="btn btn-sm btn-ghost mt-2" onclick={() => areaSearch = ''}>
Clear search
</button>
{:else}
<Globe class="w-12 h-12 mx-auto text-gray-300 mb-3" />
<p class="text-gray-500">No geographical areas found</p>
{/if}
</div>
{:else}
<DataTable
columns={areaColumns}
data={areas}
{loading}
data={filteredAreas}
loading={false}
emptyMessage="No geographical areas found"
hover={true}
bordered={false}
/>
>
{#snippet cell({ column, row, value })}
{#if column.key === 'Class'}
{@const IconComponent = getAreaClassIcon(value)}
<div class="flex items-center gap-2">
<IconComponent class="w-4 h-4 text-primary" />
<span>{value}</span>
</div>
{:else}
{value}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
<!-- Area Class Legend -->
{#if filteredAreas.length > 0}
<div class="bg-base-200 p-4 rounded-lg">
<h4 class="text-sm font-medium mb-3 flex items-center gap-2">
<Map class="w-4 h-4" />
Area Class Types
</h4>
<div class="flex flex-wrap gap-2">
{#each Object.entries(areaClassIcons) as [className, IconComponent] (className)}
<div class="badge badge-outline gap-1">
<IconComponent class="w-3 h-3" />
{className}
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
</div>

View File

@ -5,7 +5,8 @@
import DataTable from '$lib/components/DataTable.svelte';
import Modal from '$lib/components/Modal.svelte';
import SelectDropdown from '$lib/components/SelectDropdown.svelte';
import { Plus, Edit2, Trash2, ArrowLeft } from 'lucide-svelte';
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
import { Plus, Edit2, Trash2, ArrowLeft, Search, MapPin } from 'lucide-svelte';
let loading = $state(false);
let locations = $state([]);
@ -15,6 +16,8 @@
let formData = $state({ LocationID: null, Code: '', Name: '', Type: '', ParentID: null });
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
let deleting = $state(false);
let searchQuery = $state('');
const typeLabels = {
ROOM: 'Room',
@ -23,6 +26,13 @@
AREA: 'Area',
};
const typeBadgeColors = {
ROOM: 'badge-info',
BUILDING: 'badge-success',
FLOOR: 'badge-warning',
AREA: 'badge-secondary',
};
const columns = [
{ key: 'LocCode', label: 'Code', class: 'font-medium' },
{ key: 'LocFull', label: 'Name' },
@ -47,6 +57,17 @@
}
}
const filteredLocations = $derived(
locations.filter((l) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return (
(l.LocCode && l.LocCode.toLowerCase().includes(query)) ||
(l.LocFull && l.LocFull.toLowerCase().includes(query))
);
})
);
function openCreateModal() {
modalMode = 'create';
formData = { LocationID: null, Code: '', Name: '', Type: '', ParentID: null };
@ -65,7 +86,34 @@
modalOpen = true;
}
function isDuplicateCode(code, excludeId = null) {
return locations.some(
(l) =>
l.LocCode.toLowerCase() === code.toLowerCase() &&
l.LocationID !== excludeId
);
}
async function handleSave() {
if (!formData.Code.trim()) {
toastError('Location code is required');
return;
}
if (!formData.Name.trim()) {
toastError('Location name is required');
return;
}
if (!formData.Type) {
toastError('Location type is required');
return;
}
const excludeId = modalMode === 'edit' ? formData.LocationID : null;
if (isDuplicateCode(formData.Code, excludeId)) {
toastError(`Location code "${formData.Code}" already exists. Please use a unique code.`);
return;
}
saving = true;
try {
if (modalMode === 'create') {
@ -90,21 +138,29 @@
}
async function handleDelete() {
deleting = true;
try {
await deleteLocation(deleteItem.LocationID);
toastSuccess('Location deleted successfully');
deleteConfirmOpen = false;
deleteItem = null;
await loadLocations();
} catch (err) {
toastError(err.message || 'Failed to delete location');
} finally {
deleting = false;
}
}
const parentOptions = $derived(
locations
.filter((l) => l.LocationID !== formData.LocationID)
.map((l) => ({ value: l.LocationID.toString(), label: l.LocFull }))
.map((l) => ({ value: l.LocationID.toString(), label: `${l.LocCode} - ${l.LocFull}` }))
);
function getTypeBadgeClass(type) {
return typeBadgeColors[type] || 'badge-ghost';
}
</script>
<div class="p-6">
@ -123,9 +179,49 @@
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
<div class="p-4 border-b border-base-200">
<div class="flex items-center gap-3 max-w-md">
<div class="relative flex-1">
<Search class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
class="input input-bordered w-full pl-10"
placeholder="Search by code or name..."
bind:value={searchQuery}
/>
</div>
{#if searchQuery}
<button class="btn btn-ghost btn-sm" onclick={() => (searchQuery = '')}>
Clear
</button>
{/if}
</div>
</div>
{#if !loading && filteredLocations.length === 0}
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center mb-4">
<MapPin class="w-8 h-8 text-gray-400" />
</div>
<h3 class="text-lg font-semibold text-base-content mb-1">
{searchQuery ? 'No locations found' : 'No locations yet'}
</h3>
<p class="text-sm text-base-content/60 text-center max-w-sm mb-4">
{searchQuery
? `No locations match your search "${searchQuery}". Try a different search term.`
: 'Get started by adding your first location to organize your facilities.'}
</p>
{#if !searchQuery}
<button class="btn btn-primary btn-sm" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Your First Location
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={locations.map((l) => ({
data={filteredLocations.map((l) => ({
...l,
LocTypeLabel: typeLabels[l.LocType] || l.LocType || '-',
}))}
@ -137,18 +233,23 @@
{#snippet cell({ column, row, value })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)}>
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)} aria-label="Edit {row.LocFull}">
<Edit2 class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)}>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)} aria-label="Delete {row.LocFull}">
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else if column.key === 'LocTypeLabel'}
<span class="badge {getTypeBadgeClass(row.LocType)}">
{value}
</span>
{:else}
{value}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
</div>
@ -157,7 +258,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="code">
<span class="label-text font-medium">Code</span>
<span class="label-text font-medium">Location Code</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
@ -165,13 +266,16 @@
type="text"
class="input input-bordered w-full"
bind:value={formData.Code}
placeholder="Enter code"
placeholder="e.g., BLDG-01, ROOM-101"
required
/>
<label class="label" for="code">
<span class="label-text-alt text-gray-500">Unique identifier for this location</span>
</label>
</div>
<div class="form-control">
<label class="label" for="name">
<span class="label-text font-medium">Name</span>
<span class="label-text font-medium">Location Name</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
@ -179,15 +283,18 @@
type="text"
class="input input-bordered w-full"
bind:value={formData.Name}
placeholder="Enter name"
placeholder="e.g., Main Building, Laboratory Room A"
required
/>
<label class="label" for="name">
<span class="label-text-alt text-gray-500">Descriptive name for this location</span>
</label>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="type">
<span class="label-text font-medium">Type</span>
<span class="label-text font-medium">Location Type</span>
<span class="label-text-alt text-error">*</span>
</label>
<select
@ -203,14 +310,29 @@
<option value="FLOOR">Floor</option>
<option value="AREA">Area</option>
</select>
<label class="label" for="type">
<span class="label-text-alt text-gray-500">Category of this location</span>
</label>
</div>
<div class="form-control">
<label class="label" for="parent">
<span class="label-text font-medium">Parent Location</span>
<HelpTooltip
text="Select a parent location to create a hierarchy. For example, a Room can be inside a Building, or a Floor can be part of a Building."
title="Location Hierarchy"
position="top"
/>
</label>
<SelectDropdown
label="Parent Location"
name="parent"
bind:value={formData.ParentID}
options={parentOptions}
placeholder="None"
placeholder="None (top-level location)"
/>
<label class="label" for="parent">
<span class="label-text-alt text-gray-500">Optional: parent location in hierarchy</span>
</label>
</div>
</div>
</form>
{#snippet footer()}
@ -227,12 +349,28 @@
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete" size="sm">
<div class="py-2">
<p class="text-base-content/80">
Are you sure you want to delete <strong class="text-base-content">{deleteItem?.LocFull}</strong>?
Are you sure you want to delete the following location?
</p>
<p class="text-sm text-error mt-2">This action cannot be undone.</p>
<div class="bg-base-200 rounded-lg p-3 mt-3">
<p class="font-semibold text-base-content">{deleteItem?.LocFull}</p>
<p class="text-sm text-base-content/60">Code: {deleteItem?.LocCode}</p>
{#if deleteItem?.LocType}
<span class="badge badge-sm {getTypeBadgeClass(deleteItem.LocType)} mt-2">
{typeLabels[deleteItem.LocType]}
</span>
{/if}
</div>
<p class="text-sm text-error mt-4">This action cannot be undone. Any child locations will become top-level locations.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} type="button">Cancel</button>
<button class="btn btn-error" onclick={handleDelete} type="button">Delete</button>
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} disabled={deleting} type="button">
Cancel
</button>
<button class="btn btn-error" onclick={handleDelete} disabled={deleting} type="button">
{#if deleting}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{deleting ? 'Deleting...' : 'Delete'}
</button>
{/snippet}
</Modal>

View File

@ -4,7 +4,8 @@
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import DataTable from '$lib/components/DataTable.svelte';
import Modal from '$lib/components/Modal.svelte';
import { Plus, Edit2, ArrowLeft } from 'lucide-svelte';
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
import { Plus, Edit2, ArrowLeft, Search, Briefcase, Users } from 'lucide-svelte';
let loading = $state(false);
let occupations = $state([]);
@ -12,14 +13,73 @@
let modalMode = $state('create');
let saving = $state(false);
let formData = $state({ OccupationID: null, OccCode: '', OccText: '', Description: '' });
let formErrors = $state({});
// Search state
let searchQuery = $state('');
const columns = [
{ key: 'OccCode', label: 'Code', class: 'font-medium' },
{ key: 'OccText', label: 'Occupation' },
{ key: 'OccCode', label: 'Code', class: 'font-medium w-32' },
{ key: 'OccText', label: 'Occupation Name', class: 'font-medium' },
{ key: 'Description', label: 'Description' },
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
];
// Derived filtered occupations based on search query
let filteredOccupations = $derived(
searchQuery.trim()
? occupations.filter(occupation => {
const query = searchQuery.toLowerCase().trim();
return (
(occupation.OccCode && occupation.OccCode.toLowerCase().includes(query)) ||
(occupation.OccText && occupation.OccText.toLowerCase().includes(query))
);
})
: occupations
);
// Check for duplicate occupation code
function isDuplicateCode(code, excludeId = null) {
const normalizedCode = code.trim().toUpperCase();
return occupations.some(
occ =>
occ.OccCode.trim().toUpperCase() === normalizedCode &&
occ.OccupationID !== excludeId
);
}
// Validate form before submission
function validateForm() {
formErrors = {};
let isValid = true;
if (!formData.OccCode || formData.OccCode.trim() === '') {
formErrors.OccCode = 'Occupation code is required';
isValid = false;
} else if (formData.OccCode.trim().length > 10) {
formErrors.OccCode = 'Code must be 10 characters or less';
isValid = false;
} else if (isDuplicateCode(formData.OccCode, formData.OccupationID)) {
formErrors.OccCode = `Code "${formData.OccCode.trim()}" already exists. Please use a unique code.`;
isValid = false;
}
if (!formData.OccText || formData.OccText.trim() === '') {
formErrors.OccText = 'Occupation name is required';
isValid = false;
} else if (formData.OccText.trim().length > 100) {
formErrors.OccText = 'Name must be 100 characters or less';
isValid = false;
}
if (formData.Description && formData.Description.length > 255) {
formErrors.Description = 'Description must be 255 characters or less';
isValid = false;
}
return isValid;
}
onMount(async () => {
await loadOccupations();
});
@ -37,9 +97,17 @@
}
}
function handleSearchKeydown(event) {
if (event.key === 'Enter') {
// Search is reactive via $derived, but this allows for future server-side search
event.preventDefault();
}
}
function openCreateModal() {
modalMode = 'create';
formData = { OccupationID: null, OccCode: '', OccText: '', Description: '' };
formErrors = {};
modalOpen = true;
}
@ -51,10 +119,15 @@
OccText: row.OccText || '',
Description: row.Description || '',
};
formErrors = {};
modalOpen = true;
}
async function handleSave() {
if (!validateForm()) {
return;
}
saving = true;
try {
if (modalMode === 'create') {
@ -72,6 +145,13 @@
saving = false;
}
}
function handleModalClose() {
if (!saving) {
modalOpen = false;
formErrors = {};
}
}
</script>
<div class="p-6">
@ -81,7 +161,7 @@
</a>
<div class="flex-1">
<h1 class="text-3xl font-bold text-gray-800">Occupations</h1>
<p class="text-gray-600">Manage occupation codes</p>
<p class="text-gray-600">Manage occupation codes for patient demographics</p>
</div>
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
@ -89,10 +169,56 @@
</button>
</div>
<!-- Search Section -->
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1 relative">
<Search class="w-5 h-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search by code or occupation name..."
class="input input-bordered w-full pl-10"
bind:value={searchQuery}
onkeydown={handleSearchKeydown}
/>
</div>
</div>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if !loading && filteredOccupations.length === 0}
<!-- Empty State -->
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="bg-base-200 rounded-full p-6 mb-4">
{#if searchQuery.trim()}
<Search class="w-12 h-12 text-gray-400" />
{:else}
<Briefcase class="w-12 h-12 text-gray-400" />
{/if}
</div>
<h3 class="text-lg font-semibold text-gray-700 mb-2">
{searchQuery.trim() ? 'No occupations match your search' : 'No occupations found'}
</h3>
<p class="text-gray-500 text-center max-w-md mb-6">
{searchQuery.trim()
? `No occupations found matching "${searchQuery}". Try a different search term or clear the filter.`
: 'Get started by adding your first occupation code. These codes are used when registering patients to identify their profession.'}
</p>
{#if searchQuery.trim()}
<button class="btn btn-outline" onclick={() => searchQuery = ''}>
Clear Search
</button>
{:else}
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Occupation
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={occupations}
data={filteredOccupations}
{loading}
emptyMessage="No occupations found"
hover={true}
@ -101,16 +227,36 @@
{#snippet cell({ column, row, value })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)}>
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)} title="Edit occupation">
<Edit2 class="w-4 h-4" />
</button>
</div>
{:else if column.key === 'OccCode'}
<span class="badge badge-outline badge-primary font-mono text-xs">{value}</span>
{:else if column.key === 'Description'}
<span class="text-gray-500 text-sm">{value || '-'}</span>
{:else}
{value}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
<!-- Info Card -->
{#if !loading && filteredOccupations.length > 0}
<div class="mt-4 bg-blue-50 border border-blue-200 rounded-lg p-4 flex items-start gap-3">
<Users class="w-5 h-5 text-blue-500 mt-0.5 flex-shrink-0" />
<div>
<h4 class="text-sm font-medium text-blue-900">About Occupation Codes</h4>
<p class="text-sm text-blue-700 mt-1">
Occupation codes are used during patient registration to record the patient's profession.
These codes help with demographic tracking and may be used for reporting purposes.
Keep codes short and memorable (e.g., DR for Doctor, ENG for Engineer).
</p>
</div>
</div>
{/if}
</div>
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Occupation' : 'Edit Occupation'} size="md">
@ -118,17 +264,31 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="occCode">
<span class="label-text font-medium">Code</span>
<span class="label-text font-medium flex items-center gap-2">
Occupation Code
<HelpTooltip
text="A short, unique code used to identify this occupation. This code will be displayed in patient demographics. Examples: DR (Doctor), NUR (Nurse), ENG (Engineer), TCH (Teacher). Keep it short (2-5 characters) for easy reference."
title="Occupation Code Help"
position="top"
/>
</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
id="occCode"
type="text"
class="input input-bordered w-full"
class="input input-bordered w-full {formErrors.OccCode ? 'input-error' : ''}"
bind:value={formData.OccCode}
placeholder="Enter occupation code"
placeholder="e.g., DR, ENG, TCH"
required
maxlength="10"
disabled={saving}
/>
{#if formErrors.OccCode}
<span class="label-text-alt text-error mt-1">{formErrors.OccCode}</span>
{:else}
<span class="label-text-alt text-gray-500">Short unique code (max 10 characters)</span>
{/if}
</div>
<div class="form-control">
<label class="label" for="occText">
@ -138,11 +298,18 @@
<input
id="occText"
type="text"
class="input input-bordered w-full"
class="input input-bordered w-full {formErrors.OccText ? 'input-error' : ''}"
bind:value={formData.OccText}
placeholder="Enter occupation name"
placeholder="e.g., Doctor, Engineer, Teacher"
required
maxlength="100"
disabled={saving}
/>
{#if formErrors.OccText}
<span class="label-text-alt text-error mt-1">{formErrors.OccText}</span>
{:else}
<span class="label-text-alt text-gray-500">Full name of the occupation</span>
{/if}
</div>
</div>
<div class="form-control">
@ -152,14 +319,21 @@
<input
id="description"
type="text"
class="input input-bordered w-full"
class="input input-bordered w-full {formErrors.Description ? 'input-error' : ''}"
bind:value={formData.Description}
placeholder="Enter description (optional)"
placeholder="e.g., Medical practitioner, licensed physician"
maxlength="255"
disabled={saving}
/>
{#if formErrors.Description}
<span class="label-text-alt text-error mt-1">{formErrors.Description}</span>
{:else}
<span class="label-text-alt text-gray-500">Optional additional details about this occupation</span>
{/if}
</div>
</form>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (modalOpen = false)} type="button">Cancel</button>
<button class="btn btn-ghost" onclick={handleModalClose} type="button" disabled={saving}>Cancel</button>
<button class="btn btn-primary" onclick={handleSave} disabled={saving} type="button">
{#if saving}
<span class="loading loading-spinner loading-sm mr-2"></span>

View File

@ -5,7 +5,8 @@
import DataTable from '$lib/components/DataTable.svelte';
import Modal from '$lib/components/Modal.svelte';
import SelectDropdown from '$lib/components/SelectDropdown.svelte';
import { Plus, Edit2, Trash2, ArrowLeft } from 'lucide-svelte';
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
import { Plus, Edit2, Trash2, ArrowLeft, Search, Stethoscope, FolderTree } from 'lucide-svelte';
let loading = $state(false);
let specialties = $state([]);
@ -15,11 +16,13 @@
let formData = $state({ SpecialtyID: null, SpecialtyText: '', Title: '', Parent: '' });
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
let deleting = $state(false);
let searchQuery = $state('');
const columns = [
{ key: 'SpecialtyText', label: 'Specialty Name', class: 'font-medium' },
{ key: 'Title', label: 'Title' },
{ key: 'ParentLabel', label: 'Parent' },
{ key: 'ParentLabel', label: 'Parent Specialty' },
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
];
@ -40,6 +43,18 @@
}
}
// Filter specialties based on search query
const filteredSpecialties = $derived(
specialties.filter((s) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
const nameMatch = (s.SpecialtyText || '').toLowerCase().includes(query);
const titleMatch = (s.Title || '').toLowerCase().includes(query);
const parentMatch = (s.ParentLabel || '').toLowerCase().includes(query);
return nameMatch || titleMatch || parentMatch;
})
);
function openCreateModal() {
modalMode = 'create';
formData = { SpecialtyID: null, SpecialtyText: '', Title: '', Parent: '' };
@ -57,7 +72,23 @@
modalOpen = true;
}
// Check for duplicate specialty name
function isDuplicateName(name, excludeId = null) {
const normalizedName = name.trim().toLowerCase();
return specialties.some(
(s) =>
s.SpecialtyText.trim().toLowerCase() === normalizedName &&
s.SpecialtyID !== excludeId
);
}
async function handleSave() {
// Validate for duplicate names
if (isDuplicateName(formData.SpecialtyText, formData.SpecialtyID)) {
toastError(`A specialty with the name "${formData.SpecialtyText}" already exists`);
return;
}
saving = true;
try {
if (modalMode === 'create') {
@ -82,6 +113,7 @@
}
async function handleDelete() {
deleting = true;
try {
await deleteSpecialty(deleteItem.SpecialtyID);
toastSuccess('Specialty deleted successfully');
@ -89,6 +121,8 @@
await loadSpecialties();
} catch (err) {
toastError(err.message || 'Failed to delete specialty');
} finally {
deleting = false;
}
}
@ -101,6 +135,12 @@
const specialtyMap = $derived(
Object.fromEntries(specialties.map((s) => [s.SpecialtyID, s.SpecialtyText]))
);
// Get parent specialty name
function getParentName(parentId) {
if (!parentId || parentId === '0') return null;
return specialtyMap[parentId] || null;
}
</script>
<div class="p-6">
@ -110,7 +150,7 @@
</a>
<div class="flex-1">
<h1 class="text-3xl font-bold text-gray-800">Medical Specialties</h1>
<p class="text-gray-600">Manage medical specialty codes</p>
<p class="text-gray-600">Manage medical specialty codes and their hierarchical relationships</p>
</div>
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
@ -118,12 +158,61 @@
</button>
</div>
<!-- Search Bar -->
<div class="mb-4">
<div class="relative max-w-md">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
class="input input-bordered w-full pl-10"
placeholder="Search by specialty name, title, or parent..."
bind:value={searchQuery}
/>
{#if searchQuery}
<button
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-xs btn-ghost btn-circle"
onclick={() => (searchQuery = '')}
aria-label="Clear search"
>
×
</button>
{/if}
</div>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if filteredSpecialties.length === 0 && !loading}
<!-- Empty State -->
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="bg-base-200 rounded-full p-6 mb-4">
<Stethoscope class="w-12 h-12 text-base-content/40" />
</div>
<h3 class="text-lg font-semibold text-base-content mb-2">
{searchQuery ? 'No specialties match your search' : 'No specialties yet'}
</h3>
<p class="text-base-content/60 text-center max-w-sm mb-4">
{searchQuery
? 'Try adjusting your search terms or clear the filter to see all specialties.'
: 'Get started by adding your first medical specialty to the system.'}
</p>
{#if searchQuery}
<button class="btn btn-ghost btn-sm" onclick={() => (searchQuery = '')}>
Clear Search
</button>
{:else}
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add First Specialty
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={specialties.map((s) => ({
data={filteredSpecialties.map((s) => ({
...s,
ParentLabel: s.Parent === '0' || !s.Parent ? '-' : specialtyMap[s.Parent] || '-',
hasParent: s.Parent && s.Parent !== '0',
}))}
{loading}
emptyMessage="No specialties found"
@ -140,11 +229,25 @@
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else if column.key === 'SpecialtyText'}
<div class="flex items-center gap-2">
{#if row.hasParent}
<span class="text-base-content/40 ml-4">└─</span>
{/if}
<span class={row.hasParent ? 'text-base-content/80' : ''}>{value}</span>
{#if row.hasParent}
<span class="badge badge-sm badge-ghost">
<FolderTree class="w-3 h-3 mr-1" />
Child
</span>
{/if}
</div>
{:else}
{value}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
</div>
@ -161,30 +264,53 @@
type="text"
class="input input-bordered w-full"
bind:value={formData.SpecialtyText}
placeholder="Enter specialty name"
placeholder="e.g., Cardiology, Internal Medicine"
required
/>
<label class="label" for="specialtyText">
<span class="label-text-alt text-base-content/50">Unique name for the specialty</span>
</label>
</div>
<div class="form-control">
<label class="label" for="title">
<span class="label-text font-medium">Title</span>
<span class="label-text font-medium">Professional Title</span>
</label>
<input
id="title"
type="text"
class="input input-bordered w-full"
bind:value={formData.Title}
placeholder="e.g., Sp. A, Sp. And"
placeholder="e.g., Sp. PD, Sp. A, Sp. And"
/>
<label class="label" for="title">
<span class="label-text-alt text-base-content/50">Official abbreviation or title</span>
</label>
</div>
</div>
<div class="form-control">
<div class="flex items-center gap-2 mb-1">
<label class="label py-0" for="parent">
<span class="label-text font-medium">Parent Specialty</span>
</label>
<HelpTooltip
text="Organize specialties hierarchically. For example, select 'Internal Medicine' as the parent for 'Cardiology' to show it as a subspecialty."
title="Parent Specialty Hierarchy"
position="right"
/>
</div>
</div>
<SelectDropdown
label="Parent Specialty"
label=""
name="parent"
bind:value={formData.Parent}
options={parentOptions}
placeholder="None"
placeholder="None (Top-level specialty)"
/>
<label class="label" for="parent">
<span class="label-text-alt text-base-content/50">
Optional: Select a parent specialty to create a subspecialty
</span>
</label>
</div>
</form>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (modalOpen = false)} type="button">Cancel</button>
@ -197,15 +323,38 @@
{/snippet}
</Modal>
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete" size="sm">
<div class="py-2">
<p class="text-base-content/80">
Are you sure you want to delete <strong class="text-base-content">{deleteItem?.SpecialtyText}</strong>?
<Modal bind:open={deleteConfirmOpen} title="Confirm Deletion" size="md">
<div class="py-4 space-y-4">
<div class="flex items-start gap-3">
<div class="bg-error/10 rounded-full p-2">
<Trash2 class="w-6 h-6 text-error" />
</div>
<div>
<p class="text-base-content font-medium">
Are you sure you want to delete this specialty?
</p>
<p class="text-sm text-error mt-2">This action cannot be undone.</p>
<p class="text-base-content/70 mt-1">
<strong class="text-base-content">{deleteItem?.SpecialtyText}</strong>
{#if deleteItem?.Title}
<span class="text-base-content/50">({deleteItem?.Title})</span>
{/if}
</p>
</div>
</div>
<div class="bg-warning/10 border border-warning/20 rounded-lg p-3">
<p class="text-sm text-warning">
<strong>Warning:</strong> This action cannot be undone. If this specialty has child specialties,
they may become orphaned and need to be reassigned.
</p>
</div>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} type="button">Cancel</button>
<button class="btn btn-error" onclick={handleDelete} type="button">Delete</button>
<button class="btn btn-error" onclick={handleDelete} disabled={deleting} type="button">
{#if deleting}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{deleting ? 'Deleting...' : 'Delete Specialty'}
</button>
{/snippet}
</Modal>

File diff suppressed because it is too large Load Diff

View File

@ -3,7 +3,8 @@
import { fetchValueSets, fetchValueSetByKey, refreshValueSets } from '$lib/api/valuesets.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import DataTable from '$lib/components/DataTable.svelte';
import { Search, RefreshCw, ArrowLeft } from 'lucide-svelte';
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
import { Search, RefreshCw, ArrowLeft, Copy, ListX, Hash } from 'lucide-svelte';
let loading = $state(false);
let refreshing = $state(false);
@ -11,6 +12,7 @@
let searchQuery = $state('');
let selectedValueSet = $state(null);
let detailLoading = $state(false);
let copiedKey = $state('');
const columns = [
{ key: 'ValueSetKey', label: 'Key', class: 'font-medium' },
@ -18,6 +20,11 @@
{ key: 'ItemCount', label: 'Items', class: 'w-24 text-center' },
];
const itemColumns = [
{ key: 'value', label: 'Value', class: 'font-medium w-32' },
{ key: 'label', label: 'Label' },
];
onMount(async () => {
await loadValueSets();
});
@ -103,6 +110,19 @@
loadValueSets();
}
}
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
copiedKey = text;
toastSuccess('ValueSet key copied to clipboard');
setTimeout(() => {
copiedKey = '';
}, 2000);
} catch (err) {
toastError('Failed to copy to clipboard');
}
}
</script>
<div class="p-6 h-[calc(100vh-4rem)] flex flex-col">
@ -111,8 +131,15 @@
<a href="/master-data" class="btn btn-ghost btn-circle">
<ArrowLeft class="w-5 h-5" />
</a>
<div>
<div class="flex-1">
<div class="flex items-center gap-3">
<h1 class="text-3xl font-bold text-gray-800">ValueSets</h1>
<HelpTooltip
text="ValueSets are reusable lookup tables used throughout the system for dropdown menus, form fields, and standardized values. They ensure consistency in data entry and reporting."
title="About ValueSets"
position="right"
/>
</div>
<p class="text-gray-600">System lookup values and dropdown options</p>
</div>
</div>
@ -126,7 +153,7 @@
<div class="flex-1 relative">
<input
type="text"
placeholder="Search by key..."
placeholder="Search ValueSets by key (e.g., priority_status, test_category)..."
class="input input-bordered w-full pl-10"
bind:value={searchQuery}
onkeydown={handleSearchKeydown}
@ -167,7 +194,7 @@
{columns}
data={valueSets}
{loading}
emptyMessage="No ValueSets found"
emptyMessage="No ValueSets found matching your search"
hover={true}
bordered={false}
onRowClick={handleRowClick}
@ -187,46 +214,61 @@
<!-- Header -->
<div class="border-b border-base-300 pb-4">
<h2 class="text-2xl font-bold text-gray-800">{selectedValueSet.Name}</h2>
<p class="text-gray-500 mt-1">{selectedValueSet.ValueSetKey}</p>
<div class="flex items-center gap-2 mt-2">
<code class="px-2 py-1 bg-base-200 rounded text-sm font-mono text-gray-600">
{selectedValueSet.ValueSetKey}
</code>
<button
class="btn btn-xs btn-ghost btn-circle"
onclick={() => copyToClipboard(selectedValueSet.ValueSetKey)}
title="Copy ValueSet key"
>
{#if copiedKey === selectedValueSet.ValueSetKey}
<svg class="w-4 h-4 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
{:else}
<Copy class="w-4 h-4 opacity-60" />
{/if}
</button>
</div>
</div>
<!-- Items Table -->
<div>
<h3 class="font-semibold mb-3 flex items-center gap-2">
<Hash class="w-4 h-4 opacity-60" />
<span>Items</span>
<span class="badge badge-primary badge-sm">{selectedValueSet.Items?.length || 0}</span>
<span class="badge badge-primary badge-sm">
{selectedValueSet.Items?.length || 0} items
</span>
</h3>
{#if selectedValueSet.Items && selectedValueSet.Items.length > 0}
<div class="overflow-x-auto">
<table class="table table-sm w-full">
<thead>
<tr class="bg-base-200">
<th class="w-24">Value</th>
<th>Label</th>
</tr>
</thead>
<tbody>
{#each selectedValueSet.Items as item}
<tr class="hover">
<td class="font-medium">{item.value}</td>
<td>{item.label}</td>
</tr>
{/each}
</tbody>
</table>
<div class="bg-base-100 rounded-lg border border-base-200 overflow-hidden">
<DataTable
columns={itemColumns}
data={selectedValueSet.Items}
loading={false}
emptyMessage="No items in this ValueSet"
hover={true}
bordered={false}
compact={true}
/>
</div>
{:else}
<p class="text-gray-500 text-center py-8">No items in this ValueSet</p>
<div class="flex flex-col items-center justify-center py-8 text-gray-400 bg-base-50 rounded-lg border border-dashed border-base-300">
<ListX class="w-8 h-8 mb-2 opacity-50" />
<p class="text-sm">No items in this ValueSet</p>
</div>
{/if}
</div>
</div>
{:else}
<div class="flex flex-col items-center justify-center h-full min-h-[300px] text-gray-400">
<svg class="w-16 h-16 mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
<p>Select a ValueSet to view details</p>
<ListX class="w-16 h-16 mb-4 opacity-50" />
<p class="text-lg font-medium">No ValueSet Selected</p>
<p class="text-sm mt-1">Click on a ValueSet from the list to view its details</p>
</div>
{/if}
</div>

View File

@ -1,13 +1,14 @@
<script>
import { onMount } from 'svelte';
import { fetchPatients, fetchPatient, deletePatient } from '$lib/api/patients.js';
import { fetchVisitsByPatient, deleteVisit, updateVisit, createADT } from '$lib/api/visits.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import DataTable from '$lib/components/DataTable.svelte';
import Modal from '$lib/components/Modal.svelte';
import PatientFormModal from './PatientFormModal.svelte';
import PatientDetailModal from './PatientDetailModal.svelte';
import VisitListModal from './VisitListModal.svelte';
import { Plus, Edit2, Trash2, Search, ChevronLeft, ChevronRight, User, Calendar } from 'lucide-svelte';
import VisitFormModal from './VisitFormModal.svelte';
import { Plus, Edit2, Trash2, Search, ChevronLeft, ChevronRight, Calendar, MapPin, Clock, FileText, History } from 'lucide-svelte';
import VisitADTHistoryModal from './VisitADTHistoryModal.svelte';
// State
let loading = $state(false);
@ -18,14 +19,30 @@
let totalItems = $state(0);
let totalPages = $state(1);
// Selected patient and visits
let selectedPatient = $state(null);
let visits = $state([]);
let visitsLoading = $state(false);
// Modal states
let patientFormOpen = $state(false);
let patientDetailOpen = $state(false);
let deleteConfirmOpen = $state(false);
let visitListOpen = $state(false);
let selectedPatient = $state(null);
let patientToDelete = $state(null);
let patientFormLoading = $state(false);
let visitFormOpen = $state(false);
let selectedVisit = $state(null);
let adtHistoryOpen = $state(false);
let adtHistoryVisit = $state(null);
// Visit delete state
let visitDeleteConfirmOpen = $state(false);
let visitToDelete = $state(null);
// Discharge state
let dischargeModalOpen = $state(false);
let visitToDischarge = $state(null);
let dischargeDate = $state('');
let discharging = $state(false);
const columns = [
{ key: 'PatientID', label: 'Patient ID', class: 'font-medium' },
@ -72,13 +89,18 @@
}
}
function selectPatient(patient) {
selectedPatient = patient;
loadVisits();
}
function openCreateModal() {
selectedPatient = null;
patientFormOpen = true;
}
async function openEditModal(patient) {
// Reset selectedPatient to null first to prevent showing stale data
async function openEditModal(patient, event) {
event.stopPropagation();
selectedPatient = null;
patientFormLoading = true;
patientFormOpen = true;
@ -93,17 +115,8 @@
}
}
function openDetailModal(patient) {
selectedPatient = patient;
patientDetailOpen = true;
}
function openVisitList(patient) {
selectedPatient = patient;
visitListOpen = true;
}
function confirmDelete(patient) {
function confirmDelete(patient, event) {
event.stopPropagation();
patientToDelete = patient;
deleteConfirmOpen = true;
}
@ -122,13 +135,130 @@
function handlePatientSaved() {
loadPatients();
}
async function loadVisits() {
if (!selectedPatient?.InternalPID) {
visits = [];
return;
}
visitsLoading = true;
try {
const response = await fetchVisitsByPatient(selectedPatient.InternalPID);
if (Array.isArray(response)) {
visits = response;
} else if (response.data && Array.isArray(response.data)) {
visits = response.data;
} else if (response.visits && Array.isArray(response.visits)) {
visits = response.visits;
} else {
visits = [];
}
} catch (err) {
toastError(err.message || 'Failed to load visits');
visits = [];
} finally {
visitsLoading = false;
}
}
function openCreateVisit() {
selectedVisit = null;
visitFormOpen = true;
}
function openEditVisit(visit) {
selectedVisit = visit;
visitFormOpen = true;
}
function handleVisitSaved() {
loadVisits();
}
function confirmDeleteVisit(visit, event) {
if (event) event.stopPropagation();
visitToDelete = visit;
visitDeleteConfirmOpen = true;
}
async function handleDeleteVisit() {
if (!visitToDelete?.InternalPVID) return;
try {
await deleteVisit(visitToDelete.InternalPVID);
toastSuccess('Visit deleted successfully');
visitDeleteConfirmOpen = false;
visitToDelete = null;
await loadVisits();
} catch (err) {
toastError(err.message || 'Failed to delete visit');
}
}
function openDischargeModal(visit, event) {
if (event) event.stopPropagation();
visitToDischarge = visit;
dischargeDate = new Date().toISOString().slice(0, 16);
dischargeModalOpen = true;
}
async function handleDischarge() {
if (!visitToDischarge?.InternalPVID) return;
discharging = true;
try {
// Update visit with EndDate
const updatePayload = {
InternalPVID: visitToDischarge.InternalPVID,
EndDate: dischargeDate,
};
await updateVisit(updatePayload);
// Create A03 ADT record
try {
const adtPayload = {
InternalPVID: visitToDischarge.InternalPVID,
ADTCode: 'A03',
LocationID: visitToDischarge.LocationID,
LocCode: visitToDischarge.LocCode,
AttDoc: visitToDischarge.AttDoc,
AdmDoc: visitToDischarge.AdmDoc,
RefDoc: visitToDischarge.RefDoc,
CnsDoc: visitToDischarge.CnsDoc,
};
await createADT(adtPayload);
} catch (adtErr) {
console.warn('Failed to create ADT record:', adtErr);
}
toastSuccess('Patient discharged successfully');
dischargeModalOpen = false;
visitToDischarge = null;
await loadVisits();
} catch (err) {
toastError(err.message || 'Failed to discharge patient');
} finally {
discharging = false;
}
}
function formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString();
}
function formatDateTime(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleString();
}
</script>
<div class="p-6">
<div class="flex items-center gap-4 mb-6">
<div class="p-6 h-[calc(100vh-4rem)] flex flex-col">
<div class="flex items-center gap-4 mb-4">
<div class="flex-1">
<h1 class="text-3xl font-bold text-gray-800">Patients</h1>
<p class="text-gray-600">Manage patient records</p>
<p class="text-gray-600">Manage patient records and visits</p>
</div>
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
@ -137,7 +267,7 @@
</div>
<!-- Search Bar -->
<div class="mb-6">
<div class="mb-4">
<div class="flex gap-2">
<div class="flex-1 relative">
<input
@ -153,8 +283,11 @@
</div>
</div>
<!-- DataTable -->
<div class="bg-base-100 rounded-lg shadow border border-base-200">
<!-- Two Column Layout -->
<div class="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-4 min-h-0">
<!-- Left: Patient List -->
<div class="bg-base-100 rounded-lg shadow border border-base-200 flex flex-col overflow-hidden">
<div class="flex-1 overflow-auto">
<DataTable
{columns}
data={patients.map((p) => ({
@ -166,31 +299,29 @@
{loading}
emptyMessage="No patients found"
hover={true}
onRowClick={selectPatient}
>
{#snippet cell({ column, row })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-1">
<button class="btn btn-sm btn-ghost" title="View" onclick={() => openDetailModal(row)}>
<User class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost" title="Visits" onclick={() => openVisitList(row)}>
<Calendar class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost" title="Edit" onclick={() => openEditModal(row)}>
<button class="btn btn-sm btn-ghost" title="Edit" onclick={(e) => openEditModal(row, e)}>
<Edit2 class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost text-error" title="Delete" onclick={() => confirmDelete(row)}>
<button class="btn btn-sm btn-ghost text-error" title="Delete" onclick={(e) => confirmDelete(row, e)}>
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else}
<span class={selectedPatient?.InternalPID === row.InternalPID ? 'font-semibold text-primary' : ''}>
{row[column.key]}
</span>
{/if}
{/snippet}
</DataTable>
</div>
{#if totalPages > 1}
<div class="flex items-center justify-between px-4 py-3 border-t border-base-200">
<div class="flex items-center justify-between px-4 py-3 border-t border-base-200 bg-base-100">
<div class="text-sm text-gray-600">
Showing {(currentPage - 1) * perPage + 1} - {Math.min(currentPage * perPage, totalItems)} of {totalItems}
</div>
@ -206,11 +337,156 @@
</div>
{/if}
</div>
<!-- Right: Visit List -->
<div class="bg-base-100 rounded-lg shadow border border-base-200 flex flex-col overflow-hidden">
{#if selectedPatient}
<div class="p-4 bg-base-200 border-b border-base-200">
<div class="flex items-center justify-between">
<div>
<h3 class="font-bold text-lg">
{[selectedPatient.Prefix, selectedPatient.NameFirst, selectedPatient.NameMiddle, selectedPatient.NameLast].filter(Boolean).join(' ')}
</h3>
<p class="text-sm text-gray-600">Patient ID: {selectedPatient.PatientID}</p>
</div>
<button class="btn btn-primary btn-sm" onclick={openCreateVisit}>
<Plus class="w-4 h-4 mr-1" />
New Visit
</button>
</div>
</div>
<div class="flex-1 overflow-auto p-4">
{#if visitsLoading}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if visits.length === 0}
<div class="text-center py-12 text-gray-500">
<Calendar class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p class="text-lg">No visits found</p>
<p class="text-sm">This patient has no visit records.</p>
</div>
{:else}
<div class="space-y-4">
{#each visits as visit}
<div class="card bg-base-100 shadow border border-base-200 hover:shadow-md transition-shadow">
<div class="card-body p-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<Calendar class="w-4 h-4 text-primary" />
<span class="font-semibold">{formatDate(visit.PVACreateDate || visit.PVCreateDate)}</span>
{#if !visit.EndDate && !visit.ArchivedDate}
<span class="badge badge-sm badge-success">Active</span>
{:else}
<span class="badge badge-sm badge-ghost">Closed</span>
{/if}
</div>
<div class="grid grid-cols-2 gap-4 text-sm">
{#if visit.ADTCode}
<div>
<span class="text-gray-500">Type:</span>
<span class="ml-1">{visit.ADTCode}</span>
</div>
{/if}
{#if visit.LocCode || visit.LocationID}
<div class="flex items-center gap-1">
<MapPin class="w-3 h-3 text-gray-400" />
<span class="text-gray-500">Location:</span>
<span class="ml-1">{visit.LocCode || visit.LocationID || '-'}</span>
</div>
{/if}
{#if visit.AttDoc || visit.AdmDoc || visit.RefDoc || visit.CnsDoc}
<div>
<span class="text-gray-500">Doctor:</span>
<span class="ml-1">{visit.AttDoc || visit.AdmDoc || visit.RefDoc || visit.CnsDoc || '-'}</span>
</div>
{/if}
{#if visit.PVACreateDate || visit.PVCreateDate}
<div class="flex items-center gap-1">
<Clock class="w-3 h-3 text-gray-400" />
<span class="text-gray-500">Time:</span>
<span class="ml-1">{formatDateTime(visit.PVACreateDate || visit.PVCreateDate)}</span>
</div>
{/if}
</div>
{#if visit.DiagCode || visit.Diagnosis}
<div class="mt-3 pt-3 border-t border-base-200">
<div class="flex items-start gap-2">
<FileText class="w-4 h-4 text-gray-400 mt-0.5" />
<div>
<span class="text-gray-500 text-sm">Diagnosis:</span>
<p class="text-sm mt-1">{visit.DiagCode || visit.Diagnosis || '-'}</p>
</div>
</div>
</div>
{/if}
<div class="mt-2 text-xs text-gray-400">
Visit ID: {visit.PVID || visit.InternalPVID || '-'}
</div>
</div>
<div class="flex gap-1 ml-4">
{#if !visit.EndDate && !visit.ArchivedDate}
<button
class="btn btn-sm btn-ghost text-warning"
title="Discharge"
onclick={(e) => openDischargeModal(visit, e)}
>
<span class="text-xs">DIS</span>
</button>
{/if}
<button
class="btn btn-sm btn-ghost"
title="View ADT History"
onclick={() => { adtHistoryVisit = visit; adtHistoryOpen = true; }}
>
<History class="w-4 h-4" />
</button>
<button
class="btn btn-sm btn-ghost"
title="Edit"
onclick={() => openEditVisit(visit)}
>
<Edit2 class="w-4 h-4" />
</button>
<button
class="btn btn-sm btn-ghost text-error"
title="Delete"
onclick={(e) => confirmDeleteVisit(visit, e)}
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
{:else}
<div class="flex-1 flex items-center justify-center text-gray-500">
<div class="text-center">
<Calendar class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p class="text-lg">Select a patient</p>
<p class="text-sm">Click on a patient to view their visits</p>
</div>
</div>
{/if}
</div>
</div>
</div>
<PatientFormModal bind:open={patientFormOpen} patient={selectedPatient} onSave={handlePatientSaved} loading={patientFormLoading} />
<PatientDetailModal bind:open={patientDetailOpen} patient={selectedPatient} />
<VisitListModal bind:open={visitListOpen} patient={selectedPatient} />
<VisitFormModal bind:open={visitFormOpen} patient={selectedPatient} visit={selectedVisit} onSave={handleVisitSaved} />
<VisitADTHistoryModal bind:open={adtHistoryOpen} visit={adtHistoryVisit} patientName={selectedPatient ? [selectedPatient.Prefix, selectedPatient.NameFirst, selectedPatient.NameMiddle, selectedPatient.NameLast].filter(Boolean).join(' ') : ''} />
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete" size="sm">
<div class="py-2">
@ -222,3 +498,46 @@
<button class="btn btn-error" onclick={handleDelete} type="button">Delete</button>
{/snippet}
</Modal>
<Modal bind:open={visitDeleteConfirmOpen} title="Confirm Delete Visit" size="sm">
<div class="py-2">
<p>Are you sure you want to delete this visit?</p>
<p class="text-sm text-gray-600 mt-2">Visit ID: <strong>{visitToDelete?.PVID || visitToDelete?.InternalPVID}</strong></p>
<p class="text-sm text-error mt-2">This action cannot be undone.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (visitDeleteConfirmOpen = false)} type="button">Cancel</button>
<button class="btn btn-error" onclick={handleDeleteVisit} type="button">Delete</button>
{/snippet}
</Modal>
<Modal bind:open={dischargeModalOpen} title="Discharge Patient" size="sm">
<div class="py-2 space-y-4">
<p>Discharge patient <strong>{selectedPatient ? [selectedPatient.Prefix, selectedPatient.NameFirst, selectedPatient.NameMiddle, selectedPatient.NameLast].filter(Boolean).join(' ') : ''}</strong></p>
<p class="text-sm text-gray-600">Visit ID: {visitToDischarge?.PVID || visitToDischarge?.InternalPVID}</p>
<div class="form-control">
<label class="label" for="dischargeDate">
<span class="label-text font-medium">Discharge Date</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
id="dischargeDate"
type="datetime-local"
class="input input-bordered w-full"
bind:value={dischargeDate}
/>
</div>
<p class="text-sm text-warning">This will set the visit End Date and create an A03 (Discharge) ADT record.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (dischargeModalOpen = false)} type="button">Cancel</button>
<button class="btn btn-warning" onclick={handleDischarge} disabled={discharging} type="button">
{#if discharging}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{discharging ? 'Discharging...' : 'Discharge'}
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,200 @@
<script>
import Modal from '$lib/components/Modal.svelte';
import { fetchVisitADTHistory } from '$lib/api/visits.js';
import { Calendar, MapPin, User, ArrowRight, Activity, ClipboardList } from 'lucide-svelte';
let { open = $bindable(false), visit = null, patientName = '' } = $props();
let adtHistory = $state([]);
let loading = $state(false);
let error = $state('');
$effect(() => {
if (open && visit) {
loadADTHistory();
}
});
async function loadADTHistory() {
if (!visit?.InternalPVID) return;
loading = true;
error = '';
try {
const response = await fetchVisitADTHistory(visit.InternalPVID);
if (Array.isArray(response)) {
adtHistory = response;
} else if (response.data && Array.isArray(response.data)) {
adtHistory = response.data;
} else {
adtHistory = [];
}
} catch (err) {
error = err.message || 'Failed to load ADT history';
adtHistory = [];
} finally {
loading = false;
}
}
function getADTLabel(code) {
const labels = {
'A01': 'Admitted',
'A02': 'Transferred',
'A03': 'Discharged',
'A04': 'Registered',
'A08': 'Updated'
};
return labels[code] || code;
}
function getADTColor(code) {
const colors = {
'A01': 'bg-success/20 text-success border-success/50',
'A02': 'bg-warning/20 text-warning border-warning/50',
'A03': 'bg-error/20 text-error border-error/50',
'A04': 'bg-info/20 text-info border-info/50',
'A08': 'bg-neutral/20 text-neutral border-neutral/50'
};
return colors[code] || 'bg-base-200 text-base-content border-base-300';
}
function getADTBadge(code) {
const badges = {
'A01': 'badge-success',
'A02': 'badge-warning',
'A03': 'badge-error',
'A04': 'badge-info',
'A08': 'badge-neutral'
};
return badges[code] || 'badge-ghost';
}
function formatDate(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
function formatTime(dateStr) {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit'
});
}
function getDoctorName(adt) {
const names = [];
if (adt.AttDocFirstName) names.push(`Dr. ${adt.AttDocFirstName} ${adt.AttDocLastName || ''}`.trim());
else if (adt.AdmDocFirstName) names.push(`Dr. ${adt.AdmDocFirstName} ${adt.AdmDocLastName || ''}`.trim());
else if (adt.RefDocFirstName) names.push(`Dr. ${adt.RefDocFirstName} ${adt.RefDocLastName || ''}`.trim());
else if (adt.CnsDocFirstName) names.push(`Dr. ${adt.CnsDocFirstName} ${adt.CnsDocLastName || ''}`.trim());
return names[0] || '-';
}
</script>
<Modal bind:open title="ADT History" size="lg">
<div class="py-2">
{#if patientName}
<div class="mb-4 pb-3 border-b border-base-200">
<p class="text-sm text-gray-500">Patient</p>
<p class="font-semibold text-lg">{patientName}</p>
<p class="text-xs text-gray-400 mt-1">Visit ID: {visit?.PVID || visit?.InternalPVID || '-'}</p>
</div>
{/if}
{#if loading}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if error}
<div class="alert alert-error">
<span>{error}</span>
</div>
{:else if adtHistory.length === 0}
<div class="text-center py-12 text-gray-500">
<ClipboardList class="w-16 h-16 mx-auto mb-4 opacity-50" />
<p class="text-lg">No ADT history found</p>
<p class="text-sm">This visit has no admission/discharge/transfer records.</p>
</div>
{:else}
<div class="space-y-0">
{#each adtHistory as adt, index}
<div class="relative flex gap-4">
<!-- Timeline line -->
{#if index < adtHistory.length - 1}
<div class="absolute left-6 top-12 w-0.5 h-full bg-base-300 -translate-x-1/2"></div>
{/if}
<!-- Icon/Badge -->
<div class="relative z-10 flex-shrink-0">
<div class="w-12 h-12 rounded-full border-2 flex items-center justify-center {getADTColor(adt.ADTCode)}">
<Activity class="w-5 h-5" />
</div>
</div>
<!-- Content Card -->
<div class="flex-1 pb-6">
<div class="bg-base-100 border border-base-200 rounded-lg p-4 hover:shadow-md transition-shadow">
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<!-- Header -->
<div class="flex items-center gap-2 mb-3">
<span class="badge {getADTBadge(adt.ADTCode)} badge-lg">
{getADTLabel(adt.ADTCode)}
</span>
<span class="text-xs text-gray-400">#{adt.PVADTID}</span>
</div>
<!-- Date and Time -->
<div class="flex items-center gap-4 text-sm mb-3">
<div class="flex items-center gap-1.5 text-gray-600">
<Calendar class="w-4 h-4" />
<span class="font-medium">{formatDate(adt.CreateDate)}</span>
</div>
<div class="flex items-center gap-1.5 text-gray-500">
<span class="w-1 h-1 bg-gray-400 rounded-full"></span>
<span>{formatTime(adt.CreateDate)}</span>
</div>
</div>
<!-- Details Grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
{#if adt.LocCode || adt.LocationID}
<div class="flex items-center gap-2">
<MapPin class="w-4 h-4 text-gray-400" />
<span class="text-gray-500">Location:</span>
<span class="font-medium">{adt.LocCode || adt.LocationID || '-'}</span>
</div>
{/if}
{#if getDoctorName(adt) !== '-'}
<div class="flex items-center gap-2">
<User class="w-4 h-4 text-gray-400" />
<span class="text-gray-500">Doctor:</span>
<span class="font-medium">{getDoctorName(adt)}</span>
</div>
{/if}
</div>
</div>
<!-- Arrow indicator -->
{#if index === 0}
<div class="flex-shrink-0">
<span class="badge badge-sm badge-primary">Latest</span>
</div>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</Modal>

View File

@ -1,5 +1,5 @@
<script>
import { createVisit, updateVisit, fetchVisit } from '$lib/api/visits.js';
import { createVisit, updateVisit, fetchVisit, createADT } from '$lib/api/visits.js';
import { fetchLocations } from '$lib/api/locations.js';
import { fetchContacts } from '$lib/api/contacts.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
@ -19,6 +19,7 @@
let formData = $state({
InternalPVID: null,
PVID: '',
PatientID: '',
PVCreateDate: '',
ADTCode: '',
@ -37,12 +38,13 @@
const isEdit = $derived(!!visit?.InternalPVID);
const adtTypeOptions = [
{ value: 'OPD', label: 'Outpatient (OPD)' },
{ value: 'IPD', label: 'Inpatient (IPD)' },
{ value: 'ER', label: 'Emergency (ER)' },
{ value: 'ADM', label: 'Admission (ADM)' },
{ value: 'DIS', label: 'Discharge (DIS)' },
{ value: 'TRF', label: 'Transfer (TRF)' },
{ value: 'A04', label: 'Register (Outpatient/ER)' },
{ value: 'A01', label: 'Admit (Inpatient)' },
{ value: 'A02', label: 'Transfer' },
{ value: 'A03', label: 'Discharge' },
{ value: 'A08', label: 'Update Information' },
{ value: 'A11', label: 'Cancel Admit' },
{ value: 'A13', label: 'Cancel Discharge' },
];
$effect(() => {
@ -53,6 +55,7 @@
// Edit mode - populate form
formData = {
InternalPVID: visit.InternalPVID || null,
PVID: visit.PVID || '',
PatientID: visit.PatientID || patient?.PatientID || '',
PVCreateDate: visit.PVCreateDate || visit.PVACreateDate || '',
ADTCode: visit.ADTCode || '',
@ -71,6 +74,7 @@
// Create mode - reset form
formData = {
InternalPVID: null,
PVID: '',
PatientID: patient?.PatientID || '',
PVCreateDate: new Date().toISOString().slice(0, 16),
ADTCode: '',
@ -150,13 +154,33 @@
}
});
let savedVisit;
if (isEdit) {
await updateVisit(payload);
savedVisit = await updateVisit(payload);
toastSuccess('Visit updated successfully');
} else {
await createVisit(payload);
savedVisit = await createVisit(payload);
toastSuccess('Visit created successfully');
}
// Create ADT record for the visit action
try {
const adtPayload = {
InternalPVID: savedVisit?.InternalPVID || payload.InternalPVID,
ADTCode: payload.ADTCode || 'A08', // Default to update if no code
LocationID: payload.LocationID,
LocCode: payload.LocCode,
AttDoc: payload.AttDoc,
AdmDoc: payload.AdmDoc,
RefDoc: payload.RefDoc,
CnsDoc: payload.CnsDoc,
};
await createADT(adtPayload);
} catch (adtErr) {
console.warn('Failed to create ADT record:', adtErr);
// Don't fail the whole operation if ADT creation fails
}
open = false;
onSave?.();
} catch (err) {
@ -211,6 +235,21 @@
{#if activeTab === 'info'}
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{#if isEdit}
<div class="form-control">
<label class="label" for="pvid">
<span class="label-text font-medium">Visit ID</span>
</label>
<input
id="pvid"
type="text"
class="input input-bordered w-full"
bind:value={formData.PVID}
placeholder="Enter visit ID"
/>
</div>
{/if}
<div class="form-control">
<label class="label" for="patientId">
<span class="label-text font-medium">Patient ID</span>

View File

@ -1,19 +1,71 @@
<script>
import { onMount } from 'svelte';
import { fetchVisitsByPatient } from '$lib/api/visits.js';
import { error as toastError } from '$lib/utils/toast.js';
import { fetchVisitsByPatient, createVisit, updateVisit, createADT } from '$lib/api/visits.js';
import { fetchLocations } from '$lib/api/locations.js';
import { fetchContacts } from '$lib/api/contacts.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import Modal from '$lib/components/Modal.svelte';
import VisitFormModal from './VisitFormModal.svelte';
import { Calendar, Clock, MapPin, FileText, Plus, Edit2 } from 'lucide-svelte';
import SelectDropdown from '$lib/components/SelectDropdown.svelte';
import { Calendar, Clock, MapPin, FileText, Plus, Edit2, ArrowLeft, User, Activity } from 'lucide-svelte';
/** @type {{ open: boolean, patient: any | null }} */
let { open = $bindable(false), patient = null } = $props();
let visits = $state([]);
let loading = $state(false);
let visitFormOpen = $state(false);
let mode = $state('list'); // 'list' or 'form'
let selectedVisit = $state(null);
// Form state
let formSaving = $state(false);
let locations = $state([]);
let contacts = $state([]);
let formErrors = $state({});
let activeTab = $state('info'); // 'info', 'diagnosis'
let formData = $state({
InternalPVID: null,
PatientID: '',
PVCreateDate: '',
ADTCode: '',
LocationID: '',
LocCode: '',
AttDoc: '',
AdmDoc: '',
RefDoc: '',
CnsDoc: '',
DiagCode: '',
Diagnosis: '',
EndDate: '',
ArchivedDate: '',
});
const adtTypeOptions = [
{ value: 'A04', label: 'Register (Outpatient/ER)' },
{ value: 'A01', label: 'Admit (Inpatient)' },
{ value: 'A02', label: 'Transfer' },
{ value: 'A03', label: 'Discharge' },
{ value: 'A08', label: 'Update Information' },
{ value: 'A11', label: 'Cancel Admit' },
{ value: 'A13', label: 'Cancel Discharge' },
];
const isEdit = $derived(!!selectedVisit?.InternalPVID);
const locationOptions = $derived(
locations.map((l) => ({
value: l.LocationID || l.LocCode || '',
label: l.LocFull || l.LocCode || 'Unknown',
}))
);
const doctorOptions = $derived(
contacts.map((c) => ({
value: c.ContactID || c.ContactCode || '',
label: [c.NamePrefix, c.NameFirst, c.NameMiddle, c.NameLast].filter(Boolean).join(' ') || c.ContactCode || 'Unknown',
}))
);
onMount(() => {
if (patient && open) {
loadVisits();
@ -64,23 +116,148 @@
return new Date(dateStr).toLocaleString();
}
function openCreateModal() {
async function openCreateModal() {
selectedVisit = null;
visitFormOpen = true;
await loadFormData();
resetForm();
mode = 'form';
}
function openEditModal(visit) {
async function openEditModal(visit) {
selectedVisit = visit;
visitFormOpen = true;
await loadFormData();
populateForm(visit);
mode = 'form';
}
function handleVisitSaved() {
loadVisits();
function backToList() {
mode = 'list';
selectedVisit = null;
activeTab = 'info';
}
async function loadFormData() {
try {
const [locResponse, contactResponse] = await Promise.all([
fetchLocations(),
fetchContacts(),
]);
locations = Array.isArray(locResponse.data) ? locResponse.data : [];
contacts = Array.isArray(contactResponse.data) ? contactResponse.data : [];
} catch (err) {
console.error('Failed to load form data:', err);
}
}
function resetForm() {
formData = {
InternalPVID: null,
PatientID: patient?.PatientID || '',
PVCreateDate: new Date().toISOString().slice(0, 16),
ADTCode: '',
LocationID: '',
LocCode: '',
AttDoc: '',
AdmDoc: '',
RefDoc: '',
CnsDoc: '',
DiagCode: '',
Diagnosis: '',
EndDate: '',
ArchivedDate: '',
};
formErrors = {};
}
function populateForm(visit) {
formData = {
InternalPVID: visit.InternalPVID || null,
PatientID: visit.PatientID || patient?.PatientID || '',
PVCreateDate: visit.PVCreateDate || visit.PVACreateDate || '',
ADTCode: visit.ADTCode || '',
LocationID: visit.LocationID || '',
LocCode: visit.LocCode || '',
AttDoc: visit.AttDoc || '',
AdmDoc: visit.AdmDoc || '',
RefDoc: visit.RefDoc || '',
CnsDoc: visit.CnsDoc || '',
DiagCode: visit.DiagCode || '',
Diagnosis: visit.Diagnosis || '',
EndDate: visit.EndDate || '',
ArchivedDate: visit.ArchivedDate || '',
};
formErrors = {};
}
function validateForm() {
const errors = {};
if (!formData.PatientID?.trim()) {
errors.PatientID = 'Patient ID is required';
}
if (!formData.PVCreateDate) {
errors.PVCreateDate = 'Visit date is required';
}
if (!formData.ADTCode?.trim()) {
errors.ADTCode = 'Visit type is required';
}
formErrors = errors;
return Object.keys(errors).length === 0;
}
async function handleSubmit() {
if (!validateForm()) return;
formSaving = true;
try {
const payload = { ...formData };
// Remove empty fields
Object.keys(payload).forEach((key) => {
if (payload[key] === '' || payload[key] === null) {
delete payload[key];
}
});
let savedVisit;
if (isEdit) {
savedVisit = await updateVisit(payload);
toastSuccess('Visit updated successfully');
} else {
savedVisit = await createVisit(payload);
toastSuccess('Visit created successfully');
}
// Create ADT record for the visit action
try {
const adtPayload = {
InternalPVID: savedVisit?.InternalPVID || payload.InternalPVID,
ADTCode: payload.ADTCode || 'A08',
LocationID: payload.LocationID,
LocCode: payload.LocCode,
AttDoc: payload.AttDoc,
AdmDoc: payload.AdmDoc,
RefDoc: payload.RefDoc,
CnsDoc: payload.CnsDoc,
};
await createADT(adtPayload);
} catch (adtErr) {
console.warn('Failed to create ADT record:', adtErr);
}
backToList();
await loadVisits();
} catch (err) {
toastError(err.message || 'Failed to save visit');
} finally {
formSaving = false;
}
}
</script>
<Modal bind:open title="Patient Visits" size="xl">
<Modal bind:open title={mode === 'list' ? 'Patient Visits' : isEdit ? 'Edit Visit' : 'New Visit'} size="xl">
{#if patient}
{#if mode === 'list'}
<!-- Visit List View -->
<div class="mb-4 p-4 bg-base-200 rounded-lg">
<div class="flex items-center justify-between">
<div>
@ -186,11 +363,229 @@
{/each}
</div>
{/if}
{:else}
<!-- Visit Form View -->
<div class="mb-4">
<button class="btn btn-ghost btn-sm" onclick={backToList}>
<ArrowLeft class="w-4 h-4 mr-1" />
Back to Visits
</button>
</div>
<!-- Patient Info Display -->
<div class="p-4 bg-base-200 rounded-lg mb-4">
<div class="flex items-center gap-2">
<User class="w-4 h-4 text-gray-500" />
<span class="font-medium">
{[patient.Prefix, patient.NameFirst, patient.NameMiddle, patient.NameLast].filter(Boolean).join(' ')}
</span>
<span class="text-gray-500">({patient.PatientID})</span>
</div>
</div>
<form class="space-y-6" onsubmit={(e) => e.preventDefault()}>
<!-- Tabs -->
<div class="tabs tabs-bordered">
<button
type="button"
class="tab tab-lg {activeTab === 'info' ? 'tab-active' : ''}"
onclick={() => activeTab = 'info'}
>
<Calendar class="w-4 h-4 mr-2" />
Visit Info
</button>
<button
type="button"
class="tab tab-lg {activeTab === 'diagnosis' ? 'tab-active' : ''}"
onclick={() => activeTab = 'diagnosis'}
>
<Activity class="w-4 h-4 mr-2" />
Diagnosis & Status
</button>
</div>
<!-- Tab: Visit Info -->
{#if activeTab === 'info'}
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="patientId">
<span class="label-text font-medium">Patient ID</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
id="patientId"
type="text"
class="input input-bordered w-full"
class:input-error={formErrors.PatientID}
bind:value={formData.PatientID}
placeholder="Enter patient ID"
disabled={!!patient}
/>
{#if formErrors.PatientID}
<span class="text-error text-sm mt-1">{formErrors.PatientID}</span>
{/if}
</div>
<div class="form-control">
<label class="label" for="visitDate">
<span class="label-text font-medium">Visit Date</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
id="visitDate"
type="datetime-local"
class="input input-bordered w-full"
class:input-error={formErrors.PVCreateDate}
bind:value={formData.PVCreateDate}
/>
{#if formErrors.PVCreateDate}
<span class="text-error text-sm mt-1">{formErrors.PVCreateDate}</span>
{/if}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<SelectDropdown
label="Visit Type"
name="adtCode"
bind:value={formData.ADTCode}
options={adtTypeOptions}
placeholder="Select visit type..."
required={true}
error={formErrors.ADTCode}
/>
<SelectDropdown
label="Location"
name="location"
bind:value={formData.LocationID}
options={locationOptions}
placeholder="Select location..."
/>
</div>
<div class="border-t border-base-200 pt-4 mt-4">
<h4 class="font-medium text-gray-700 mb-4">Doctors</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<SelectDropdown
label="Attending Doctor"
name="attDoc"
bind:value={formData.AttDoc}
options={doctorOptions}
placeholder="Select attending doctor..."
/>
<SelectDropdown
label="Admitting Doctor"
name="admDoc"
bind:value={formData.AdmDoc}
options={doctorOptions}
placeholder="Select admitting doctor..."
/>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
<SelectDropdown
label="Referring Doctor"
name="refDoc"
bind:value={formData.RefDoc}
options={doctorOptions}
placeholder="Select referring doctor..."
/>
<SelectDropdown
label="Consulting Doctor"
name="cnsDoc"
bind:value={formData.CnsDoc}
options={doctorOptions}
placeholder="Select consulting doctor..."
/>
</div>
</div>
</div>
{/if}
<VisitFormModal bind:open={visitFormOpen} {patient} visit={selectedVisit} onSave={handleVisitSaved} />
<!-- Tab: Diagnosis & Status -->
{#if activeTab === 'diagnosis'}
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="diagCode">
<span class="label-text font-medium">Diagnosis Code</span>
</label>
<input
id="diagCode"
type="text"
class="input input-bordered w-full"
bind:value={formData.DiagCode}
placeholder="Enter diagnosis code"
/>
</div>
</div>
<div class="form-control">
<label class="label" for="diagnosis">
<span class="label-text font-medium">Diagnosis Description</span>
</label>
<textarea
id="diagnosis"
class="textarea textarea-bordered w-full"
bind:value={formData.Diagnosis}
placeholder="Enter diagnosis description"
rows="3"
></textarea>
</div>
<div class="border-t border-base-200 pt-4 mt-4">
<h4 class="font-medium text-gray-700 mb-4">Visit Status</h4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="endDate">
<span class="label-text font-medium">End Date</span>
</label>
<input
id="endDate"
type="datetime-local"
class="input input-bordered w-full"
bind:value={formData.EndDate}
/>
</div>
<div class="form-control">
<label class="label" for="archivedDate">
<span class="label-text font-medium">Archived Date</span>
</label>
<input
id="archivedDate"
type="datetime-local"
class="input input-bordered w-full"
bind:value={formData.ArchivedDate}
/>
</div>
</div>
</div>
</div>
{/if}
</form>
{/if}
{/if}
{#snippet footer()}
{#if mode === 'list'}
<button class="btn btn-ghost" onclick={() => (open = false)} type="button">Close</button>
{:else}
<div class="flex justify-end gap-2">
<button class="btn btn-ghost" onclick={backToList} type="button">
Cancel
</button>
<button class="btn btn-primary" onclick={handleSubmit} disabled={formSaving} type="button">
{#if formSaving}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{formSaving ? 'Saving...' : 'Save'}
</button>
</div>
{/if}
{/snippet}
</Modal>

View File

@ -89,25 +89,25 @@
<div class="hero-content flex-col w-full max-w-lg">
<!-- Logo Section -->
<div class="text-center mb-4">
<div class="w-16 h-16 mx-auto rounded-xl bg-emerald-100 flex items-center justify-center mb-3 shadow border-2 border-emerald-200">
<svg class="w-10 h-10 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div class="w-16 h-16 mx-auto rounded-xl bg-primary/10 flex items-center justify-center mb-3 shadow border-2 border-primary/20">
<svg class="w-10 h-10 text-primary" 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-3xl font-bold text-emerald-600 mb-1">CLQMS</h1>
<h1 class="text-3xl font-bold text-primary mb-1">CLQMS</h1>
<p class="text-gray-600 text-sm">Clinical Laboratory Quality Management System</p>
</div>
<!-- Login Card -->
<div class="card bg-white w-full max-w-md shadow-xl border border-gray-200 border-t-4 border-t-emerald-500">
<div class="card bg-white w-full max-w-md shadow-xl border border-gray-200 border-t-4 border-t-secondary">
<div class="card-body p-6">
<h2 class="text-xl font-bold text-center text-emerald-700 mb-4">Welcome Back</h2>
<h2 class="text-xl font-bold text-center text-secondary mb-4">Welcome Back</h2>
<form on:submit|preventDefault={handleSubmit}>
<!-- Username Field -->
<div class="mb-3">
<label class="input w-full input-sm input-bordered bg-white border-gray-300 flex items-center gap-2 {usernameError ? 'border-red-500' : ''}">
<span class="label"><User class="w-4 h-4 text-emerald-500" /></span>
<label class="input w-full input-sm input-bordered bg-white border-gray-300 flex items-center gap-2 {usernameError ? 'border-red-500' : ''}">
<span class="label"><User class="w-4 h-4 text-secondary" /></span>
<input
type="text"
id="username"
@ -126,8 +126,8 @@
<!-- Password Field -->
<div class="mb-3">
<label class="input w-full input-sm input-bordered bg-white border-gray-300 flex items-center gap-2 {passwordError ? 'border-red-500' : ''}">
<span class="label"><Lock class="w-4 h-4 text-emerald-500" /></span>
<label class="input w-full input-sm input-bordered bg-white border-gray-300 flex items-center gap-2 {passwordError ? 'border-red-500' : ''}">
<span class="label"><Lock class="w-4 h-4 text-secondary" /></span>
<input
type={showPassword ? 'text' : 'password'}
id="password"
@ -140,9 +140,9 @@
/>
<button type="button" class="btn btn-ghost btn-xs btn-circle" on:click={togglePassword} disabled={loading}>
{#if showPassword}
<EyeOff class="w-4 h-4 text-emerald-500" />
<EyeOff class="w-4 h-4 text-secondary" />
{:else}
<Eye class="w-4 h-4 text-emerald-500" />
<Eye class="w-4 h-4 text-secondary" />
{/if}
</button>
</label>
@ -153,11 +153,11 @@
<!-- Remember Me & Forgot Password -->
<div class="flex items-center justify-between mb-4">
<label class="label cursor-pointer flex items-center gap-2">
<input type="checkbox" bind:checked={rememberMe} class="checkbox checkbox-sm checkbox-emerald" disabled={loading} />
<label class="label cursor-pointer flex items-center gap-2">
<input type="checkbox" bind:checked={rememberMe} class="checkbox checkbox-sm checkbox-secondary" 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>
<a href="#" class="text-sm text-secondary hover:underline">Forgot password?</a>
</div>
<!-- Error Message -->
@ -169,7 +169,7 @@
<!-- Login Button -->
<div class="form-control">
<button type="submit" class="btn btn-sm bg-emerald-600 hover:bg-emerald-700 text-white shadow hover:shadow-lg transition-all w-full" disabled={loading}>
<button type="submit" class="btn btn-sm btn-primary text-white shadow hover:shadow-lg transition-all w-full" disabled={loading}>
{#if loading}
<span class="loading loading-spinner loading-xs mr-2"></span>
Signing in...