feat: Add Master Data modules and UI components
This commit is contained in:
parent
5df9523d5a
commit
f7cb3d50d4
1304
docs/api-docs.yaml
1304
docs/api-docs.yaml
File diff suppressed because it is too large
Load Diff
@ -11,40 +11,7 @@
|
||||
- **SvelteKit File-based Routing** - Standard patterns
|
||||
- **Server-side auth checking** - SvelteKit hooks for session validation
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ ├── api/ # API service functions
|
||||
│ │ ├── client.js # Base fetch wrapper with auth
|
||||
│ │ ├── auth.js # Auth endpoints
|
||||
│ │ ├── valuesets.js # ValueSet endpoints
|
||||
│ │ ├── masterdata.js # Master data endpoints
|
||||
│ │ ├── patients.js # Patient endpoints
|
||||
│ │ └── ...
|
||||
│ ├── components/ # Reusable Svelte components
|
||||
│ │ ├── FormInput.svelte
|
||||
│ │ ├── DataTable.svelte
|
||||
│ │ ├── SelectDropdown.svelte
|
||||
│ │ └── Layout.svelte
|
||||
│ └── stores/ # Svelte stores
|
||||
│ ├── auth.js # Auth state
|
||||
│ └── valuesets.js # Cached lookup values
|
||||
├── routes/
|
||||
│ ├── +layout.svelte # Root layout with nav
|
||||
│ ├── +page.svelte # Dashboard (home)
|
||||
│ ├── login/
|
||||
│ │ └── +page.svelte
|
||||
│ ├── (app)/ # Group: protected routes
|
||||
│ │ ├── +layout.svelte # Auth check
|
||||
│ │ ├── patients/
|
||||
│ │ ├── valuesets/
|
||||
│ │ ├── masterdata/
|
||||
│ │ └── ...
|
||||
│ └── api/ # Internal API routes (if needed)
|
||||
└── app.html
|
||||
```
|
||||
## Project Structure\n\n```\nsrc/\n├── lib/\n│ ├── api/ # API service functions\n│ │ ├── client.js # Base fetch wrapper with auth\n│ │ ├── auth.js # Auth endpoints\n│ │ ├── valuesets.js # ValueSet endpoints\n│ │ ├── locations.js # Location endpoints\n│ │ ├── contacts.js # Contact endpoints\n│ │ ├── specialties.js # Specialty endpoints\n│ │ └── ... # Other endpoint modules\n│ ├── components/ # Reusable Svelte components\n│ │ ├── DataTable.svelte\n│ │ ├── Modal.svelte\n│ │ ├── SelectDropdown.svelte\n│ │ ├── Sidebar.svelte\n│ │ └── ToastContainer.svelte\n│ ├── stores/ # Svelte stores\n│ │ ├── auth.js # Auth state\n│ │ └── valuesets.js # Cached lookup values\n│ └── utils/ # Utility functions\n│ └── toast.js # Toast notifications\n├── routes/\n│ ├── +layout.svelte # Root layout with nav\n│ ├── +page.svelte # Redirect to login\n│ ├── login/\n│ │ └── +page.svelte\n│ └── (app)/ # Group: protected routes\n│ ├── +layout.svelte # Auth check with sidebar\n│ ├── dashboard/\n│ │ └── +page.svelte\n│ └── master-data/\n│ ├── +page.svelte\n│ ├── locations/\n│ ├── contacts/\n│ ├── specialties/\n│ └── valuesets/\n├── app.css # Global styles with Tailwind\n└── app.html # HTML template\n```
|
||||
|
||||
## Implementation Phases
|
||||
|
||||
@ -69,95 +36,137 @@ src/
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: Foundation Data
|
||||
**Priority:** High | **Time Estimate:** 4-6 hours
|
||||
### Phase 1: Foundation Data ✅ COMPLETED
|
||||
**Priority:** High | **Time Estimate:** 4-6 hours | **Status:** Done
|
||||
|
||||
**Why First:** These are prerequisites for all other modules. ValueSets provide dropdown options; Master Data provides reference entities.
|
||||
|
||||
#### 1a. ValueSets Module
|
||||
- [ ] ValueSet definitions list page
|
||||
- [ ] ValueSet definitions create/edit form
|
||||
- [ ] ValueSet items management (CRUD)
|
||||
- [ ] Cache frequently used value sets in stores
|
||||
#### 1a. System ValueSets Module (Library) ✅ COMPLETED
|
||||
System ValueSets are pre-defined lookup values used throughout the application (gender, marital status, specimen types, etc.). These are read-only libraries fetched from JSON files and cached.
|
||||
|
||||
- [x] ValueSet browser page (view system value sets by key)
|
||||
- [x] ValueSet items viewer (read-only)
|
||||
- [x] Cache frequently used value sets in stores
|
||||
- [x] SelectDropdown component using cached value sets
|
||||
- [x] Refresh ValueSet cache
|
||||
|
||||
**API Endpoints (Library/System ValueSets):**
|
||||
- `GET /api/valueset` - List all library value sets with item counts
|
||||
- `GET /api/valueset/{key}` - Get library value set by key (e.g., 'marital_status', 'sex')
|
||||
- `POST /api/valueset/refresh` - Refresh library ValueSet cache from JSON files
|
||||
|
||||
#### 1b. Master Data - Locations & Contacts ✅ COMPLETED
|
||||
- [x] Locations list page with search (supports LocCode, LocName filters)
|
||||
- [x] Locations create/edit form
|
||||
- [x] Contacts (Physicians) list page
|
||||
- [x] Contacts create/edit form
|
||||
|
||||
**API Endpoints:**
|
||||
- `GET /api/valueset` - List value set definitions
|
||||
- `POST /api/valuesetdef` - Create value set definition
|
||||
- `PUT /api/valuesetdef/{id}` - Update value set definition
|
||||
- `DELETE /api/valuesetdef/{id}` - Delete value set definition
|
||||
- `GET /api/valueset/items` - List value set items
|
||||
- `POST /api/valueset/items` - Create value set item
|
||||
- `PUT /api/valueset/items/{id}` - Update value set item
|
||||
- `DELETE /api/valueset/items/{id}` - Delete value set item
|
||||
- `POST /api/valueset/refresh` - Refresh cache
|
||||
|
||||
#### 1b. Master Data - Locations & Contacts
|
||||
- [ ] Locations list page with search
|
||||
- [ ] Locations create/edit form
|
||||
- [ ] Contacts (Physicians) list page
|
||||
- [ ] Contacts create/edit form
|
||||
|
||||
**API Endpoints:**
|
||||
- `GET /api/location` - List locations
|
||||
- `POST /api/location` - Create location
|
||||
- `PATCH /api/location` - Update location
|
||||
- `DELETE /api/location` - Delete location
|
||||
**Locations:**
|
||||
- `GET /api/location` - List locations (query: LocCode, LocName)
|
||||
- `POST /api/location` - Create location (required: LocCode, LocFull)
|
||||
- `PATCH /api/location` - Update location (required: LocCode, LocFull)
|
||||
- `DELETE /api/location` - Delete location (body: { LocationID })
|
||||
- `GET /api/location/{id}` - Get location details
|
||||
|
||||
**Location Schema:**
|
||||
- `LocationID` - Primary key (auto-generated)
|
||||
- `SiteID` - Reference to site
|
||||
- `LocCode` - Location code, max 6 chars (required)
|
||||
- `Parent` - Parent location ID (for hierarchical locations)
|
||||
- `LocFull` - Full location name, max 255 chars (required)
|
||||
- `Description` - Description, max 255 chars
|
||||
- `LocType` - Location type (e.g., ROOM, WARD, BUILDING)
|
||||
- `CreateDate`, `EndDate` - Timestamps
|
||||
|
||||
**Contacts:**
|
||||
- `GET /api/contact` - List contacts
|
||||
- `POST /api/contact` - Create contact
|
||||
- `POST /api/contact` - Create contact (required: NameFirst)
|
||||
- `PATCH /api/contact` - Update contact
|
||||
- `DELETE /api/contact` - Delete contact
|
||||
- `GET /api/contact/{id}` - Get contact details
|
||||
|
||||
#### 1c. Master Data - Supporting Entities
|
||||
- [ ] Occupations management
|
||||
- [ ] Medical Specialties management
|
||||
- [ ] Counters management (for ID generation)
|
||||
**Contact Schema:**
|
||||
- `ContactID` - Primary key (auto-generated)
|
||||
- `NameFirst` - First name (required)
|
||||
- `NameLast` - Last name
|
||||
- `Title` - Title (e.g., Dr, Mr, Mrs)
|
||||
- `Initial` - Middle initial
|
||||
- `Birthdate` - Date of birth (ISO 8601)
|
||||
- `EmailAddress1`, `EmailAddress2` - Email addresses
|
||||
- `Phone` - Primary phone
|
||||
- `MobilePhone1`, `MobilePhone2` - Mobile numbers
|
||||
- `Specialty` - Medical specialty code
|
||||
- `SubSpecialty` - Sub-specialty code
|
||||
- `CreateDate`, `EndDate` - Timestamps
|
||||
|
||||
#### 1d. Master Data - Supporting Entities ✅ COMPLETED
|
||||
- [x] Occupations management
|
||||
- [x] Medical Specialties management
|
||||
- [x] Counters management (for ID generation)
|
||||
|
||||
**API Endpoints:**
|
||||
|
||||
**Occupations:**
|
||||
- `GET /api/occupation` - List occupations
|
||||
- `POST /api/occupation` - Create occupation
|
||||
- `PATCH /api/occupation` - Update occupation
|
||||
- `POST /api/occupation` - Create occupation (required: OccCode, OccText)
|
||||
- `PATCH /api/occupation` - Update occupation (required: OccCode, OccText)
|
||||
- `GET /api/occupation/{id}` - Get occupation details
|
||||
|
||||
**Occupation Schema:**
|
||||
- `OccupationID` - Primary key (auto-generated)
|
||||
- `OccCode` - Occupation code (required)
|
||||
- `OccText` - Occupation name/text (required)
|
||||
- `Description` - Additional description
|
||||
- `CreateDate` - Creation timestamp
|
||||
|
||||
**Medical Specialties:**
|
||||
- `GET /api/medicalspecialty` - List specialties
|
||||
- `POST /api/medicalspecialty` - Create specialty
|
||||
- `PATCH /api/medicalspecialty` - Update specialty
|
||||
- `POST /api/medicalspecialty` - Create specialty (required: SpecialtyText)
|
||||
- `PATCH /api/medicalspecialty` - Update specialty (required: SpecialtyText)
|
||||
- `GET /api/medicalspecialty/{id}` - Get specialty details
|
||||
|
||||
**Medical Specialty Schema:**
|
||||
- `SpecialtyID` - Primary key (auto-generated)
|
||||
- `SpecialtyText` - Specialty name/text (required)
|
||||
- `Parent` - Parent specialty ID (for hierarchical structure)
|
||||
- `Title` - Title/abbreviation
|
||||
- `CreateDate`, `EndDate` - Timestamps
|
||||
|
||||
**Counters:**
|
||||
- `GET /api/counter` - List counters
|
||||
- `POST /api/counter` - Create counter
|
||||
- `PATCH /api/counter` - Update counter
|
||||
- `DELETE /api/counter` - Delete counter
|
||||
- `DELETE /api/counter` - Delete counter (body: { CounterID })
|
||||
- `GET /api/counter/{id}` - Get counter details
|
||||
|
||||
#### 1d. Master Data - Geography
|
||||
- [ ] Provinces list (read-only dropdown)
|
||||
- [ ] Cities list with province filter
|
||||
**Counter Schema:**
|
||||
- `CounterID` - Primary key (auto-generated)
|
||||
- `CounterDesc` - Counter description/name
|
||||
- `CounterValue` - Current counter value
|
||||
- `CounterStart` - Starting value
|
||||
- `CounterEnd` - Ending value (for auto-reset)
|
||||
- `CounterReset` - Reset pattern (D=Daily, M=Monthly, Y=Yearly)
|
||||
|
||||
#### 1e. Master Data - Geography ✅ COMPLETED
|
||||
- [x] Geographical areas list
|
||||
- [x] Provinces list (read-only dropdown)
|
||||
- [x] Cities list with province filter
|
||||
|
||||
**API Endpoints:**
|
||||
- `GET /api/areageo` - List all geographical areas
|
||||
- `GET /api/areageo/provinces` - List provinces
|
||||
- `GET /api/areageo/cities` - List cities (with province_id filter)
|
||||
- `GET /api/areageo/cities?province_id={id}` - List cities (with province filter)
|
||||
|
||||
**Notes:**
|
||||
- Geography endpoints are read-only (no POST/PATCH/DELETE)
|
||||
- Used for patient address province/city selection
|
||||
- Returns hierarchical location data
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Patient Management
|
||||
**Priority:** High | **Time Estimate:** 3-4 hours
|
||||
|
||||
**Dependencies:** Master Data (locations, contacts, occupations, provinces/cities)
|
||||
|
||||
#### 2a. Patient CRUD
|
||||
- [ ] Patients list page with pagination and search
|
||||
- [ ] Patient create form with validation
|
||||
- [ ] Patient edit form
|
||||
- [ ] Patient detail view
|
||||
- [ ] Patient delete with confirmation
|
||||
|
||||
**API Endpoints:**
|
||||
- `GET /api/patient` - List patients (with pagination, search)
|
||||
- `POST /api/patient` - Create patient
|
||||
- `PATCH /api/patient` - Update patient
|
||||
- `DELETE /api/patient` - Delete patient
|
||||
- `GET /api/patient/{id}` - Get patient by ID
|
||||
- `GET /api/patient/check` - Check if patient exists
|
||||
### Phase 2: Patient Management\n**Priority:** High | **Time Estimate:** 3-4 hours\n\n**Dependencies:** Master Data (locations, contacts, occupations, provinces/cities)\n\n#### 2a. Patient CRUD\n- [ ] Patients list page with pagination and search\n- [ ] Patient create form with validation\n- [ ] Patient edit form\n- [ ] Patient detail view\n- [ ] Patient delete with confirmation\n\n**API Endpoints:**\n- `GET /api/patient` - List patients (query: page, perPage, InternalPID, PatientID, Name, Birthdate)\n- `POST /api/patient` - Create patient\n- `PATCH /api/patient` - Update patient\n- `DELETE /api/patient` - Delete patient (soft delete, body: { InternalPID })\n- `GET /api/patient/{id}` - Get patient by ID\n- `GET /api/patient/check` - Check if patient exists (query: PatientID, EmailAddress1)
|
||||
|
||||
#### 2b. Advanced Patient Features
|
||||
- [ ] Patient identifier management (KTP, PASS, SSN, etc.)
|
||||
@ -219,6 +228,7 @@ src/
|
||||
- [ ] Specimen preparation methods
|
||||
- [ ] Specimen statuses
|
||||
- [ ] Collection methods
|
||||
- [ ] Samples list with status filtering
|
||||
|
||||
**API Endpoints:**
|
||||
- `GET /api/specimen` - List specimens
|
||||
@ -241,46 +251,15 @@ src/
|
||||
- `POST /api/specimen/collection` - Create collection method
|
||||
- `PATCH /api/specimen/collection` - Update collection method
|
||||
- `GET /api/specimen/collection/{id}` - Get collection method details
|
||||
- `GET /api/sample` - Get samples (with status filter)
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Test Catalog
|
||||
**Priority:** Medium | **Time Estimate:** 2-3 hours
|
||||
|
||||
**Dependencies:** Organization (disciplines, departments), ValueSets
|
||||
|
||||
- [ ] Test definitions list with filtering
|
||||
- [ ] Create/edit test definitions
|
||||
- [ ] Test mapping management (host/client codes)
|
||||
|
||||
**API Endpoints:**
|
||||
- `GET /api/tests` - List tests (with filters)
|
||||
- `POST /api/tests` - Create test
|
||||
- `PATCH /api/tests` - Update test
|
||||
- `GET /api/tests/{id}` - Get test details
|
||||
|
||||
**Test Types:** TEST, PARAM, CALC, GROUP, TITLE
|
||||
### Phase 5: Test Catalog\n**Priority:** Medium | **Time Estimate:** 2-3 hours\n\n**Dependencies:** Organization (disciplines, departments), ValueSets\n\n- [ ] Test definitions list with filtering\n- [ ] Create/edit test definitions\n- [ ] Test mapping management (host/client codes)\n\n**API Endpoints:**\n- `GET /api/tests` - List tests\n- `POST /api/tests` - Create test\n- `PATCH /api/tests` - Update test\n- `GET /api/tests/{id}` - Get test details\n\n**Query Parameters:**\n- `page`, `perPage` - Pagination\n- `SiteID` - Filter by site\n- `TestType` - Filter by type (TEST, PARAM, CALC, GROUP, TITLE)\n- `VisibleScr` - Filter by screen visibility (0=hidden, 1=visible)\n- `VisibleRpt` - Filter by report visibility (0=hidden, 1=visible)\n- `TestSiteName` - Search by test name or code\n\n**Test Types:**\n- `TEST` - Technical test\n- `PARAM` - Parameter\n- `CALC` - Calculated\n- `GROUP` - Panel/Profile\n- `TITLE` - Section header\n\n**Test Definition Schema:**\n- `id` - Primary key\n- `TestCode` - Test code\n- `TestName` - Test name\n- `TestType` - Type (TEST, PARAM, CALC, GROUP, TITLE)\n- `DisciplineID` - Reference to discipline\n- `DepartmentID` - Reference to department\n- `SpecimenType` - Specimen type code\n- `Unit` - Measurement unit\n- `Formula` - Calculation formula
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Orders
|
||||
**Priority:** High | **Time Estimate:** 3-4 hours
|
||||
|
||||
**Dependencies:** Patients, Visits, Tests, Specimen, ValueSets (priorities, statuses)
|
||||
|
||||
- [ ] Orders list with status filtering
|
||||
- [ ] Create order with test selection
|
||||
- [ ] Order detail view
|
||||
- [ ] Update order status
|
||||
- [ ] Order items management
|
||||
|
||||
**API Endpoints:**
|
||||
- `GET /api/ordertest` - List orders (with filters)
|
||||
- `POST /api/ordertest` - Create order
|
||||
- `PATCH /api/ordertest` - Update order
|
||||
- `DELETE /api/ordertest` - Delete order
|
||||
- `GET /api/ordertest/{id}` - Get order details
|
||||
- `POST /api/ordertest/status` - Update order status
|
||||
### Phase 6: Orders\n**Priority:** High | **Time Estimate:** 3-4 hours\n\n**Dependencies:** Patients, Visits, Tests, Specimen, ValueSets (priorities, statuses)\n\n- [ ] Orders list with status filtering\n- [ ] Create order with test selection\n- [ ] Order detail view\n- [ ] Update order status\n- [ ] Order items management\n\n**API Endpoints:**\n- `GET /api/ordertest` - List orders (filters: OrderStatus, InternalPID, page, perPage)\n- `POST /api/ordertest` - Create order (requires: PatientID, Tests array)\n- `PATCH /api/ordertest` - Update order\n- `DELETE /api/ordertest` - Delete order\n- `GET /api/ordertest/{id}` - Get order details\n- `POST /api/ordertest/status` - Update order status (body: { OrderID, OrderStatus })\n\n**Order Status Values:**\n- `ORD` - Ordered\n- `SCH` - Scheduled\n- `ANA` - Analysis\n- `VER` - Verified\n- `REV` - Reviewed\n- `REP` - Reported\n\n**Priority Values:**\n- `R` - Routine\n- `S` - Stat\n- `U` - Urgent\n\n**OrderTest Schema:**\n- `OrderID` - Order identifier\n- `PatientID` - Reference to patient\n- `VisitID` - Reference to visit\n- `OrderDate` - Order timestamp (ISO 8601)\n- `OrderStatus` - Status code (ORD, SCH, ANA, VER, REV, REP)\n- `Priority` - Priority code (R, S, U)\n- `SiteID` - Reference to site\n- `RequestingPhysician` - Requesting physician identifier\n\n**Create Order Request:**\n```json\n{\n \"PatientID\": \"string\",\n \"VisitID\": \"string\" (optional),\n \"Priority\": \"R|S|U\",\n \"SiteID\": integer (optional),\n \"RequestingPhysician\": \"string\" (optional),\n \"Tests\": [\n {\n \"TestID\": integer,\n \"SpecimenType\": \"string\" (optional)\n }\n ]\n}\n```
|
||||
|
||||
---
|
||||
|
||||
@ -295,56 +274,65 @@ src/
|
||||
|
||||
**API Endpoints:**
|
||||
- `GET /api/dashboard` - Get dashboard summary
|
||||
- `GET /api/result` - Get patient results
|
||||
- `GET /api/sample` - Get samples
|
||||
- Returns: pendingOrders, todayResults, criticalResults, activePatients
|
||||
- `GET /api/result` - Get patient results (filter by PatientID, pagination with page)
|
||||
|
||||
**Dashboard Metrics:**
|
||||
- pendingOrders
|
||||
- todayResults
|
||||
- criticalResults
|
||||
- activePatients
|
||||
- pendingOrders - Number of pending orders
|
||||
- todayResults - Results processed today
|
||||
- criticalResults - Critical/panic results
|
||||
- activePatients - Currently active patients
|
||||
|
||||
---
|
||||
|
||||
### Phase 8: Organization Structure
|
||||
**Priority:** Medium | **Time Estimate:** 2-3 hours
|
||||
### Phase 8: User-defined ValueSets
|
||||
**Priority:** Medium | **Time Estimate:** 3-4 hours
|
||||
**Dependencies:** Test Catalog (Phase 5)
|
||||
|
||||
- [ ] Accounts management
|
||||
- [ ] Sites management
|
||||
- [ ] Disciplines management
|
||||
- [ ] Departments management
|
||||
- [ ] Workstations management
|
||||
User-defined ValueSets are created and managed by lab administrators in the database (not from JSON files). These require full CRUD operations and are used for test result interpretation (reference ranges, flags, interpretations).
|
||||
|
||||
- [ ] User ValueSet definitions list page
|
||||
- [ ] User ValueSet definition create/edit form
|
||||
- [ ] User ValueSet items management (CRUD)
|
||||
|
||||
**API Endpoints:**
|
||||
- `GET /api/organization/account` - List accounts
|
||||
- `POST /api/organization/account` - Create account
|
||||
- `PATCH /api/organization/account` - Update account
|
||||
- `DELETE /api/organization/account` - Delete account
|
||||
- `GET /api/organization/account/{id}` - Get account details
|
||||
- `GET /api/organization/site` - List sites
|
||||
- `POST /api/organization/site` - Create site
|
||||
- `PATCH /api/organization/site` - Update site
|
||||
- `DELETE /api/organization/site` - Delete site
|
||||
- `GET /api/organization/site/{id}` - Get site details
|
||||
- `GET /api/organization/discipline` - List disciplines
|
||||
- `POST /api/organization/discipline` - Create discipline
|
||||
- `PATCH /api/organization/discipline` - Update discipline
|
||||
- `DELETE /api/organization/discipline` - Delete discipline
|
||||
- `GET /api/organization/discipline/{id}` - Get discipline details
|
||||
- `GET /api/organization/department` - List departments
|
||||
- `POST /api/organization/department` - Create department
|
||||
- `PATCH /api/organization/department` - Update department
|
||||
- `DELETE /api/organization/department` - Delete department
|
||||
- `GET /api/organization/department/{id}` - Get department details
|
||||
- `GET /api/organization/workstation` - List workstations
|
||||
- `POST /api/organization/workstation` - Create workstation
|
||||
- `PATCH /api/organization/workstation` - Update workstation
|
||||
- `DELETE /api/organization/workstation` - Delete workstation
|
||||
- `GET /api/organization/workstation/{id}` - Get workstation details
|
||||
|
||||
**User ValueSet Definitions:**
|
||||
- `GET /api/valueset/user/def` - List user value set definitions
|
||||
- `POST /api/valueset/user/def` - Create user value set definition
|
||||
- `GET /api/valueset/user/def/{id}` - Get user value set definition by ID
|
||||
- `PUT /api/valueset/user/def/{id}` - Update user value set definition
|
||||
- `DELETE /api/valueset/user/def/{id}` - Delete user value set definition
|
||||
|
||||
**User ValueSet Items:**
|
||||
- `GET /api/valueset/user/items` - List user value set items (filter by VSetID)
|
||||
- `POST /api/valueset/user/items` - Create user value set item
|
||||
- `GET /api/valueset/user/items/{id}` - Get user value set item by ID
|
||||
- `PUT /api/valueset/user/items/{id}` - Update user value set item
|
||||
- `DELETE /api/valueset/user/items/{id}` - Delete user value set item
|
||||
|
||||
**ValueSetDef Schema:**
|
||||
- `id` - Primary key
|
||||
- `VSetCode` - Value set code
|
||||
- `VSetName` - Value set name
|
||||
- `Description` - Description
|
||||
- `Category` - Category
|
||||
|
||||
**ValueSetItem Schema:**
|
||||
- `id` - Primary key
|
||||
- `VSetID` - Reference to ValueSet definition
|
||||
- `VValue` - Value code
|
||||
- `VLabel` - Display label
|
||||
- `VSeq` - Sequence order
|
||||
- `IsActive` - Active flag (boolean)
|
||||
|
||||
---
|
||||
|
||||
### Phase 9: Edge API (Instrument Integration)
|
||||
### Phase 9: Organization Structure\n**Priority:** Medium | **Time Estimate:** 2-3 hours\n\n- [ ] Accounts management\n- [ ] Sites management\n- [ ] Disciplines management\n- [ ] Departments management\n- [ ] Workstations management\n\n**API Endpoints:**\n\n**Accounts:**\n- `GET /api/organization/account` - List accounts\n- `POST /api/organization/account` - Create account\n- `PATCH /api/organization/account` - Update account (body: { id, AccountName, AccountCode, AccountType })\n- `DELETE /api/organization/account` - Delete account (body: { id })\n- `GET /api/organization/account/{id}` - Get account details\n\n**Account Schema:**\n- `id` - Primary key\n- `AccountName` - Account name\n- `AccountCode` - Account code\n- `AccountType` - Account type\n\n**Sites:**\n- `GET /api/organization/site` - List sites\n- `POST /api/organization/site` - Create site\n- `PATCH /api/organization/site` - Update site (body: { id, SiteName, SiteCode, AccountID })\n- `DELETE /api/organization/site` - Delete site (body: { id })\n- `GET /api/organization/site/{id}` - Get site details\n\n**Site Schema:**\n- `id` - Primary key\n- `SiteName` - Site name\n- `SiteCode` - Site code\n- `AccountID` - Reference to account\n\n**Disciplines:**\n- `GET /api/organization/discipline` - List disciplines\n- `POST /api/organization/discipline` - Create discipline\n- `PATCH /api/organization/discipline` - Update discipline (body: { id, DisciplineName, DisciplineCode })\n- `DELETE /api/organization/discipline` - Delete discipline (body: { id })\n- `GET /api/organization/discipline/{id}` - Get discipline details\n\n**Discipline Schema:**\n- `id` - Primary key\n- `DisciplineName` - Discipline name\n- `DisciplineCode` - Discipline code\n\n**Departments:**\n- `GET /api/organization/department` - List departments\n- `POST /api/organization/department` - Create department\n- `PATCH /api/organization/department` - Update department (body: { id, DeptName, DeptCode, SiteID })\n- `DELETE /api/organization/department` - Delete department (body: { id })\n- `GET /api/organization/department/{id}` - Get department details\n\n**Department Schema:**\n- `id` - Primary key\n- `DeptName` - Department name\n- `DeptCode` - Department code\n- `SiteID` - Reference to site\n\n**Workstations:**\n- `GET /api/organization/workstation` - List workstations\n- `POST /api/organization/workstation` - Create workstation\n- `PATCH /api/organization/workstation` - Update workstation (body: { id, WorkstationName, WorkstationCode, SiteID, DepartmentID })\n- `DELETE /api/organization/workstation` - Delete workstation (body: { id })\n- `GET /api/organization/workstation/{id}` - Get workstation details\n\n**Workstation Schema:**\n- `id` - Primary key\n- `WorkstationName` - Workstation name\n- `WorkstationCode` - Workstation code\n- `SiteID` - Reference to site\n- `DepartmentID` - Reference to department
|
||||
|
||||
---
|
||||
|
||||
### Phase 10: Edge API (Instrument Integration)
|
||||
**Priority:** Low | **Time Estimate:** 2-3 hours
|
||||
|
||||
- [ ] Edge results viewer
|
||||
@ -352,9 +340,44 @@ src/
|
||||
- [ ] Instrument status monitoring
|
||||
|
||||
**API Endpoints:**
|
||||
- `GET /api/edge/orders` - Fetch pending orders
|
||||
- `POST /api/edge/orders/{orderId}/ack` - Acknowledge order
|
||||
- `POST /api/edge/status` - Log instrument status
|
||||
- `GET /api/edge/orders` - Fetch pending orders (filter by instrument_id, status)
|
||||
- `POST /api/edge/orders/{orderId}/ack` - Acknowledge order delivery
|
||||
- `POST /api/edge/results` - Receive results from instrument (tiny-edge)
|
||||
- `POST /api/edge/status` - Log instrument status (status: online, offline, error, maintenance)
|
||||
|
||||
---
|
||||
|
||||
### Phase 11: Authentication
|
||||
**Priority:** High | **Time Estimate:** 1-2 hours | **Status:** ✅ COMPLETED
|
||||
|
||||
Foundation authentication system for user login/logout and session management.
|
||||
|
||||
- [x] Login page with form validation
|
||||
- [x] JWT token handling (HTTP-only cookie)
|
||||
- [x] Logout functionality
|
||||
- [x] Auth state management
|
||||
- [x] Protected route guards
|
||||
- [x] Auth status checking
|
||||
|
||||
**API Endpoints:**
|
||||
|
||||
**V1 Authentication:**
|
||||
- `POST /api/auth/login` - User login (returns JWT in cookie)
|
||||
- `POST /api/auth/logout` - User logout
|
||||
- `GET /api/auth/check` - Check authentication status
|
||||
- `POST /api/auth/register` - Register new user
|
||||
- `POST /api/auth/change_pass` - Change password (authenticated)
|
||||
|
||||
**V2 Authentication:**
|
||||
- `POST /v2/auth/login` - V2 User login
|
||||
- `POST /v2/auth/logout` - V2 User logout
|
||||
- `GET /v2/auth/check` - V2 Check authentication
|
||||
- `POST /v2/auth/register` - V2 Register new user
|
||||
|
||||
**Security:**
|
||||
- JWT tokens stored in HTTP-only cookies
|
||||
- Bearer token support for API requests
|
||||
- Automatic redirect to login on 401 responses
|
||||
|
||||
---
|
||||
|
||||
@ -363,6 +386,8 @@ src/
|
||||
### 1. FormInput.svelte
|
||||
Text input with label, validation, and error display.
|
||||
|
||||
**Status:** Not yet implemented - using native inputs with DaisyUI styling
|
||||
|
||||
```svelte
|
||||
<FormInput
|
||||
label="Patient ID"
|
||||
@ -373,7 +398,7 @@ Text input with label, validation, and error display.
|
||||
/>
|
||||
```
|
||||
|
||||
### 2. SelectDropdown.svelte
|
||||
### 2. SelectDropdown.svelte ✅ COMPLETED
|
||||
Dropdown populated from ValueSets or API data.
|
||||
|
||||
```svelte
|
||||
@ -385,7 +410,7 @@ Dropdown populated from ValueSets or API data.
|
||||
/>
|
||||
```
|
||||
|
||||
### 3. DataTable.svelte
|
||||
### 3. DataTable.svelte ✅ COMPLETED
|
||||
Sortable, paginated table with actions.
|
||||
|
||||
```svelte
|
||||
@ -401,7 +426,9 @@ Sortable, paginated table with actions.
|
||||
### 4. SearchBar.svelte
|
||||
Search input with debounce.
|
||||
|
||||
### 5. Modal.svelte
|
||||
**Status:** Not yet implemented
|
||||
|
||||
### 5. Modal.svelte ✅ COMPLETED
|
||||
Reusable modal for confirmations and forms.
|
||||
|
||||
---
|
||||
@ -478,13 +505,13 @@ import { writable } from 'svelte/store';
|
||||
|
||||
export const valueSets = writable({});
|
||||
|
||||
export async function loadValueSet(code) {
|
||||
const res = await fetch(`/api/valueset?VSetCode=${code}`);
|
||||
export async function loadValueSet(key) {
|
||||
const res = await fetch(`/api/valueset/${key}`);
|
||||
const data = await res.json();
|
||||
|
||||
valueSets.update(vs => ({
|
||||
...vs,
|
||||
[code]: data.data || []
|
||||
[key]: data.data?.Items || []
|
||||
}));
|
||||
}
|
||||
|
||||
@ -536,12 +563,13 @@ VITE_API_URL=http://localhost/clqms01
|
||||
```
|
||||
Dashboard (Home)
|
||||
├── Master Data
|
||||
│ ├── ValueSets
|
||||
│ ├── System ValueSets (read-only from JSON)
|
||||
│ ├── Locations
|
||||
│ ├── Contacts
|
||||
│ ├── Occupations
|
||||
│ ├── Medical Specialties
|
||||
│ └── Counters
|
||||
│ ├── Counters
|
||||
│ └── Geography (Provinces/Cities)
|
||||
├── Organization
|
||||
│ ├── Accounts
|
||||
│ ├── Sites
|
||||
@ -555,7 +583,8 @@ Dashboard (Home)
|
||||
│ ├── Specimens
|
||||
│ ├── Tests
|
||||
│ ├── Orders
|
||||
│ └── Results
|
||||
│ ├── Results
|
||||
│ └── User ValueSets (CRUD in database)
|
||||
└── Edge
|
||||
└── Instrument Status
|
||||
```
|
||||
@ -570,3 +599,208 @@ Dashboard (Home)
|
||||
- Implement optimistic updates where appropriate
|
||||
- Use loading states for all async operations
|
||||
- Add toast notifications for success/error feedback
|
||||
|
||||
---
|
||||
|
||||
## API Reference Summary
|
||||
|
||||
### Authentication
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/api/auth/login` | User login (JWT in cookie) |
|
||||
| POST | `/api/auth/logout` | User logout |
|
||||
| GET | `/api/auth/check` | Check auth status |
|
||||
| POST | `/api/auth/register` | Register new user |
|
||||
| POST | `/api/auth/change_pass` | Change password |
|
||||
| POST | `/v2/auth/login` | V2 Login |
|
||||
| POST | `/v2/auth/logout` | V2 Logout |
|
||||
| GET | `/v2/auth/check` | V2 Auth check |
|
||||
| POST | `/v2/auth/register` | V2 Register |
|
||||
|
||||
### ValueSets (Library/System)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/valueset` | List all library value sets |
|
||||
| GET | `/api/valueset/{key}` | Get value set by key |
|
||||
| POST | `/api/valueset/refresh` | Refresh cache from JSON |
|
||||
|
||||
### ValueSets (User-defined)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/valueset/user/def` | List user value set definitions |
|
||||
| POST | `/api/valueset/user/def` | Create definition |
|
||||
| GET | `/api/valueset/user/def/{id}` | Get definition by ID |
|
||||
| PUT | `/api/valueset/user/def/{id}` | Update definition |
|
||||
| DELETE | `/api/valueset/user/def/{id}` | Delete definition |
|
||||
| GET | `/api/valueset/user/items` | List items (filter: VSetID) |
|
||||
| POST | `/api/valueset/user/items` | Create item |
|
||||
| GET | `/api/valueset/user/items/{id}` | Get item by ID |
|
||||
| PUT | `/api/valueset/user/items/{id}` | Update item |
|
||||
| DELETE | `/api/valueset/user/items/{id}` | Delete item |
|
||||
|
||||
### Master Data - Locations
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/location` | List locations (query: LocCode, LocName) |
|
||||
| POST | `/api/location` | Create location |
|
||||
| PATCH | `/api/location` | Update location |
|
||||
| DELETE | `/api/location` | Delete location |
|
||||
| GET | `/api/location/{id}` | Get location by ID |
|
||||
|
||||
### Master Data - Contacts
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/contact` | List contacts |
|
||||
| POST | `/api/contact` | Create contact |
|
||||
| PATCH | `/api/contact` | Update contact |
|
||||
| DELETE | `/api/contact` | Delete contact |
|
||||
| GET | `/api/contact/{id}` | Get contact by ID |
|
||||
|
||||
### Master Data - Supporting Entities
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/occupation` | List occupations |
|
||||
| POST | `/api/occupation` | Create occupation |
|
||||
| PATCH | `/api/occupation` | Update occupation |
|
||||
| GET | `/api/occupation/{id}` | Get occupation by ID |
|
||||
| GET | `/api/medicalspecialty` | List specialties |
|
||||
| POST | `/api/medicalspecialty` | Create specialty |
|
||||
| PATCH | `/api/medicalspecialty` | Update specialty |
|
||||
| GET | `/api/medicalspecialty/{id}` | Get specialty by ID |
|
||||
| GET | `/api/counter` | List counters |
|
||||
| POST | `/api/counter` | Create counter |
|
||||
| PATCH | `/api/counter` | Update counter |
|
||||
| DELETE | `/api/counter` | Delete counter |
|
||||
| GET | `/api/counter/{id}` | Get counter by ID |
|
||||
|
||||
### Master Data - Geography (Read-only)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/areageo` | List all geographical areas |
|
||||
| GET | `/api/areageo/provinces` | List provinces |
|
||||
| GET | `/api/areageo/cities` | List cities (query: province_id) |
|
||||
|
||||
### Patients
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/patient` | List patients (query: page, perPage, Name, etc.) |
|
||||
| POST | `/api/patient` | Create patient |
|
||||
| PATCH | `/api/patient` | Update patient |
|
||||
| DELETE | `/api/patient` | Delete patient (soft delete) |
|
||||
| GET | `/api/patient/{id}` | Get patient by ID |
|
||||
| GET | `/api/patient/check` | Check if patient exists |
|
||||
|
||||
### Patient Visits
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/patvisit` | List visits (query: page, perPage) |
|
||||
| POST | `/api/patvisit` | Create visit |
|
||||
| PATCH | `/api/patvisit` | Update visit |
|
||||
| DELETE | `/api/patvisit` | Delete visit |
|
||||
| GET | `/api/patvisit/{id}` | Get visit by PVID |
|
||||
| GET | `/api/patvisit/patient/{patientId}` | Get visits by patient |
|
||||
| POST | `/api/patvisitadt` | Create ADT visit |
|
||||
| PATCH | `/api/patvisitadt` | Update ADT visit |
|
||||
|
||||
### Organization - Accounts
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/organization/account` | List accounts |
|
||||
| POST | `/api/organization/account` | Create account |
|
||||
| PATCH | `/api/organization/account` | Update account |
|
||||
| DELETE | `/api/organization/account` | Delete account |
|
||||
| GET | `/api/organization/account/{id}` | Get account by ID |
|
||||
|
||||
### Organization - Sites
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/organization/site` | List sites |
|
||||
| POST | `/api/organization/site` | Create site |
|
||||
| PATCH | `/api/organization/site` | Update site |
|
||||
| DELETE | `/api/organization/site` | Delete site |
|
||||
| GET | `/api/organization/site/{id}` | Get site by ID |
|
||||
|
||||
### Organization - Disciplines
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/organization/discipline` | List disciplines |
|
||||
| POST | `/api/organization/discipline` | Create discipline |
|
||||
| PATCH | `/api/organization/discipline` | Update discipline |
|
||||
| DELETE | `/api/organization/discipline` | Delete discipline |
|
||||
| GET | `/api/organization/discipline/{id}` | Get discipline by ID |
|
||||
|
||||
### Organization - Departments
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/organization/department` | List departments |
|
||||
| POST | `/api/organization/department` | Create department |
|
||||
| PATCH | `/api/organization/department` | Update department |
|
||||
| DELETE | `/api/organization/department` | Delete department |
|
||||
| GET | `/api/organization/department/{id}` | Get department by ID |
|
||||
|
||||
### Organization - Workstations
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/organization/workstation` | List workstations |
|
||||
| POST | `/api/organization/workstation` | Create workstation |
|
||||
| PATCH | `/api/organization/workstation` | Update workstation |
|
||||
| DELETE | `/api/organization/workstation` | Delete workstation |
|
||||
| GET | `/api/organization/workstation/{id}` | Get workstation by ID |
|
||||
|
||||
### Specimen Management
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/specimen` | List specimens |
|
||||
| POST | `/api/specimen` | Create specimen |
|
||||
| PATCH | `/api/specimen` | Update specimen |
|
||||
| GET | `/api/specimen/{id}` | Get specimen by ID |
|
||||
| GET | `/api/specimen/container` | List containers |
|
||||
| POST | `/api/specimen/container` | Create container |
|
||||
| PATCH | `/api/specimen/container` | Update container |
|
||||
| GET | `/api/specimen/container/{id}` | Get container by ID |
|
||||
| GET | `/api/specimen/prep` | List preparations |
|
||||
| POST | `/api/specimen/prep` | Create preparation |
|
||||
| PATCH | `/api/specimen/prep` | Update preparation |
|
||||
| GET | `/api/specimen/prep/{id}` | Get preparation by ID |
|
||||
| GET | `/api/specimen/status` | List statuses |
|
||||
| POST | `/api/specimen/status` | Create status |
|
||||
| PATCH | `/api/specimen/status` | Update status |
|
||||
| GET | `/api/specimen/status/{id}` | Get status by ID |
|
||||
| GET | `/api/specimen/collection` | List collection methods |
|
||||
| POST | `/api/specimen/collection` | Create collection method |
|
||||
| PATCH | `/api/specimen/collection` | Update collection method |
|
||||
| GET | `/api/specimen/collection/{id}` | Get collection method by ID |
|
||||
| GET | `/api/sample` | Get samples |
|
||||
|
||||
### Test Catalog
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/tests` | List tests (query: TestType, SiteID, etc.) |
|
||||
| POST | `/api/tests` | Create test |
|
||||
| PATCH | `/api/tests` | Update test |
|
||||
| GET | `/api/tests/{id}` | Get test by ID |
|
||||
|
||||
### Orders
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/ordertest` | List orders (query: OrderStatus, InternalPID) |
|
||||
| POST | `/api/ordertest` | Create order |
|
||||
| PATCH | `/api/ordertest` | Update order |
|
||||
| DELETE | `/api/ordertest` | Delete order |
|
||||
| GET | `/api/ordertest/{id}` | Get order by ID |
|
||||
| POST | `/api/ordertest/status` | Update order status |
|
||||
|
||||
### Results & Dashboard
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/dashboard` | Get dashboard summary |
|
||||
| GET | `/api/result` | Get patient results (query: PatientID, page) |
|
||||
|
||||
### Edge API (Instrument Integration)
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/edge/orders` | Fetch pending orders |
|
||||
| POST | `/api/edge/orders/{orderId}/ack` | Acknowledge order |
|
||||
| POST | `/api/edge/results` | Receive instrument results |
|
||||
| POST | `/api/edge/status` | Log instrument status |
|
||||
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin 'daisyui';
|
||||
@plugin 'daisyui' {
|
||||
themes: light --default, emerald, forest, dark;
|
||||
}
|
||||
|
||||
@theme {
|
||||
/* Custom theme variables can be added here */
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="emerald">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
@ -33,5 +33,5 @@ export async function logout() {
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function getCurrentUser() {
|
||||
return post('/api/auth/me', {});
|
||||
return post('/api/auth/check', {});
|
||||
}
|
||||
|
||||
@ -94,6 +94,20 @@ export function put(endpoint, body, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH request helper
|
||||
* @param {string} endpoint
|
||||
* @param {Object} body
|
||||
* @param {Object} options
|
||||
*/
|
||||
export function patch(endpoint, body, options = {}) {
|
||||
return apiClient(endpoint, {
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request helper
|
||||
* @param {string} endpoint
|
||||
|
||||
22
src/lib/api/contacts.js
Normal file
22
src/lib/api/contacts.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { get, post, patch, del } from './client.js';
|
||||
|
||||
export async function fetchContacts(params = {}) {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return get(query ? `/api/contact?${query}` : '/api/contact');
|
||||
}
|
||||
|
||||
export async function fetchContact(id) {
|
||||
return get(`/api/contact/${id}`);
|
||||
}
|
||||
|
||||
export async function createContact(data) {
|
||||
return post('/api/contact', data);
|
||||
}
|
||||
|
||||
export async function updateContact(data) {
|
||||
return patch('/api/contact', data);
|
||||
}
|
||||
|
||||
export async function deleteContact(id) {
|
||||
return del('/api/contact', { body: JSON.stringify({ ContactID: id }) });
|
||||
}
|
||||
22
src/lib/api/counters.js
Normal file
22
src/lib/api/counters.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { get, post, patch, del } from './client.js';
|
||||
|
||||
export async function fetchCounters(params = {}) {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return get(query ? `/api/counter?${query}` : '/api/counter');
|
||||
}
|
||||
|
||||
export async function fetchCounter(id) {
|
||||
return get(`/api/counter/${id}`);
|
||||
}
|
||||
|
||||
export async function createCounter(data) {
|
||||
return post('/api/counter', data);
|
||||
}
|
||||
|
||||
export async function updateCounter(data) {
|
||||
return patch('/api/counter', data);
|
||||
}
|
||||
|
||||
export async function deleteCounter(id) {
|
||||
return del('/api/counter', { body: JSON.stringify({ CounterID: id }) });
|
||||
}
|
||||
14
src/lib/api/geography.js
Normal file
14
src/lib/api/geography.js
Normal file
@ -0,0 +1,14 @@
|
||||
import { get } from './client.js';
|
||||
|
||||
export async function fetchGeographicalAreas() {
|
||||
return get('/api/areageo');
|
||||
}
|
||||
|
||||
export async function fetchProvinces() {
|
||||
return get('/api/areageo/provinces');
|
||||
}
|
||||
|
||||
export async function fetchCities(provinceId = null) {
|
||||
const query = provinceId ? `?province_id=${provinceId}` : '';
|
||||
return get(`/api/areageo/cities${query}`);
|
||||
}
|
||||
35
src/lib/api/locations.js
Normal file
35
src/lib/api/locations.js
Normal file
@ -0,0 +1,35 @@
|
||||
import { get, post, patch, del } from './client.js';
|
||||
|
||||
export async function fetchLocations(params = {}) {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return get(query ? `/api/location?${query}` : '/api/location');
|
||||
}
|
||||
|
||||
export async function fetchLocation(id) {
|
||||
return get(`/api/location/${id}`);
|
||||
}
|
||||
|
||||
export async function createLocation(data) {
|
||||
const payload = {
|
||||
LocCode: data.Code,
|
||||
LocFull: data.Name,
|
||||
LocType: data.Type,
|
||||
Parent: data.ParentID,
|
||||
};
|
||||
return post('/api/location', payload);
|
||||
}
|
||||
|
||||
export async function updateLocation(data) {
|
||||
const payload = {
|
||||
LocationID: data.LocationID,
|
||||
LocCode: data.Code,
|
||||
LocFull: data.Name,
|
||||
LocType: data.Type,
|
||||
Parent: data.ParentID,
|
||||
};
|
||||
return patch('/api/location', payload);
|
||||
}
|
||||
|
||||
export async function deleteLocation(id) {
|
||||
return del('/api/location', { body: JSON.stringify({ LocationID: id }) });
|
||||
}
|
||||
29
src/lib/api/occupations.js
Normal file
29
src/lib/api/occupations.js
Normal file
@ -0,0 +1,29 @@
|
||||
import { get, post, patch } from './client.js';
|
||||
|
||||
export async function fetchOccupations(params = {}) {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return get(query ? `/api/occupation?${query}` : '/api/occupation');
|
||||
}
|
||||
|
||||
export async function fetchOccupation(id) {
|
||||
return get(`/api/occupation/${id}`);
|
||||
}
|
||||
|
||||
export async function createOccupation(data) {
|
||||
const payload = {
|
||||
OccCode: data.OccCode,
|
||||
OccText: data.OccText,
|
||||
Description: data.Description,
|
||||
};
|
||||
return post('/api/occupation', payload);
|
||||
}
|
||||
|
||||
export async function updateOccupation(data) {
|
||||
const payload = {
|
||||
OccupationID: data.OccupationID,
|
||||
OccCode: data.OccCode,
|
||||
OccText: data.OccText,
|
||||
Description: data.Description,
|
||||
};
|
||||
return patch('/api/occupation', payload);
|
||||
}
|
||||
22
src/lib/api/specialties.js
Normal file
22
src/lib/api/specialties.js
Normal file
@ -0,0 +1,22 @@
|
||||
import { get, post, patch, del } from './client.js';
|
||||
|
||||
export async function fetchSpecialties(params = {}) {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return get(query ? `/api/medicalspecialty?${query}` : '/api/medicalspecialty');
|
||||
}
|
||||
|
||||
export async function fetchSpecialty(id) {
|
||||
return get(`/api/medicalspecialty/${id}`);
|
||||
}
|
||||
|
||||
export async function createSpecialty(data) {
|
||||
return post('/api/medicalspecialty', data);
|
||||
}
|
||||
|
||||
export async function updateSpecialty(data) {
|
||||
return patch('/api/medicalspecialty', data);
|
||||
}
|
||||
|
||||
export async function deleteSpecialty(id) {
|
||||
return del('/api/medicalspecialty', { body: JSON.stringify({ id }) });
|
||||
}
|
||||
73
src/lib/api/valuesets.js
Normal file
73
src/lib/api/valuesets.js
Normal file
@ -0,0 +1,73 @@
|
||||
import { get } from './client.js';
|
||||
|
||||
/**
|
||||
* ValueSets API endpoints
|
||||
* System ValueSets are read-only lookup values used throughout the application
|
||||
*/
|
||||
|
||||
/**
|
||||
* Fetch list of value set definitions
|
||||
* @param {Object} params - Query parameters
|
||||
* @param {string} [params.key] - Filter by value set key
|
||||
* @returns {Promise<{status: string, data: Array}>}
|
||||
*/
|
||||
export async function fetchValueSets(params = {}) {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params.key) {
|
||||
queryParams.append('key', params.key);
|
||||
}
|
||||
|
||||
const query = queryParams.toString();
|
||||
const endpoint = query ? `/api/valueset?${query}` : '/api/valueset';
|
||||
|
||||
return get(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single value set by its key (includes items)
|
||||
* @param {string} key - Value set key (e.g., 'GENDER', 'MARITAL_STATUS')
|
||||
* @returns {Promise<{status: string, data: Object}>}
|
||||
*/
|
||||
export async function fetchValueSetByKey(key) {
|
||||
return get(`/api/valueset/${encodeURIComponent(key)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch value set items
|
||||
* @param {Object} params - Query parameters
|
||||
* @param {string} [params.key] - Filter by value set key
|
||||
* @returns {Promise<{status: string, data: Array}>}
|
||||
*/
|
||||
export async function fetchValueSetItems(params = {}) {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
if (params.key) {
|
||||
queryParams.append('key', params.key);
|
||||
}
|
||||
|
||||
const query = queryParams.toString();
|
||||
const endpoint = query ? `/api/valueset/items?${query}` : '/api/valueset/items';
|
||||
|
||||
return get(endpoint);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh value sets cache on the server
|
||||
* @param {Object} params - Request parameters
|
||||
* @param {string} [params.key] - Specific value set key to refresh (optional)
|
||||
* @returns {Promise<{status: string, message: string}>}
|
||||
*/
|
||||
export async function refreshValueSets(params = {}) {
|
||||
const { post } = await import('./client.js');
|
||||
return post('/api/valueset', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single value set item by ID
|
||||
* @param {number} itemId - Item ID
|
||||
* @returns {Promise<{status: string, data: Object}>}
|
||||
*/
|
||||
export async function fetchValueSetItemById(itemId) {
|
||||
return get(`/api/valueset/items/${itemId}`);
|
||||
}
|
||||
120
src/lib/components/DataTable.svelte
Normal file
120
src/lib/components/DataTable.svelte
Normal file
@ -0,0 +1,120 @@
|
||||
<script>
|
||||
/**
|
||||
* @typedef {Object} Column
|
||||
* @property {string} key - Column key (accessor)
|
||||
* @property {string} label - Column header label
|
||||
* @property {string} [class] - CSS classes for the column
|
||||
* @property {Function} [render] - Custom render function
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Props
|
||||
* @property {Array<Column>} columns - Column definitions
|
||||
* @property {Array<Object>} data - Table data rows
|
||||
* @property {boolean} [loading] - Whether data is loading
|
||||
* @property {string} [emptyMessage] - Message when no data
|
||||
* @property {boolean} [striped] - Whether to stripe rows
|
||||
* @property {boolean} [hover] - Whether rows have hover effect
|
||||
* @property {boolean} [bordered] - Whether table has borders
|
||||
* @property {function} [onRowClick] - Callback when row is clicked
|
||||
* @property {string} [class] - Additional CSS classes
|
||||
*/
|
||||
|
||||
/** @type {Props & { cell?: import('svelte').Snippet<{ column: Column, row: Object, value: any }> }} */
|
||||
let {
|
||||
columns,
|
||||
data,
|
||||
loading = false,
|
||||
emptyMessage = 'No data available',
|
||||
striped = false,
|
||||
hover = true,
|
||||
bordered = true,
|
||||
onRowClick = null,
|
||||
class: className = '',
|
||||
cell,
|
||||
} = $props();
|
||||
|
||||
/**
|
||||
* Get value from row object using column key (supports nested keys)
|
||||
* @param {Object} row - Data row
|
||||
* @param {string} key - Column key
|
||||
* @returns {any}
|
||||
*/
|
||||
function getValue(row, key) {
|
||||
if (!key) return '';
|
||||
const keys = key.split('.');
|
||||
let value = row;
|
||||
for (const k of keys) {
|
||||
if (value == null) return '';
|
||||
value = value[k];
|
||||
}
|
||||
return value ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle row click
|
||||
* @param {Object} row - Clicked row
|
||||
* @param {number} index - Row index
|
||||
*/
|
||||
function handleRowClick(row, index) {
|
||||
if (onRowClick) {
|
||||
onRowClick(row, index);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto {className}">
|
||||
<table class="table w-full" class:table-zebra={striped} class:table-bordered={bordered}>
|
||||
<thead>
|
||||
<tr class="bg-base-200">
|
||||
{#each columns as column}
|
||||
<th class="font-semibold {column.class || ''}">
|
||||
{column.label}
|
||||
</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{#if loading}
|
||||
<tr>
|
||||
<td colspan={columns.length} class="text-center py-8">
|
||||
<span class="loading loading-spinner loading-md text-primary"></span>
|
||||
<p class="text-sm text-gray-500 mt-2">Loading...</p>
|
||||
</td>
|
||||
</tr>
|
||||
{:else if data.length === 0}
|
||||
<tr>
|
||||
<td colspan={columns.length} class="text-center py-8 text-gray-500">
|
||||
{emptyMessage}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each data as row, index}
|
||||
<tr
|
||||
class:bg-base-100={true}
|
||||
class:hover={hover}
|
||||
class:cursor-pointer={!!onRowClick}
|
||||
class:bg-base-200={index % 2 === 1 && striped}
|
||||
onclick={() => handleRowClick(row, index)}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleRowClick(row, index)}
|
||||
tabindex={onRowClick ? 0 : -1}
|
||||
role={onRowClick ? 'button' : undefined}
|
||||
>
|
||||
{#each columns as column}
|
||||
<td class="{column.class || ''}">
|
||||
{#if cell}
|
||||
{@render cell({ column, row, value: getValue(row, column.key) })}
|
||||
{:else if column.render}
|
||||
{@render column.render(row)}
|
||||
{:else}
|
||||
{getValue(row, column.key)}
|
||||
{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
107
src/lib/components/Modal.svelte
Normal file
107
src/lib/components/Modal.svelte
Normal file
@ -0,0 +1,107 @@
|
||||
<script>
|
||||
import { X } from 'lucide-svelte';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Props
|
||||
* @property {boolean} open - Whether modal is open
|
||||
* @property {string} [title] - Modal title
|
||||
* @property {string} [size] - Modal size (sm, md, lg, xl, full)
|
||||
* @property {boolean} [closable] - Whether modal can be closed by clicking backdrop
|
||||
* @property {Function} [onClose] - Callback when modal is closed
|
||||
* @property {import('svelte').Snippet} [children] - Modal content
|
||||
* @property {import('svelte').Snippet} [footer] - Modal footer
|
||||
*/
|
||||
|
||||
/** @type {Props} */
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title = '',
|
||||
size = 'md',
|
||||
closable = true,
|
||||
onClose = null,
|
||||
children,
|
||||
footer,
|
||||
} = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'modal-sm',
|
||||
md: '',
|
||||
lg: 'modal-lg',
|
||||
xl: 'modal-xl',
|
||||
full: 'modal-full',
|
||||
};
|
||||
|
||||
/**
|
||||
* Close the modal
|
||||
*/
|
||||
function close() {
|
||||
if (closable) {
|
||||
open = false;
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle backdrop click
|
||||
* @param {MouseEvent} event
|
||||
*/
|
||||
function handleBackdropClick(event) {
|
||||
if (event.target === event.currentTarget && closable) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle escape key
|
||||
* @param {KeyboardEvent} event
|
||||
*/
|
||||
function handleKeydown(event) {
|
||||
if (event.key === 'Escape' && closable) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<dialog class="modal {sizeClasses[size] || ''}" class:modal-open={open}>
|
||||
<div class="modal-box" role="dialog" aria-modal="true" aria-labelledby={title ? 'modal-title' : undefined}>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
{#if title}
|
||||
<h3 id="modal-title" class="font-bold text-lg">{title}</h3>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
|
||||
{#if closable}
|
||||
<button
|
||||
class="btn btn-sm btn-circle btn-ghost"
|
||||
onclick={close}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="py-2">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
{#if footer}
|
||||
<div class="modal-action">
|
||||
{@render footer()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Backdrop -->
|
||||
<form method="dialog" class="modal-backdrop" onclick={handleBackdropClick}>
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
101
src/lib/components/SelectDropdown.svelte
Normal file
101
src/lib/components/SelectDropdown.svelte
Normal file
@ -0,0 +1,101 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { valueSets } from '$lib/stores/valuesets.js';
|
||||
import { ChevronDown } from 'lucide-svelte';
|
||||
|
||||
/**
|
||||
* @typedef {Object} Props
|
||||
* @property {string} label - Input label
|
||||
* @property {string} name - Input name
|
||||
* @property {string} [value] - Selected value
|
||||
* @property {string} [valueSetKey] - ValueSet key to load options from
|
||||
* @property {Array<{value: string, label: string}>} [options] - Manual options array
|
||||
* @property {string} [placeholder] - Placeholder text
|
||||
* @property {boolean} [required] - Whether input is required
|
||||
* @property {boolean} [disabled] - Whether input is disabled
|
||||
* @property {string} [error] - Error message
|
||||
* @property {string} [class] - Additional CSS classes
|
||||
*/
|
||||
|
||||
/** @type {Props} */
|
||||
let {
|
||||
label,
|
||||
name,
|
||||
value = $bindable(''),
|
||||
valueSetKey = '',
|
||||
options = [],
|
||||
placeholder = 'Select...',
|
||||
required = false,
|
||||
disabled = false,
|
||||
error = '',
|
||||
class: className = '',
|
||||
} = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
let dropdownOptions = $state([]);
|
||||
|
||||
// Load options from ValueSet if key is provided
|
||||
onMount(async () => {
|
||||
if (valueSetKey) {
|
||||
loading = true;
|
||||
const items = await valueSets.load(valueSetKey);
|
||||
dropdownOptions = items.map((item) => ({
|
||||
value: item.Value,
|
||||
label: item.Label,
|
||||
}));
|
||||
loading = false;
|
||||
} else if (options.length > 0) {
|
||||
dropdownOptions = options;
|
||||
}
|
||||
});
|
||||
|
||||
// Watch for changes in manual options
|
||||
$effect(() => {
|
||||
if (!valueSetKey && options.length > 0) {
|
||||
dropdownOptions = options;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="form-control w-full {className}">
|
||||
<label class="label" for={name}>
|
||||
<span class="label-text font-medium">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="text-error">*</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="relative">
|
||||
<select
|
||||
{name}
|
||||
id={name}
|
||||
bind:value
|
||||
{required}
|
||||
{disabled}
|
||||
class="select select-bordered w-full pr-10 {error ? 'select-error' : ''}"
|
||||
class:opacity-50={loading}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
|
||||
{#each dropdownOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<ChevronDown class="w-4 h-4 opacity-50" />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-error">{error}</span>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
38
src/lib/components/ToastContainer.svelte
Normal file
38
src/lib/components/ToastContainer.svelte
Normal file
@ -0,0 +1,38 @@
|
||||
<script>
|
||||
import { toasts, removeToast } from '$lib/utils/toast.js';
|
||||
import { X, Info, CheckCircle, AlertTriangle, AlertCircle } from 'lucide-svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
const icons = {
|
||||
info: Info,
|
||||
success: CheckCircle,
|
||||
warning: AlertTriangle,
|
||||
error: AlertCircle,
|
||||
};
|
||||
|
||||
const alertClasses = {
|
||||
info: 'alert-info',
|
||||
success: 'alert-success',
|
||||
warning: 'alert-warning',
|
||||
error: 'alert-error',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="toast toast-top toast-end z-50 gap-2">
|
||||
{#each $toasts as toast (toast.id)}
|
||||
<div
|
||||
class="alert {alertClasses[toast.type]} shadow-lg"
|
||||
transition:fly={{ x: 100, duration: 300 }}
|
||||
>
|
||||
<svelte:component this={icons[toast.type]} class="w-5 h-5" />
|
||||
<span>{toast.message}</span>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost btn-circle"
|
||||
onclick={() => removeToast(toast.id)}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
193
src/lib/stores/valuesets.js
Normal file
193
src/lib/stores/valuesets.js
Normal file
@ -0,0 +1,193 @@
|
||||
import { writable, derived } from 'svelte/store';
|
||||
import { fetchValueSetByKey } from '$lib/api/valuesets.js';
|
||||
import { error as toastError } from '$lib/utils/toast.js';
|
||||
|
||||
/**
|
||||
* ValueSets store - in-memory cache for system ValueSets
|
||||
* Note: Backend handles caching, this is just for reducing API calls during session
|
||||
*/
|
||||
|
||||
// Store structure: { [key]: { items: [], loaded: boolean, loading: boolean, error: string|null } }
|
||||
function createValueSetsStore() {
|
||||
const { subscribe, set, update } = writable({});
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
|
||||
/**
|
||||
* Load a value set by key (fetch if not in cache)
|
||||
* @param {string} key - Value set key
|
||||
* @returns {Promise<Array>} - Array of value set items
|
||||
*/
|
||||
async load(key) {
|
||||
let result = [];
|
||||
|
||||
update((cache) => {
|
||||
// If already loading, return current state
|
||||
if (cache[key]?.loading) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
// If already loaded, return cached items
|
||||
if (cache[key]?.loaded) {
|
||||
result = cache[key].items;
|
||||
return cache;
|
||||
}
|
||||
|
||||
// Mark as loading
|
||||
return {
|
||||
...cache,
|
||||
[key]: { items: [], loaded: false, loading: true, error: null },
|
||||
};
|
||||
});
|
||||
|
||||
// If already loaded, return immediately
|
||||
if (result.length > 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// If already loading, wait a bit and retry
|
||||
const currentState = getCurrentState();
|
||||
if (currentState[key]?.loading) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return this.load(key);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetchValueSetByKey(key);
|
||||
|
||||
if (response.status === 'success' && response.data) {
|
||||
const items = response.data.Items || [];
|
||||
|
||||
// Sort by Sequence if available
|
||||
items.sort((a, b) => (a.Sequence || 0) - (b.Sequence || 0));
|
||||
|
||||
update((cache) => ({
|
||||
...cache,
|
||||
[key]: { items, loaded: true, loading: false, error: null },
|
||||
}));
|
||||
|
||||
return items;
|
||||
} else {
|
||||
throw new Error(response.message || 'Failed to load value set');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err.message || `Failed to load value set: ${key}`;
|
||||
|
||||
update((cache) => ({
|
||||
...cache,
|
||||
[key]: { items: [], loaded: false, loading: false, error: errorMessage },
|
||||
}));
|
||||
|
||||
toastError(errorMessage);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get value set items (load if needed)
|
||||
* @param {string} key - Value set key
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async get(key) {
|
||||
return this.load(key);
|
||||
},
|
||||
|
||||
/**
|
||||
* Get value set items synchronously (returns cached data or empty array)
|
||||
* @param {string} key - Value set key
|
||||
* @returns {Array}
|
||||
*/
|
||||
getSync(key) {
|
||||
let items = [];
|
||||
subscribe((cache) => {
|
||||
items = cache[key]?.items || [];
|
||||
})();
|
||||
return items;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if value set is loaded
|
||||
* @param {string} key - Value set key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isLoaded(key) {
|
||||
let loaded = false;
|
||||
subscribe((cache) => {
|
||||
loaded = cache[key]?.loaded || false;
|
||||
})();
|
||||
return loaded;
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if value set is loading
|
||||
* @param {string} key - Value set key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isLoading(key) {
|
||||
let loading = false;
|
||||
subscribe((cache) => {
|
||||
loading = cache[key]?.loading || false;
|
||||
})();
|
||||
return loading;
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all cached value sets
|
||||
*/
|
||||
clear() {
|
||||
set({});
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear a specific value set from cache
|
||||
* @param {string} key - Value set key
|
||||
*/
|
||||
clearKey(key) {
|
||||
update((cache) => {
|
||||
const newCache = { ...cache };
|
||||
delete newCache[key];
|
||||
return newCache;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Reload a value set (clear cache and fetch fresh)
|
||||
* @param {string} key - Value set key
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async reload(key) {
|
||||
this.clearKey(key);
|
||||
return this.load(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to get current state synchronously
|
||||
function getCurrentState() {
|
||||
let state = {};
|
||||
valueSets.subscribe((s) => {
|
||||
state = s;
|
||||
})();
|
||||
return state;
|
||||
}
|
||||
|
||||
export const valueSets = createValueSetsStore();
|
||||
|
||||
/**
|
||||
* Derived store to get all loaded value set keys
|
||||
*/
|
||||
export const loadedValueSetKeys = derived(valueSets, ($valueSets) => {
|
||||
return Object.keys($valueSets).filter((key) => $valueSets[key].loaded);
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a derived store for a specific value set
|
||||
* @param {string} key - Value set key
|
||||
* @returns {import('svelte/store').Readable}
|
||||
*/
|
||||
export function getValueSetStore(key) {
|
||||
return derived(valueSets, ($valueSets) => {
|
||||
return $valueSets[key] || { items: [], loaded: false, loading: false, error: null };
|
||||
});
|
||||
}
|
||||
86
src/lib/utils/toast.js
Normal file
86
src/lib/utils/toast.js
Normal file
@ -0,0 +1,86 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
/**
|
||||
* Toast notification system using DaisyUI
|
||||
*/
|
||||
|
||||
export const toasts = writable([]);
|
||||
|
||||
let toastId = 0;
|
||||
|
||||
/**
|
||||
* Add a toast notification
|
||||
* @param {string} message - Toast message
|
||||
* @param {'info'|'success'|'warning'|'error'} type - Toast type
|
||||
* @param {number} duration - Duration in milliseconds
|
||||
*/
|
||||
export function addToast(message, type = 'info', duration = 3000) {
|
||||
const id = ++toastId;
|
||||
|
||||
toasts.update((items) => [
|
||||
...items,
|
||||
{ id, message, type, duration },
|
||||
]);
|
||||
|
||||
// Auto-remove after duration
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a toast by ID
|
||||
* @param {number} id - Toast ID
|
||||
*/
|
||||
export function removeToast(id) {
|
||||
toasts.update((items) => items.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Show info toast
|
||||
* @param {string} message
|
||||
* @param {number} duration
|
||||
*/
|
||||
export function info(message, duration) {
|
||||
return addToast(message, 'info', duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show success toast
|
||||
* @param {string} message
|
||||
* @param {number} duration
|
||||
*/
|
||||
export function success(message, duration) {
|
||||
return addToast(message, 'success', duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show warning toast
|
||||
* @param {string} message
|
||||
* @param {number} duration
|
||||
*/
|
||||
export function warning(message, duration) {
|
||||
return addToast(message, 'warning', duration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error toast
|
||||
* @param {string} message
|
||||
* @param {number} duration
|
||||
*/
|
||||
export function error(message, duration) {
|
||||
return addToast(message, 'error', duration);
|
||||
}
|
||||
|
||||
export default {
|
||||
add: addToast,
|
||||
remove: removeToast,
|
||||
info,
|
||||
success,
|
||||
warning,
|
||||
error,
|
||||
};
|
||||
@ -2,9 +2,12 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { onMount } from 'svelte';
|
||||
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||
import { Menu, BarChart3, User, Settings, LogOut } from 'lucide-svelte';
|
||||
|
||||
let { children } = $props();
|
||||
let checking = $state(true);
|
||||
let sidebarOpen = $state(true);
|
||||
|
||||
onMount(() => {
|
||||
// Check authentication
|
||||
@ -14,6 +17,15 @@
|
||||
checking = false;
|
||||
}
|
||||
});
|
||||
|
||||
function toggleSidebar() {
|
||||
sidebarOpen = !sidebarOpen;
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout();
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if checking}
|
||||
@ -21,37 +33,78 @@
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="min-h-screen">
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar bg-base-200 px-4">
|
||||
<div class="min-h-screen bg-gradient-to-br from-base-200 via-base-100 to-emerald-50/20 flex">
|
||||
<!-- Sidebar - Fixed on mobile, sticky on desktop -->
|
||||
<div class="lg:sticky lg:top-0 lg:h-screen lg:self-start flex-shrink-0">
|
||||
<Sidebar bind:isOpen={sidebarOpen} />
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content flex-1 flex flex-col min-h-screen transition-all duration-300">
|
||||
<!-- Header -->
|
||||
<div class="navbar bg-base-100 shadow-sm border-b border-base-300">
|
||||
<div class="flex-none">
|
||||
<button
|
||||
class="btn btn-square btn-ghost text-gray-600 hover:bg-emerald-50 hover:text-emerald-600"
|
||||
onclick={toggleSidebar}
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<Menu class="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<a href="/dashboard" class="btn btn-ghost text-xl">MyApp</a>
|
||||
<a href="/dashboard" class="btn btn-ghost text-xl font-bold text-gray-800 hover:bg-gray-100">
|
||||
<BarChart3 class="w-6 h-6 mr-2 text-emerald-600" />
|
||||
CLQMS Dashboard
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-none gap-2">
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" class="btn btn-ghost btn-circle avatar">
|
||||
<div class="w-10 rounded-full bg-primary text-primary-content flex items-center justify-center">
|
||||
<span class="text-lg">{$auth.user?.name?.[0] || 'U'}</span>
|
||||
<button tabindex="0" class="btn btn-ghost btn-circle avatar placeholder hover:bg-emerald-50">
|
||||
<div class="bg-emerald-100 text-emerald-700 rounded-full w-10 border-2 border-emerald-200 flex items-center justify-center">
|
||||
<span class="font-semibold">{$auth.user?.name?.split(' ').map(n => n[0]).join('').slice(0, 2).toUpperCase() || 'U'}</span>
|
||||
</div>
|
||||
</button>
|
||||
<ul tabindex="0" class="mt-3 z-[1] p-2 shadow menu menu-sm dropdown-content bg-base-100 rounded-box w-52">
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow-xl bg-base-100 rounded-box w-52 border border-emerald-200 mt-3">
|
||||
<li>
|
||||
<span class="text-sm opacity-70">{$auth.user?.email || 'user@example.com'}</span>
|
||||
<a href="/profile" class="hover:bg-emerald-50">
|
||||
<User class="w-4 h-4 text-emerald-600" />
|
||||
Profile
|
||||
</a>
|
||||
</li>
|
||||
<li class="menu-divider"></li>
|
||||
<li>
|
||||
<button onclick={() => auth.logout() || goto('/login')}>
|
||||
<a href="/settings" class="hover:bg-emerald-50">
|
||||
<Settings class="w-4 h-4 text-emerald-600" />
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
<div class="divider my-0"></div>
|
||||
<li>
|
||||
<button onclick={handleLogout} class="text-error hover:bg-red-50">
|
||||
<LogOut class="w-4 h-4" />
|
||||
Logout
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<main class="p-4">
|
||||
<!-- Main content area -->
|
||||
<main class="flex-1 overflow-auto">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer footer-center p-4 bg-base-200 text-base-content border-t border-base-300">
|
||||
<div class="flex items-center justify-center gap-2 text-sm">
|
||||
<span class="font-semibold text-emerald-600">CLQMS</span>
|
||||
<span class="text-gray-500">|</span>
|
||||
<span class="text-gray-600">Clinical Laboratory Quality Management System</span>
|
||||
<span class="text-gray-500">|</span>
|
||||
<span class="text-gray-500">© 2026</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@ -1,42 +1,126 @@
|
||||
<script>
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import {
|
||||
Clock,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Users,
|
||||
TrendingUp,
|
||||
PieChart,
|
||||
Plus,
|
||||
UserCircle
|
||||
} from 'lucide-svelte';
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto max-w-4xl">
|
||||
<h1 class="text-3xl font-bold mb-6">Dashboard</h1>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<!-- Welcome Card -->
|
||||
<div class="card bg-primary text-primary-content">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Welcome back!</h2>
|
||||
<p>Hello, {$auth.user?.name || 'User'}!</p>
|
||||
<p class="text-sm opacity-80">{$auth.user?.email || ''}</p>
|
||||
<div class="p-4">
|
||||
<!-- 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">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Stats Card -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Quick Stats</h2>
|
||||
<div class="stats stats-vertical">
|
||||
<div class="stat">
|
||||
<div class="stat-title">Status</div>
|
||||
<div class="stat-value text-success text-2xl">Active</div>
|
||||
<!-- 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-body p-4">
|
||||
<h2 class="card-title text-emerald-700 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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card bg-base-100 shadow border-t-4 border-teal-500 hover:shadow-lg transition-shadow">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="card-title text-teal-700 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Card -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">Getting Started</h2>
|
||||
<p class="text-sm">Your protected dashboard is now ready. Add more features and components as needed.</p>
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<a href="/" class="btn btn-sm btn-primary">Go Home</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Recent Activity -->
|
||||
<div class="card bg-base-100 shadow border-t-4 border-green-500 hover:shadow-lg transition-shadow">
|
||||
<div class="card-body p-4">
|
||||
<h2 class="card-title mb-2 text-green-700 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>
|
||||
</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>
|
||||
<hr class="bg-emerald-300"/>
|
||||
</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>
|
||||
</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>
|
||||
<hr class="bg-green-300"/>
|
||||
</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>
|
||||
</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>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
93
src/routes/(app)/master-data/+page.svelte
Normal file
93
src/routes/(app)/master-data/+page.svelte
Normal file
@ -0,0 +1,93 @@
|
||||
<script>
|
||||
import {
|
||||
List,
|
||||
MapPin,
|
||||
Users,
|
||||
Briefcase,
|
||||
Stethoscope,
|
||||
Hash,
|
||||
Globe,
|
||||
ChevronRight
|
||||
} from 'lucide-svelte';
|
||||
|
||||
const modules = [
|
||||
{
|
||||
title: 'ValueSets',
|
||||
description: 'System lookup values and dropdown options (GENDER, MARITAL_STATUS, etc.)',
|
||||
icon: List,
|
||||
href: '/master-data/valuesets',
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
title: 'Locations',
|
||||
description: 'Manage locations and facilities',
|
||||
icon: MapPin,
|
||||
href: '/master-data/locations',
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
title: 'Contacts',
|
||||
description: 'Manage physicians and contacts',
|
||||
icon: Users,
|
||||
href: '/master-data/contacts',
|
||||
color: 'bg-purple-500',
|
||||
},
|
||||
{
|
||||
title: 'Occupations',
|
||||
description: 'Manage occupation codes',
|
||||
icon: Briefcase,
|
||||
href: '/master-data/occupations',
|
||||
color: 'bg-orange-500',
|
||||
},
|
||||
{
|
||||
title: 'Medical Specialties',
|
||||
description: 'Manage medical specialty codes',
|
||||
icon: Stethoscope,
|
||||
href: '/master-data/specialties',
|
||||
color: 'bg-pink-500',
|
||||
},
|
||||
{
|
||||
title: 'Counters',
|
||||
description: 'Manage ID generation counters',
|
||||
icon: Hash,
|
||||
href: '/master-data/counters',
|
||||
color: 'bg-teal-500',
|
||||
},
|
||||
{
|
||||
title: 'Geography',
|
||||
description: 'View provinces, cities, and geographical areas',
|
||||
icon: Globe,
|
||||
href: '/master-data/geography',
|
||||
color: 'bg-indigo-500',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<h1 class="text-3xl font-bold text-gray-800 mb-2">Master Data</h1>
|
||||
<p class="text-gray-600 mb-8">Manage reference data and lookup values used throughout the system</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each modules as module}
|
||||
<a
|
||||
href={module.href}
|
||||
class="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow border border-base-200 group"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="{module.color} text-white p-3 rounded-lg">
|
||||
<svelte:component this={module.icon} class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="card-title text-lg group-hover:text-primary transition-colors">
|
||||
{module.title}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">{module.description}</p>
|
||||
</div>
|
||||
<ChevronRight class="w-5 h-5 text-gray-400 group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
252
src/routes/(app)/master-data/contacts/+page.svelte
Normal file
252
src/routes/(app)/master-data/contacts/+page.svelte
Normal file
@ -0,0 +1,252 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchContacts, createContact, updateContact, deleteContact } from '$lib/api/contacts.js';
|
||||
import { fetchSpecialties } from '$lib/api/specialties.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 SelectDropdown from '$lib/components/SelectDropdown.svelte';
|
||||
import { Plus, Edit2, Trash2, ArrowLeft } from 'lucide-svelte';
|
||||
|
||||
let loading = $state(false);
|
||||
let contacts = $state([]);
|
||||
let specialties = $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);
|
||||
|
||||
const specialtyOptions = $derived(
|
||||
specialties.map((s) => ({ value: s.SpecialtyID, label: s.SpecialtyText }))
|
||||
);
|
||||
|
||||
const specialtyMap = $derived(
|
||||
Object.fromEntries(specialties.map((s) => [s.SpecialtyID, s.SpecialtyText]))
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{ key: 'Initial', label: 'Initial', class: 'font-medium' },
|
||||
{ key: 'FullName', label: 'Name' },
|
||||
{ key: 'Title', label: 'Title' },
|
||||
{ key: 'SpecialtyLabel', label: 'Specialty' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([loadContacts(), loadSpecialties()]);
|
||||
});
|
||||
|
||||
async function loadSpecialties() {
|
||||
try {
|
||||
const response = await fetchSpecialties();
|
||||
specialties = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err) {
|
||||
console.error('Failed to load specialties:', err);
|
||||
specialties = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContacts() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetchContacts();
|
||||
contacts = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load contacts');
|
||||
contacts = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
modalMode = 'create';
|
||||
formData = { ContactID: null, NameFirst: '', NameLast: '', Title: '', Initial: '', Specialty: '' };
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEditModal(row) {
|
||||
modalMode = 'edit';
|
||||
formData = {
|
||||
ContactID: row.ContactID,
|
||||
NameFirst: row.NameFirst || '',
|
||||
NameLast: row.NameLast || '',
|
||||
Title: row.Title || '',
|
||||
Initial: row.Initial || '',
|
||||
Specialty: row.Specialty || '',
|
||||
};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving = true;
|
||||
try {
|
||||
if (modalMode === 'create') {
|
||||
await createContact(formData);
|
||||
toastSuccess('Contact created successfully');
|
||||
} else {
|
||||
await updateContact(formData);
|
||||
toastSuccess('Contact updated successfully');
|
||||
}
|
||||
modalOpen = false;
|
||||
await loadContacts();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to save contact');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(row) {
|
||||
deleteItem = row;
|
||||
deleteConfirmOpen = true;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteContact(deleteItem.ContactID);
|
||||
toastSuccess('Contact deleted successfully');
|
||||
deleteConfirmOpen = false;
|
||||
await loadContacts();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to delete contact');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<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" />
|
||||
</a>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-3xl font-bold text-gray-800">Contacts</h1>
|
||||
<p class="text-gray-600">Manage physicians and contacts</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick={openCreateModal}>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add Contact
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||
<DataTable
|
||||
{columns}
|
||||
data={contacts.map((c) => ({
|
||||
...c,
|
||||
FullName: `${c.NameFirst || ''} ${c.NameLast || ''}`.trim(),
|
||||
SpecialtyLabel: specialtyMap[c.Specialty] || '-',
|
||||
}))}
|
||||
{loading}
|
||||
emptyMessage="No contacts found"
|
||||
hover={true}
|
||||
bordered={false}
|
||||
>
|
||||
{#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)}>
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)}>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
{value}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Contact' : 'Edit Contact'} size="md">
|
||||
<form class="space-y-5" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
|
||||
<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-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="initial"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.Initial}
|
||||
placeholder="Enter initial"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="title">
|
||||
<span class="label-text font-medium">Title</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.Title}
|
||||
placeholder="Dr, Prof, etc."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="nameFirst">
|
||||
<span class="label-text font-medium">First Name</span>
|
||||
</label>
|
||||
<input
|
||||
id="nameFirst"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.NameFirst}
|
||||
placeholder="Enter first name"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="nameLast">
|
||||
<span class="label-text font-medium">Last Name</span>
|
||||
</label>
|
||||
<input
|
||||
id="nameLast"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.NameLast}
|
||||
placeholder="Enter last name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SelectDropdown
|
||||
label="Specialty"
|
||||
name="specialty"
|
||||
bind:value={formData.Specialty}
|
||||
options={specialtyOptions}
|
||||
placeholder="Select specialty..."
|
||||
/>
|
||||
</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>
|
||||
{/if}
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
{/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?.Initial}</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={() => (deleteConfirmOpen = false)} type="button">Cancel</button>
|
||||
<button class="btn btn-error" onclick={handleDelete} type="button">Delete</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
269
src/routes/(app)/master-data/counters/+page.svelte
Normal file
269
src/routes/(app)/master-data/counters/+page.svelte
Normal file
@ -0,0 +1,269 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchCounters, createCounter, updateCounter, deleteCounter } from '$lib/api/counters.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 { Plus, Edit2, Trash2, ArrowLeft } from 'lucide-svelte';
|
||||
|
||||
let loading = $state(false);
|
||||
let counters = $state([]);
|
||||
let modalOpen = $state(false);
|
||||
let modalMode = $state('create');
|
||||
let saving = $state(false);
|
||||
let formData = $state({
|
||||
CounterID: null,
|
||||
CounterDesc: '',
|
||||
CounterValue: 0,
|
||||
CounterStart: 0,
|
||||
CounterEnd: null,
|
||||
CounterReset: ''
|
||||
});
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let deleteItem = $state(null);
|
||||
|
||||
const resetOptions = [
|
||||
{ value: '', label: 'None (Manual)' },
|
||||
{ value: 'D', label: 'Daily' },
|
||||
{ value: 'M', label: 'Monthly' },
|
||||
{ value: 'Y', label: 'Yearly' },
|
||||
];
|
||||
|
||||
const resetLabels = {
|
||||
'D': 'Daily',
|
||||
'M': 'Monthly',
|
||||
'Y': 'Yearly',
|
||||
'': 'Manual',
|
||||
};
|
||||
|
||||
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: 'actions', label: 'Actions', class: 'w-32 text-center' },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
await loadCounters();
|
||||
});
|
||||
|
||||
async function loadCounters() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetchCounters();
|
||||
counters = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load counters');
|
||||
counters = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
modalMode = 'create';
|
||||
formData = {
|
||||
CounterID: null,
|
||||
CounterDesc: '',
|
||||
CounterValue: 0,
|
||||
CounterStart: 0,
|
||||
CounterEnd: null,
|
||||
CounterReset: ''
|
||||
};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEditModal(row) {
|
||||
modalMode = 'edit';
|
||||
formData = {
|
||||
CounterID: row.CounterID,
|
||||
CounterDesc: row.CounterDesc || '',
|
||||
CounterValue: row.CounterValue || 0,
|
||||
CounterStart: row.CounterStart || 0,
|
||||
CounterEnd: row.CounterEnd || null,
|
||||
CounterReset: row.CounterReset || '',
|
||||
};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving = true;
|
||||
try {
|
||||
if (modalMode === 'create') {
|
||||
await createCounter(formData);
|
||||
toastSuccess('Counter created successfully');
|
||||
} else {
|
||||
await updateCounter(formData);
|
||||
toastSuccess('Counter updated successfully');
|
||||
}
|
||||
modalOpen = false;
|
||||
await loadCounters();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to save counter');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(row) {
|
||||
deleteItem = row;
|
||||
deleteConfirmOpen = true;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteCounter(deleteItem.CounterID);
|
||||
toastSuccess('Counter deleted successfully');
|
||||
deleteConfirmOpen = false;
|
||||
await loadCounters();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to delete counter');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<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" />
|
||||
</a>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-3xl font-bold text-gray-800">Counters</h1>
|
||||
<p class="text-gray-600">Manage ID generation counters</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick={openCreateModal}>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add Counter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||
<DataTable
|
||||
{columns}
|
||||
data={counters.map((c) => ({
|
||||
...c,
|
||||
CounterResetLabel: resetLabels[c.CounterReset] || c.CounterReset || 'Manual',
|
||||
CounterEnd: c.CounterEnd || '-',
|
||||
}))}
|
||||
{loading}
|
||||
emptyMessage="No counters found"
|
||||
hover={true}
|
||||
bordered={false}
|
||||
>
|
||||
{#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)}>
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)}>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{value}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Counter' : 'Edit Counter'} size="md">
|
||||
<form class="space-y-5" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
|
||||
<div class="form-control">
|
||||
<label class="label" for="counterDesc">
|
||||
<span class="label-text font-medium">Description</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="counterDesc"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.CounterDesc}
|
||||
placeholder="Enter counter description"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<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>
|
||||
</label>
|
||||
<input
|
||||
id="counterValue"
|
||||
type="number"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.CounterValue}
|
||||
placeholder="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
</label>
|
||||
<input
|
||||
id="counterStart"
|
||||
type="number"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.CounterStart}
|
||||
placeholder="0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="counterEnd">
|
||||
<span class="label-text font-medium">End Value</span>
|
||||
</label>
|
||||
<input
|
||||
id="counterEnd"
|
||||
type="number"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.CounterEnd}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="counterReset">
|
||||
<span class="label-text font-medium">Reset Pattern</span>
|
||||
</label>
|
||||
<select
|
||||
id="counterReset"
|
||||
name="counterReset"
|
||||
class="select select-bordered w-full"
|
||||
bind:value={formData.CounterReset}
|
||||
>
|
||||
{#each resetOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</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>
|
||||
{/if}
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
{/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?.CounterDesc}</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={() => (deleteConfirmOpen = false)} type="button">Cancel</button>
|
||||
<button class="btn btn-error" onclick={handleDelete} type="button">Delete</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
182
src/routes/(app)/master-data/geography/+page.svelte
Normal file
182
src/routes/(app)/master-data/geography/+page.svelte
Normal file
@ -0,0 +1,182 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchGeographicalAreas, fetchProvinces, fetchCities } from '$lib/api/geography.js';
|
||||
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';
|
||||
|
||||
let loading = $state(false);
|
||||
let activeTab = $state('provinces');
|
||||
let areas = $state([]);
|
||||
let provinces = $state([]);
|
||||
let cities = $state([]);
|
||||
let selectedProvince = $state('');
|
||||
|
||||
const areaColumns = [
|
||||
{ key: 'AreaGeoID', label: 'ID', class: 'font-medium' },
|
||||
{ key: 'AreaCode', label: 'Code' },
|
||||
{ key: 'AreaName', label: 'Name' },
|
||||
{ key: 'Class', label: 'Class' },
|
||||
{ key: 'Parent', label: 'Parent' },
|
||||
];
|
||||
|
||||
const provinceColumns = [
|
||||
{ key: 'value', label: 'ID', class: 'font-medium' },
|
||||
{ key: 'label', label: 'Province Name' },
|
||||
];
|
||||
|
||||
const cityColumns = [
|
||||
{ key: 'value', label: 'ID', class: 'font-medium' },
|
||||
{ key: 'label', label: 'City Name' },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
await loadProvinces();
|
||||
});
|
||||
|
||||
async function loadAreas() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetchGeographicalAreas();
|
||||
areas = response.data || [];
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load geographical areas');
|
||||
areas = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProvinces() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetchProvinces();
|
||||
provinces = Array.isArray(response) ? response : (Array.isArray(response.data) ? response.data : []);
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load provinces');
|
||||
provinces = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCities() {
|
||||
loading = true;
|
||||
try {
|
||||
const provinceId = selectedProvince || null;
|
||||
const response = await fetchCities(provinceId);
|
||||
cities = Array.isArray(response) ? response : (Array.isArray(response.data) ? response.data : []);
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load cities');
|
||||
cities = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTabChange(tab) {
|
||||
activeTab = tab;
|
||||
if (tab === 'areas' && areas.length === 0) {
|
||||
loadAreas();
|
||||
} else if (tab === 'cities' && cities.length === 0) {
|
||||
loadCities();
|
||||
}
|
||||
}
|
||||
|
||||
const provinceOptions = $derived(
|
||||
provinces.map((p) => ({ value: p.value, label: p.label }))
|
||||
);
|
||||
|
||||
// Reload cities when province filter changes
|
||||
$effect(() => {
|
||||
if (activeTab === 'cities') {
|
||||
loadCities();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<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" />
|
||||
</a>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-3xl font-bold text-gray-800">Geography</h1>
|
||||
<p class="text-gray-600">View geographical areas, provinces, and cities</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs tabs-boxed mb-6">
|
||||
<button
|
||||
class="tab gap-2"
|
||||
class:tab-active={activeTab === 'provinces'}
|
||||
onclick={() => handleTabChange('provinces')}
|
||||
>
|
||||
<MapPin class="w-4 h-4" />
|
||||
Provinces
|
||||
</button>
|
||||
<button
|
||||
class="tab gap-2"
|
||||
class:tab-active={activeTab === 'cities'}
|
||||
onclick={() => handleTabChange('cities')}
|
||||
>
|
||||
<Building2 class="w-4 h-4" />
|
||||
Cities
|
||||
</button>
|
||||
<button
|
||||
class="tab gap-2"
|
||||
class:tab-active={activeTab === 'areas'}
|
||||
onclick={() => handleTabChange('areas')}
|
||||
>
|
||||
<Globe class="w-4 h-4" />
|
||||
All Areas
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'provinces'}
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||
<DataTable
|
||||
columns={provinceColumns}
|
||||
data={provinces}
|
||||
{loading}
|
||||
emptyMessage="No provinces found"
|
||||
hover={true}
|
||||
bordered={false}
|
||||
/>
|
||||
</div>
|
||||
{:else if activeTab === 'cities'}
|
||||
<div class="space-y-4">
|
||||
<div class="bg-base-200 p-4 rounded-lg">
|
||||
<SelectDropdown
|
||||
label="Filter by Province"
|
||||
name="province"
|
||||
bind:value={selectedProvince}
|
||||
options={provinceOptions}
|
||||
placeholder="All Provinces"
|
||||
/>
|
||||
</div>
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||
<DataTable
|
||||
columns={cityColumns}
|
||||
data={cities}
|
||||
{loading}
|
||||
emptyMessage="No cities found"
|
||||
hover={true}
|
||||
bordered={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{:else if activeTab === 'areas'}
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||
<DataTable
|
||||
columns={areaColumns}
|
||||
data={areas}
|
||||
{loading}
|
||||
emptyMessage="No geographical areas found"
|
||||
hover={true}
|
||||
bordered={false}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
238
src/routes/(app)/master-data/locations/+page.svelte
Normal file
238
src/routes/(app)/master-data/locations/+page.svelte
Normal file
@ -0,0 +1,238 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchLocations, createLocation, updateLocation, deleteLocation } from '$lib/api/locations.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 SelectDropdown from '$lib/components/SelectDropdown.svelte';
|
||||
import { Plus, Edit2, Trash2, ArrowLeft } from 'lucide-svelte';
|
||||
|
||||
let loading = $state(false);
|
||||
let locations = $state([]);
|
||||
let modalOpen = $state(false);
|
||||
let modalMode = $state('create');
|
||||
let saving = $state(false);
|
||||
let formData = $state({ LocationID: null, Code: '', Name: '', Type: '', ParentID: null });
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let deleteItem = $state(null);
|
||||
|
||||
const typeLabels = {
|
||||
ROOM: 'Room',
|
||||
BUILDING: 'Building',
|
||||
FLOOR: 'Floor',
|
||||
AREA: 'Area',
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ key: 'LocCode', label: 'Code', class: 'font-medium' },
|
||||
{ key: 'LocFull', label: 'Name' },
|
||||
{ key: 'LocTypeLabel', label: 'Type' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
await loadLocations();
|
||||
});
|
||||
|
||||
async function loadLocations() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetchLocations();
|
||||
locations = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load locations');
|
||||
locations = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
modalMode = 'create';
|
||||
formData = { LocationID: null, Code: '', Name: '', Type: '', ParentID: null };
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEditModal(row) {
|
||||
modalMode = 'edit';
|
||||
formData = {
|
||||
LocationID: row.LocationID,
|
||||
Code: row.LocCode,
|
||||
Name: row.LocFull,
|
||||
Type: row.LocType || '',
|
||||
ParentID: row.Parent,
|
||||
};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving = true;
|
||||
try {
|
||||
if (modalMode === 'create') {
|
||||
await createLocation(formData);
|
||||
toastSuccess('Location created successfully');
|
||||
} else {
|
||||
await updateLocation(formData);
|
||||
toastSuccess('Location updated successfully');
|
||||
}
|
||||
modalOpen = false;
|
||||
await loadLocations();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to save location');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(row) {
|
||||
deleteItem = row;
|
||||
deleteConfirmOpen = true;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteLocation(deleteItem.LocationID);
|
||||
toastSuccess('Location deleted successfully');
|
||||
deleteConfirmOpen = false;
|
||||
await loadLocations();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to delete location');
|
||||
}
|
||||
}
|
||||
|
||||
const parentOptions = $derived(
|
||||
locations
|
||||
.filter((l) => l.LocationID !== formData.LocationID)
|
||||
.map((l) => ({ value: l.LocationID.toString(), label: l.LocFull }))
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<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" />
|
||||
</a>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-3xl font-bold text-gray-800">Locations</h1>
|
||||
<p class="text-gray-600">Manage locations and facilities</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick={openCreateModal}>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add Location
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||
<DataTable
|
||||
{columns}
|
||||
data={locations.map((l) => ({
|
||||
...l,
|
||||
LocTypeLabel: typeLabels[l.LocType] || l.LocType || '-',
|
||||
}))}
|
||||
{loading}
|
||||
emptyMessage="No locations found"
|
||||
hover={true}
|
||||
bordered={false}
|
||||
>
|
||||
{#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)}>
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)}>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{value}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Location' : 'Edit Location'} size="md">
|
||||
<form class="space-y-5" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
|
||||
<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-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="code"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.Code}
|
||||
placeholder="Enter code"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="name">
|
||||
<span class="label-text font-medium">Name</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.Name}
|
||||
placeholder="Enter name"
|
||||
required
|
||||
/>
|
||||
</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-alt text-error">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="type"
|
||||
name="type"
|
||||
class="select select-bordered w-full"
|
||||
bind:value={formData.Type}
|
||||
required
|
||||
>
|
||||
<option value="">Select type...</option>
|
||||
<option value="ROOM">Room</option>
|
||||
<option value="BUILDING">Building</option>
|
||||
<option value="FLOOR">Floor</option>
|
||||
<option value="AREA">Area</option>
|
||||
</select>
|
||||
</div>
|
||||
<SelectDropdown
|
||||
label="Parent Location"
|
||||
name="parent"
|
||||
bind:value={formData.ParentID}
|
||||
options={parentOptions}
|
||||
placeholder="None"
|
||||
/>
|
||||
</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>
|
||||
{/if}
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
{/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?.LocFull}</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={() => (deleteConfirmOpen = false)} type="button">Cancel</button>
|
||||
<button class="btn btn-error" onclick={handleDelete} type="button">Delete</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
170
src/routes/(app)/master-data/occupations/+page.svelte
Normal file
170
src/routes/(app)/master-data/occupations/+page.svelte
Normal file
@ -0,0 +1,170 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchOccupations, createOccupation, updateOccupation } from '$lib/api/occupations.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 { Plus, Edit2, ArrowLeft } from 'lucide-svelte';
|
||||
|
||||
let loading = $state(false);
|
||||
let occupations = $state([]);
|
||||
let modalOpen = $state(false);
|
||||
let modalMode = $state('create');
|
||||
let saving = $state(false);
|
||||
let formData = $state({ OccupationID: null, OccCode: '', OccText: '', Description: '' });
|
||||
|
||||
const columns = [
|
||||
{ key: 'OccCode', label: 'Code', class: 'font-medium' },
|
||||
{ key: 'OccText', label: 'Occupation' },
|
||||
{ key: 'Description', label: 'Description' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
await loadOccupations();
|
||||
});
|
||||
|
||||
async function loadOccupations() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetchOccupations();
|
||||
occupations = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load occupations');
|
||||
occupations = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
modalMode = 'create';
|
||||
formData = { OccupationID: null, OccCode: '', OccText: '', Description: '' };
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEditModal(row) {
|
||||
modalMode = 'edit';
|
||||
formData = {
|
||||
OccupationID: row.OccupationID,
|
||||
OccCode: row.OccCode || '',
|
||||
OccText: row.OccText || '',
|
||||
Description: row.Description || '',
|
||||
};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving = true;
|
||||
try {
|
||||
if (modalMode === 'create') {
|
||||
await createOccupation(formData);
|
||||
toastSuccess('Occupation created successfully');
|
||||
} else {
|
||||
await updateOccupation(formData);
|
||||
toastSuccess('Occupation updated successfully');
|
||||
}
|
||||
modalOpen = false;
|
||||
await loadOccupations();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to save occupation');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<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" />
|
||||
</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>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick={openCreateModal}>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add Occupation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||
<DataTable
|
||||
{columns}
|
||||
data={occupations}
|
||||
{loading}
|
||||
emptyMessage="No occupations found"
|
||||
hover={true}
|
||||
bordered={false}
|
||||
>
|
||||
{#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)}>
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{value}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Occupation' : 'Edit Occupation'} size="md">
|
||||
<form class="space-y-5" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
|
||||
<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-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="occCode"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.OccCode}
|
||||
placeholder="Enter occupation code"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="occText">
|
||||
<span class="label-text font-medium">Occupation Name</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="occText"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.OccText}
|
||||
placeholder="Enter occupation name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="description">
|
||||
<span class="label-text font-medium">Description</span>
|
||||
</label>
|
||||
<input
|
||||
id="description"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.Description}
|
||||
placeholder="Enter description (optional)"
|
||||
/>
|
||||
</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>
|
||||
{/if}
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
211
src/routes/(app)/master-data/specialties/+page.svelte
Normal file
211
src/routes/(app)/master-data/specialties/+page.svelte
Normal file
@ -0,0 +1,211 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchSpecialties, createSpecialty, updateSpecialty, deleteSpecialty } from '$lib/api/specialties.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 SelectDropdown from '$lib/components/SelectDropdown.svelte';
|
||||
import { Plus, Edit2, Trash2, ArrowLeft } from 'lucide-svelte';
|
||||
|
||||
let loading = $state(false);
|
||||
let specialties = $state([]);
|
||||
let modalOpen = $state(false);
|
||||
let modalMode = $state('create');
|
||||
let saving = $state(false);
|
||||
let formData = $state({ SpecialtyID: null, SpecialtyText: '', Title: '', Parent: '' });
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let deleteItem = $state(null);
|
||||
|
||||
const columns = [
|
||||
{ key: 'SpecialtyText', label: 'Specialty Name', class: 'font-medium' },
|
||||
{ key: 'Title', label: 'Title' },
|
||||
{ key: 'ParentLabel', label: 'Parent' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
await loadSpecialties();
|
||||
});
|
||||
|
||||
async function loadSpecialties() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetchSpecialties();
|
||||
specialties = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load specialties');
|
||||
specialties = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
modalMode = 'create';
|
||||
formData = { SpecialtyID: null, SpecialtyText: '', Title: '', Parent: '' };
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEditModal(row) {
|
||||
modalMode = 'edit';
|
||||
formData = {
|
||||
SpecialtyID: row.SpecialtyID,
|
||||
SpecialtyText: row.SpecialtyText || '',
|
||||
Title: row.Title || '',
|
||||
Parent: row.Parent || '',
|
||||
};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
saving = true;
|
||||
try {
|
||||
if (modalMode === 'create') {
|
||||
await createSpecialty(formData);
|
||||
toastSuccess('Specialty created successfully');
|
||||
} else {
|
||||
await updateSpecialty(formData);
|
||||
toastSuccess('Specialty updated successfully');
|
||||
}
|
||||
modalOpen = false;
|
||||
await loadSpecialties();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to save specialty');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(row) {
|
||||
deleteItem = row;
|
||||
deleteConfirmOpen = true;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteSpecialty(deleteItem.SpecialtyID);
|
||||
toastSuccess('Specialty deleted successfully');
|
||||
deleteConfirmOpen = false;
|
||||
await loadSpecialties();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to delete specialty');
|
||||
}
|
||||
}
|
||||
|
||||
const parentOptions = $derived(
|
||||
specialties
|
||||
.filter((s) => s.SpecialtyID !== formData.SpecialtyID)
|
||||
.map((s) => ({ value: s.SpecialtyID, label: s.SpecialtyText }))
|
||||
);
|
||||
|
||||
const specialtyMap = $derived(
|
||||
Object.fromEntries(specialties.map((s) => [s.SpecialtyID, s.SpecialtyText]))
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<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" />
|
||||
</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>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick={openCreateModal}>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add Specialty
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||
<DataTable
|
||||
{columns}
|
||||
data={specialties.map((s) => ({
|
||||
...s,
|
||||
ParentLabel: s.Parent === '0' || !s.Parent ? '-' : specialtyMap[s.Parent] || '-',
|
||||
}))}
|
||||
{loading}
|
||||
emptyMessage="No specialties found"
|
||||
hover={true}
|
||||
bordered={false}
|
||||
>
|
||||
{#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)}>
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)}>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{value}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Specialty' : 'Edit Specialty'} size="md">
|
||||
<form class="space-y-5" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="specialtyText">
|
||||
<span class="label-text font-medium">Specialty Name</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="specialtyText"
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.SpecialtyText}
|
||||
placeholder="Enter specialty name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="title">
|
||||
<span class="label-text font-medium">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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SelectDropdown
|
||||
label="Parent Specialty"
|
||||
name="parent"
|
||||
bind:value={formData.Parent}
|
||||
options={parentOptions}
|
||||
placeholder="None"
|
||||
/>
|
||||
</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>
|
||||
{/if}
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
{/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>?
|
||||
</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>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
234
src/routes/(app)/master-data/valuesets/+page.svelte
Normal file
234
src/routes/(app)/master-data/valuesets/+page.svelte
Normal file
@ -0,0 +1,234 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
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';
|
||||
|
||||
let loading = $state(false);
|
||||
let refreshing = $state(false);
|
||||
let valueSets = $state([]);
|
||||
let searchQuery = $state('');
|
||||
let selectedValueSet = $state(null);
|
||||
let detailLoading = $state(false);
|
||||
|
||||
const columns = [
|
||||
{ key: 'ValueSetKey', label: 'Key', class: 'font-medium' },
|
||||
{ key: 'Name', label: 'Name' },
|
||||
{ key: 'ItemCount', label: 'Items', class: 'w-24 text-center' },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
await loadValueSets();
|
||||
});
|
||||
|
||||
async function loadValueSets() {
|
||||
loading = true;
|
||||
try {
|
||||
const params = searchQuery ? { key: searchQuery } : {};
|
||||
const response = await fetchValueSets(params);
|
||||
|
||||
let dataArray = [];
|
||||
|
||||
if (response.status === 'success' && response.data) {
|
||||
if (typeof response.data === 'object' && !Array.isArray(response.data)) {
|
||||
dataArray = Object.entries(response.data).map(([key, count]) => ({
|
||||
ValueSetKey: key,
|
||||
Name: key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()),
|
||||
ItemCount: count,
|
||||
}));
|
||||
} else if (Array.isArray(response.data)) {
|
||||
dataArray = response.data;
|
||||
}
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
dataArray = dataArray.filter((vs) =>
|
||||
vs.ValueSetKey?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
valueSets = dataArray;
|
||||
} catch (err) {
|
||||
console.error('Load error:', err);
|
||||
toastError(err.message || 'Failed to load ValueSets');
|
||||
valueSets = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
refreshing = true;
|
||||
try {
|
||||
await refreshValueSets();
|
||||
toastSuccess('ValueSets cache refreshed successfully');
|
||||
await loadValueSets();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to refresh ValueSets');
|
||||
} finally {
|
||||
refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRowClick(row) {
|
||||
detailLoading = true;
|
||||
|
||||
try {
|
||||
const response = await fetchValueSetByKey(row.ValueSetKey);
|
||||
if (response.status === 'success' && response.data) {
|
||||
// Transform API response (array of items) to include metadata
|
||||
selectedValueSet = {
|
||||
ValueSetKey: row.ValueSetKey,
|
||||
Name: row.Name,
|
||||
Items: Array.isArray(response.data) ? response.data : []
|
||||
};
|
||||
} else {
|
||||
throw new Error('Failed to load ValueSet details');
|
||||
}
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load ValueSet details');
|
||||
selectedValueSet = null;
|
||||
} finally {
|
||||
detailLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
loadValueSets();
|
||||
}
|
||||
|
||||
function handleSearchKeydown(event) {
|
||||
if (event.key === 'Enter') {
|
||||
loadValueSets();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6 h-[calc(100vh-4rem)] flex flex-col">
|
||||
<!-- 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" />
|
||||
</a>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-800">ValueSets</h1>
|
||||
<p class="text-gray-600">System lookup values and dropdown options</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Two Column Layout -->
|
||||
<div class="flex-1 grid grid-cols-1 lg:grid-cols-2 gap-6 min-h-0">
|
||||
<!-- Left Column: ValueSet List -->
|
||||
<div class="flex flex-col min-h-0">
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-4">
|
||||
<div class="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by key..."
|
||||
class="input input-bordered w-full pl-10"
|
||||
bind:value={searchQuery}
|
||||
onkeydown={handleSearchKeydown}
|
||||
/>
|
||||
<Search class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={handleSearch}
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<Search class="w-4 h-4" />
|
||||
{/if}
|
||||
Search
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="btn btn-outline"
|
||||
onclick={handleRefresh}
|
||||
disabled={refreshing}
|
||||
>
|
||||
{#if refreshing}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<RefreshCw class="w-4 h-4" />
|
||||
{/if}
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="flex-1 bg-base-100 rounded-lg shadow border border-base-200 overflow-auto">
|
||||
<DataTable
|
||||
{columns}
|
||||
data={valueSets}
|
||||
{loading}
|
||||
emptyMessage="No ValueSets found"
|
||||
hover={true}
|
||||
bordered={false}
|
||||
onRowClick={handleRowClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: ValueSet Detail -->
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-6 overflow-auto">
|
||||
{#if detailLoading}
|
||||
<div class="flex flex-col items-center justify-center h-full min-h-[300px]">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="text-gray-500 mt-4">Loading ValueSet details...</p>
|
||||
</div>
|
||||
{:else if selectedValueSet}
|
||||
<div class="space-y-6">
|
||||
<!-- 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>
|
||||
|
||||
<!-- Items Table -->
|
||||
<div>
|
||||
<h3 class="font-semibold mb-3 flex items-center gap-2">
|
||||
<span>Items</span>
|
||||
<span class="badge badge-primary badge-sm">{selectedValueSet.Items?.length || 0}</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>
|
||||
{:else}
|
||||
<p class="text-gray-500 text-center py-8">No items in this ValueSet</p>
|
||||
{/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>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1,6 +1,7 @@
|
||||
<script>
|
||||
import '../app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import ToastContainer from '$lib/components/ToastContainer.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
@ -11,4 +12,5 @@
|
||||
|
||||
<div class="min-h-screen bg-base-100">
|
||||
{@render children()}
|
||||
<ToastContainer />
|
||||
</div>
|
||||
|
||||
@ -88,26 +88,26 @@
|
||||
<div class="hero min-h-screen bg-white">
|
||||
<div class="hero-content flex-col w-full max-w-lg">
|
||||
<!-- Logo Section -->
|
||||
<div class="text-center mb-6">
|
||||
<div class="w-20 h-20 mx-auto rounded-2xl bg-emerald-100 flex items-center justify-center mb-4 shadow-lg border-2 border-emerald-200">
|
||||
<svg class="w-12 h-12 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="text-4xl font-bold text-emerald-600 mb-2">CLQMS</h1>
|
||||
<p class="text-gray-600">Clinical Laboratory Quality Management System</p>
|
||||
<h1 class="text-3xl font-bold text-emerald-600 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-xl shadow-2xl border border-gray-200 border-t-4 border-t-emerald-500">
|
||||
<div class="card-body p-8">
|
||||
<h2 class="text-2xl font-bold text-center text-emerald-700 mb-6">Welcome Back</h2>
|
||||
<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-body p-6">
|
||||
<h2 class="text-xl font-bold text-center text-emerald-700 mb-4">Welcome Back</h2>
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit}>
|
||||
<!-- Username Field -->
|
||||
<div class="mb-4">
|
||||
<label class="input w-full input-bordered bg-white border-gray-300 flex items-center gap-2 {usernameError ? 'border-red-500' : ''}">
|
||||
<span class="label"><User class="w-5 h-5 text-emerald-500" /></span>
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
@ -125,9 +125,9 @@
|
||||
</div>
|
||||
|
||||
<!-- Password Field -->
|
||||
<div class="mb-4">
|
||||
<label class="input w-full input-bordered bg-white border-gray-300 flex items-center gap-2 {passwordError ? 'border-red-500' : ''}">
|
||||
<span class="label"><Lock class="w-5 h-5 text-emerald-500" /></span>
|
||||
<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>
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
@ -138,11 +138,11 @@
|
||||
disabled={loading}
|
||||
class="grow text-gray-900 placeholder-gray-400"
|
||||
/>
|
||||
<button type="button" class="btn btn-ghost btn-sm btn-circle" on:click={togglePassword} disabled={loading}>
|
||||
<button type="button" class="btn btn-ghost btn-xs btn-circle" on:click={togglePassword} disabled={loading}>
|
||||
{#if showPassword}
|
||||
<EyeOff class="w-5 h-5 text-emerald-500" />
|
||||
<EyeOff class="w-4 h-4 text-emerald-500" />
|
||||
{:else}
|
||||
<Eye class="w-5 h-5 text-emerald-500" />
|
||||
<Eye class="w-4 h-4 text-emerald-500" />
|
||||
{/if}
|
||||
</button>
|
||||
</label>
|
||||
@ -152,7 +152,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Remember Me & Forgot Password -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<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} />
|
||||
<span class="label-text text-sm">Remember me</span>
|
||||
@ -169,12 +169,12 @@
|
||||
|
||||
<!-- Login Button -->
|
||||
<div class="form-control">
|
||||
<button type="submit" class="btn bg-emerald-600 hover:bg-emerald-700 text-white shadow-lg hover:shadow-xl transition-all w-full" disabled={loading}>
|
||||
<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}>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-sm mr-2"></span>
|
||||
<span class="loading loading-spinner loading-xs mr-2"></span>
|
||||
Signing in...
|
||||
{:else}
|
||||
<LogIn class="w-5 h-5 mr-2" />
|
||||
<LogIn class="w-4 h-4 mr-2" />
|
||||
Sign In
|
||||
{/if}
|
||||
</button>
|
||||
@ -192,8 +192,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="mt-8 text-center">
|
||||
<p class="text-sm text-gray-400">© 2026 CLQMS. All rights reserved.</p>
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-xs text-gray-400">© 2026 CLQMS. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user