feat(equipment,organization): add equipment API client and complete organization module structure

- Add equipment.js API client with full CRUD operations
- Add organization sub-routes: account, department, discipline, instrument, site, workstation
- Create EquipmentModal and DeleteConfirmModal components
- Update master-data navigation and sidebar
- Update tests, containers, counters, geography, locations, occupations, specialties, testmap, and valuesets pages
- Add COMPONENT_ORGANIZATION.md documentation
This commit is contained in:
mahdahar 2026-02-24 16:53:04 +07:00
parent 8f75a1339c
commit ae806911be
26 changed files with 3154 additions and 914 deletions

141
AGENTS.md
View File

@ -5,13 +5,22 @@ SvelteKit frontend for Clinical Laboratory Quality Management System. Uses Svelt
## Commands
```bash
pnpm run dev # Development server
pnpm run build # Production build
# Development
pnpm run dev # Start development server
pnpm run build # Production build (outputs to build/)
pnpm run preview # Preview production build
pnpm run prepare # Sync SvelteKit
pnpm run prepare # Sync SvelteKit (runs on install)
# Package management
pnpm install # Install dependencies (use pnpm, not npm/yarn)
# Testing (not configured yet - add when needed)
# vitest run src/path/to/test.js # Run single test
# vitest # Run tests in watch mode
# npx playwright test # E2E tests
```
**No test framework yet.** When adding: use Vitest (`vitest run src/path/to/test.js`), Playwright for E2E.
**No ESLint/Prettier configured yet.** When adding: configure in `vite.config.js` or separate config files.
## Code Style
@ -20,26 +29,26 @@ pnpm run prepare # Sync SvelteKit
- **Quotes**: Single quotes
- **Indentation**: 2 spaces
- **Trailing commas**: In multi-line objects/arrays
- **JSDoc**: Document all exported functions
- **JSDoc**: Document all exported functions with `@param` and `@returns`
## Naming Conventions
- **Components**: PascalCase (`LoginForm.svelte`)
- **Files/Routes**: lowercase with hyphens (`+page.svelte`)
- **Variables**: camelCase (`isLoading`, `userName`)
- **Constants**: UPPER_SNAKE_CASE (`API_URL`)
- **Stores**: camelCase (`auth`, `config`)
- **Handlers**: prefix with `handle` (`handleSubmit`)
- **Form state**: `formLoading`, `formError`
## Imports Order
### Imports Order
1. Svelte (`svelte`, `$app/*`)
2. `$lib/*` (stores, api, components, utils)
3. External libraries (`lucide-svelte`)
4. Relative imports (minimize, prefer `$lib`)
## Svelte 5 Components
## Naming Conventions
- **Components**: PascalCase (`LoginForm.svelte`, `PatientFormModal.svelte`)
- **Files/Routes**: lowercase with hyphens (`+page.svelte`, `user-profile/`)
- **Variables**: camelCase (`isLoading`, `userName`)
- **Constants**: UPPER_SNAKE_CASE (`API_URL`, `STORAGE_KEY`)
- **Stores**: camelCase, descriptive (`auth`, `config`, `userStore`)
- **Event handlers**: prefix with `handle` (`handleSubmit`, `handleClick`)
- **Form state**: `formLoading`, `formError`, `deleteConfirmOpen`
## Svelte 5 Component Structure
```svelte
<script>
@ -50,7 +59,7 @@ pnpm run prepare # Sync SvelteKit
import { User } from 'lucide-svelte';
// 2. Props with $bindable
let { open = $bindable(false), title = '', children } = $props();
let { open = $bindable(false), title = '', children, footer } = $props();
// 3. State
let loading = $state(false);
@ -74,6 +83,7 @@ pnpm run prepare # Sync SvelteKit
import { get, post, put, patch, del } from '$lib/api/client.js';
// Feature endpoints (with JSDoc)
/** @param {Object} params - Query parameters */
export async function fetchItems(params = {}) {
const query = new URLSearchParams(params).toString();
return get(query ? `/api/items?${query}` : '/api/items');
@ -104,13 +114,13 @@ function createStore() {
return JSON.parse(localStorage.getItem('key'));
};
const { subscribe, set } = writable(getInitialState());
const { subscribe, set, update } = writable(getInitialState());
return {
subscribe,
setData: (data) => {
if (browser) localStorage.setItem('key', JSON.stringify(data));
set(data);
set({ data });
}
};
}
@ -118,42 +128,23 @@ function createStore() {
## Component Patterns
### Modal with Snippets
```svelte
<!-- Modal with $bindable props -->
<Modal bind:open={showModal} title="Edit" size="lg">
<Modal bind:open={showModal} title="Edit Item" size="lg">
{#snippet children()}
<form>...</form>
<form onsubmit={handleSubmit}>
<input class="input input-bordered" bind:value={formData.name} />
</form>
{/snippet}
{#snippet footer()}
<button class="btn btn-primary">Save</button>
<button class="btn btn-ghost" onclick={() => showModal = false}>Cancel</button>
<button class="btn btn-primary" onclick={handleSubmit}>Save</button>
{/snippet}
</Modal>
<!-- Dialog with backdrop -->
<dialog class="modal modal-open">
<div class="modal-box">
{@render children?.()}
</div>
<form method="dialog" class="modal-backdrop" onclick={handleBackdropClick}>
<button>close</button>
</form>
</dialog>
```
## Error Handling
```javascript
try {
const result = await api.fetchData();
success('Operation successful');
} catch (err) {
const message = err.message || 'An unexpected error occurred';
error(message);
console.error('Operation failed:', err);
}
```
## Form Patterns
### Form with Validation
```javascript
let formLoading = $state(false);
@ -174,7 +165,7 @@ async function handleSubmit() {
formLoading = true;
try {
await api.submit(formData);
success('Success');
toastSuccess('Success');
} catch (err) {
formError = err.message;
} finally {
@ -183,12 +174,43 @@ async function handleSubmit() {
}
```
## Error Handling
```javascript
try {
const result = await api.fetchData();
toastSuccess('Operation successful');
} catch (err) {
const message = err.message || 'An unexpected error occurred';
toastError(message);
console.error('Operation failed:', err);
}
```
## Styling (DaisyUI + Tailwind)
- **Components**: `btn`, `card`, `alert`, `input`, `navbar`, `modal`, `dropdown`
- **Components**: `btn`, `card`, `alert`, `input`, `navbar`, `modal`, `dropdown`, `menu`
- **Colors**: `primary` (emerald), `secondary` (dark blue), `accent` (royal blue)
- **Sizes**: `btn-sm`, `input-sm`, `select-sm` for compact forms
- **Custom**: `.compact-y`, `.compact-p`, `.compact-input`
- **Custom**: `.compact-y`, `.compact-p`, `.compact-input`, `.compact-btn`, `.compact-card`
## Project Structure
```
src/
lib/
api/ # API clients per feature
stores/ # Svelte stores (auth, config, valuesets)
components/ # Reusable components (Modal, DataTable, Sidebar)
utils/ # Utilities (toast, helpers)
types/ # TypeScript type definitions
routes/ # SvelteKit routes
(app)/ # Route groups (protected)
dashboard/
patients/
master-data/
login/ # Public routes
```
## Authentication
@ -199,22 +221,7 @@ async function handleSubmit() {
## LocalStorage
- Only access in browser: check `browser` from `$app/environment`
- Use descriptive keys: `clqms_username`, `auth_token`
## Project Structure
```
src/
lib/
api/ # API clients per feature
stores/ # Svelte stores
components/ # Reusable components
utils/ # Utilities (toast, helpers)
routes/ # SvelteKit routes
(app)/ # Route groups (protected)
login/
dashboard/
```
- Use descriptive keys: `clqms_username`, `clqms_remember`, `auth_token`
## Important Notes

155
COMPONENT_ORGANIZATION.md Normal file
View File

@ -0,0 +1,155 @@
# Component Organization Guide
Guide for splitting large components and modals into manageable files.
## When to Split Components
Split a component when:
- File exceeds 200 lines
- Component has multiple distinct sections (tabs, steps, panels)
- Logic becomes hard to follow
- Multiple developers work on different parts
## Modal Organization Pattern
### Structure for Large Modals
```
src/routes/(app)/feature/
├── +page.svelte # Main page
├── FeatureModal.svelte # Main modal container
└── feature-modal/ # Modal sub-components (kebab-case folder)
├── modals/ # Nested modals
│ └── PickerModal.svelte
└── tabs/ # Tab content components
├── BasicInfoTab.svelte
├── SettingsTab.svelte
└── AdvancedTab.svelte
```
### Example: Test Form Modal
**Location**: `src/routes/(app)/master-data/tests/test-modal/`
```svelte
<!-- TestFormModal.svelte -->
<script>
import Modal from '$lib/components/Modal.svelte';
import BasicInfoTab from './test-modal/tabs/BasicInfoTab.svelte';
import TechDetailsTab from './test-modal/tabs/TechDetailsTab.svelte';
import CalcDetailsTab from './test-modal/tabs/CalcDetailsTab.svelte';
let { open = $bindable(false), test = null } = $props();
let activeTab = $state('basic');
let formData = $state({});
</script>
<Modal bind:open title={test ? 'Edit Test' : 'New Test'} size="xl">
{#snippet children()}
<div class="tabs tabs-boxed mb-4">
<button class="tab" class:tab-active={activeTab === 'basic'} onclick={() => activeTab = 'basic'}>Basic</button>
<button class="tab" class:tab-active={activeTab === 'technical'} onclick={() => activeTab = 'technical'}>Technical</button>
<button class="tab" class:tab-active={activeTab === 'calculation'} onclick={() => activeTab = 'calculation'}>Calculation</button>
</div>
{#if activeTab === 'basic'}
<BasicInfoTab bind:formData />
{:else if activeTab === 'technical'}
<TechDetailsTab bind:formData />
{:else if activeTab === 'calculation'}
<CalcDetailsTab bind:formData />
{/if}
{/snippet}
</Modal>
```
```svelte
<!-- test-modal/tabs/BasicInfoTab.svelte -->
<script>
let { formData = $bindable({}) } = $props();
</script>
<div class="space-y-4">
<div class="form-control">
<label class="label">Test Name</label>
<input class="input input-bordered" bind:value={formData.name} />
</div>
<div class="form-control">
<label class="label">Description</label>
<textarea class="textarea textarea-bordered" bind:value={formData.description}></textarea>
</div>
</div>
```
## Data Flow
### Parent to Child
- Pass data via props (`bind:formData`)
- Use `$bindable()` for two-way binding
- Keep state in parent when shared across tabs
### Child to Parent
- Use callbacks for actions (`onSave`, `onClose`)
- Modify bound data directly (with `$bindable`)
- Emit events for complex interactions
## Props Interface Pattern
```javascript
// Define props with JSDoc
/** @type {{ formData: Object, onValidate: Function, readonly: boolean }} */
let {
formData = $bindable({}),
onValidate = () => true,
readonly = false
} = $props();
```
## Naming Conventions
- **Main modal**: `{Feature}Modal.svelte` (e.g., `TestFormModal.svelte`)
- **Tab components**: `{TabName}Tab.svelte` (e.g., `BasicInfoTab.svelte`)
- **Nested modals**: `{Action}Modal.svelte` (e.g., `ConfirmDeleteModal.svelte`)
- **Folder names**: kebab-case matching the modal name (e.g., `test-modal/`)
## Shared State Management
For complex modals with shared state across tabs:
```javascript
// In main modal
let sharedState = $state({
dirty: false,
errors: {},
selectedItems: []
});
// Pass to tabs
<TabName formData={formData} sharedState={sharedState} />
```
## Import Order in Sub-components
Same as main components:
1. Svelte imports
2. `$lib/*` imports
3. External libraries
4. Relative imports (other tabs/modals)
## Testing Split Components
```bash
# Test individual tab component
vitest run src/routes/feature/modal-tabs/BasicInfoTab.test.js
# Test main modal integration
vitest run src/routes/feature/FeatureModal.test.js
```
## Benefits
- **Maintainability**: Each file has single responsibility
- **Collaboration**: Multiple developers can work on different tabs
- **Testing**: Test individual sections in isolation
- **Performance**: Only render visible tab content
- **Reusability**: Tabs can be used in different modals

View File

@ -47,6 +47,8 @@ tags:
description: Value set definitions and items
- name: Demo
description: Demo/test endpoints (no authentication)
- name: EquipmentList
description: Laboratory equipment and instrument management
paths:
/api/auth/login:
post:
@ -594,6 +596,225 @@ paths:
responses:
'200':
description: Status logged
/api/equipmentlist:
get:
tags:
- EquipmentList
summary: List equipment
description: Get list of equipment with optional filters
security:
- bearerAuth: []
parameters:
- name: IEID
in: query
schema:
type: string
description: Filter by IEID
- name: InstrumentName
in: query
schema:
type: string
description: Filter by instrument name
- name: DepartmentID
in: query
schema:
type: integer
description: Filter by department ID
- name: WorkstationID
in: query
schema:
type: integer
description: Filter by workstation ID
- name: Enable
in: query
schema:
type: integer
enum:
- 0
- 1
description: Filter by enable status
responses:
'200':
description: List of equipment
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: array
items:
$ref: '#/components/schemas/EquipmentList'
post:
tags:
- EquipmentList
summary: Create equipment
description: Create a new equipment entry
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- IEID
- DepartmentID
- Enable
- EquipmentRole
properties:
IEID:
type: string
maxLength: 50
DepartmentID:
type: integer
InstrumentID:
type: string
maxLength: 150
InstrumentName:
type: string
maxLength: 150
WorkstationID:
type: integer
Enable:
type: integer
enum:
- 0
- 1
EquipmentRole:
type: string
maxLength: 1
responses:
'201':
description: Equipment created
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: integer
patch:
tags:
- EquipmentList
summary: Update equipment
description: Update an existing equipment entry
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- EID
properties:
EID:
type: integer
IEID:
type: string
maxLength: 50
DepartmentID:
type: integer
InstrumentID:
type: string
maxLength: 150
InstrumentName:
type: string
maxLength: 150
WorkstationID:
type: integer
Enable:
type: integer
enum:
- 0
- 1
EquipmentRole:
type: string
maxLength: 1
responses:
'200':
description: Equipment updated
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: integer
delete:
tags:
- EquipmentList
summary: Delete equipment
description: Soft delete an equipment entry
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- EID
properties:
EID:
type: integer
responses:
'200':
description: Equipment deleted
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
/api/equipmentlist/{id}:
get:
tags:
- EquipmentList
summary: Get equipment by ID
description: Get a single equipment entry by its EID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Equipment ID
responses:
'200':
description: Equipment details
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '#/components/schemas/EquipmentList'
/api/location:
get:
tags:
@ -2556,6 +2777,326 @@ paths:
responses:
'200':
description: Collection method details
/api/test/testmap:
get:
tags:
- Tests
summary: List all test mappings
security:
- bearerAuth: []
parameters:
- name: page
in: query
schema:
type: integer
default: 1
description: Page number for pagination
- name: perPage
in: query
schema:
type: integer
default: 20
description: Number of items per page
responses:
'200':
description: List of test mappings
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
data:
type: array
items:
$ref: '#/components/schemas/TestMap'
post:
tags:
- Tests
summary: Create test mapping
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
TestSiteID:
type: integer
description: Test Site ID (required)
HostType:
type: string
description: Host type code
HostID:
type: string
description: Host identifier
HostTestCode:
type: string
description: Test code in host system
HostTestName:
type: string
description: Test name in host system
ClientType:
type: string
description: Client type code
ClientID:
type: string
description: Client identifier
ConDefID:
type: integer
description: Connection definition ID
ClientTestCode:
type: string
description: Test code in client system
ClientTestName:
type: string
description: Test name in client system
required:
- TestSiteID
responses:
'201':
description: Test mapping created
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
data:
type: integer
description: Created TestMapID
patch:
tags:
- Tests
summary: Update test mapping
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
TestMapID:
type: integer
description: Test Map ID (required)
TestSiteID:
type: integer
HostType:
type: string
HostID:
type: string
HostTestCode:
type: string
HostTestName:
type: string
ClientType:
type: string
ClientID:
type: string
ConDefID:
type: integer
ClientTestCode:
type: string
ClientTestName:
type: string
required:
- TestMapID
responses:
'200':
description: Test mapping updated
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
data:
type: integer
description: Updated TestMapID
delete:
tags:
- Tests
summary: Soft delete test mapping
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
TestMapID:
type: integer
description: Test Map ID to delete (required)
required:
- TestMapID
responses:
'200':
description: Test mapping deleted successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
data:
type: integer
description: Deleted TestMapID
'404':
description: Test mapping not found or already deleted
/api/test/testmap/{id}:
get:
tags:
- Tests
summary: Get test mapping by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Test Map ID
responses:
'200':
description: Test mapping details
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '#/components/schemas/TestMap'
'404':
description: Test mapping not found
/api/test/testmap/by-testsite/{testSiteID}:
get:
tags:
- Tests
summary: Get test mappings by test site
security:
- bearerAuth: []
parameters:
- name: testSiteID
in: path
required: true
schema:
type: integer
description: Test Site ID
responses:
'200':
description: List of test mappings for the test site
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: array
items:
$ref: '#/components/schemas/TestMap'
/api/test/testmap/by-host/{hostType}/{hostID}:
get:
tags:
- Tests
summary: Get test mappings by host
security:
- bearerAuth: []
parameters:
- name: hostType
in: path
required: true
schema:
type: string
description: Host type code
- name: hostID
in: path
required: true
schema:
type: string
description: Host identifier
responses:
'200':
description: List of test mappings for the host
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: array
items:
$ref: '#/components/schemas/TestMap'
/api/test/testmap/by-client/{clientType}/{clientID}:
get:
tags:
- Tests
summary: Get test mappings by client
security:
- bearerAuth: []
parameters:
- name: clientType
in: path
required: true
schema:
type: string
description: Client type code
- name: clientID
in: path
required: true
schema:
type: string
description: Client identifier
responses:
'200':
description: List of test mappings for the client
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: array
items:
$ref: '#/components/schemas/TestMap'
/api/tests:
get:
tags:
@ -2674,14 +3215,18 @@ paths:
type: string
enum:
- NMRIC
- RANGE
- TEXT
- VSET
- NORES
RefType:
type: string
enum:
- NMRC
- TEXT
- RANGE
- THOLD
- VSET
- TEXT
- NOREF
VSet:
type: integer
ReqQty:
@ -2794,14 +3339,18 @@ paths:
type: string
enum:
- NMRIC
- RANGE
- TEXT
- VSET
- NORES
RefType:
type: string
enum:
- NMRC
- TEXT
- RANGE
- THOLD
- VSET
- TEXT
- NOREF
VSet:
type: integer
ReqQty:
@ -2955,7 +3504,7 @@ paths:
tags:
- ValueSets
summary: List lib value sets
description: List all library/system value sets from JSON files with item counts. Returns an object where keys are value set names and values are item counts.
description: List all library/system value sets from JSON files with item counts. Returns an array of objects with value, label, and count properties.
security:
- bearerAuth: []
parameters:
@ -2963,7 +3512,7 @@ paths:
in: query
schema:
type: string
description: Optional search term to filter value set names
description: Optional search term to filter value set names or labels
responses:
'200':
description: List of lib value sets with item counts
@ -2976,14 +3525,19 @@ paths:
type: string
example: success
data:
type: object
additionalProperties:
type: integer
description: Number of items in each value set
type: array
items:
$ref: '#/components/schemas/ValueSetListItem'
example:
sex: 3
marital_status: 6
order_status: 6
- value: sex
label: Sex
count: 3
- value: marital_status
label: Marital Status
count: 6
- value: order_status
label: Order Status
count: 6
/api/valueset/{key}:
get:
tags:
@ -4139,22 +4693,46 @@ components:
type: string
enum:
- NMRIC
- RANGE
- TEXT
- VSET
- NORES
description: |
NMRIC: Numeric result
VSET: Value set result
Result type determines the format of test results:
- NMRIC: Single numeric value
- RANGE: Numeric range (min-max)
- TEXT: Free text result
- VSET: Value set/enum result
- NORES: No result (for GROUP and TITLE types)
TestType to ResultType mapping:
- TEST: NMRIC | RANGE | TEXT | VSET
- PARAM: NMRIC | RANGE | TEXT | VSET
- CALC: NMRIC (calculated result is always numeric)
- GROUP: NORES (no result, container only)
- TITLE: NORES (no result, header only)
RefType:
type: string
enum:
- NMRC
- TEXT
- RANGE
- THOLD
- VSET
- TEXT
- NOREF
description: |
NMRC: Numeric reference range
TEXT: Text reference
THOLD: Threshold reference
VSET: Value set reference
Reference type determines which reference range table to use:
- RANGE: Numeric reference range
- THOLD: Threshold/panic range
- VSET: Value set reference
- TEXT: Free text reference
- NOREF: No reference (for NORES result type)
ResultType to RefType mapping:
- NMRIC: RANGE | THOLD → refnum table
- RANGE: RANGE | THOLD → refnum table
- VSET: VSET → reftxt table
- TEXT: TEXT → reftxt table
- NORES: NOREF → (no reference table)
VSet:
type: integer
description: Value set ID for VSET result type
@ -4530,9 +5108,6 @@ components:
HostID:
type: string
description: Host identifier
HostDataSource:
type: string
description: Host data source
HostTestCode:
type: string
description: Test code in host system
@ -4545,9 +5120,6 @@ components:
ClientID:
type: string
description: Client identifier
ClientDataSource:
type: string
description: Client data source
ConDefID:
type: integer
description: Connection definition ID
@ -4812,6 +5384,55 @@ components:
type: string
format: date-time
nullable: true
EquipmentList:
type: object
properties:
EID:
type: integer
description: Equipment ID (auto-increment)
IEID:
type: string
maxLength: 50
description: Internal Equipment ID
DepartmentID:
type: integer
description: Reference to department
InstrumentID:
type: string
maxLength: 150
description: Instrument identifier
InstrumentName:
type: string
maxLength: 150
description: Instrument display name
WorkstationID:
type: integer
description: Reference to workstation
Enable:
type: integer
enum:
- 0
- 1
description: Equipment status (0=disabled, 1=enabled)
EquipmentRole:
type: string
maxLength: 1
description: Equipment role code
CreateDate:
type: string
format: date-time
description: Creation timestamp
EndDate:
type: string
format: date-time
nullable: true
description: Deletion timestamp (soft delete)
DepartmentName:
type: string
description: Joined department name
WorkstationName:
type: string
description: Joined workstation name
Contact:
type: object
properties:
@ -4864,3 +5485,16 @@ components:
type: string
format: date-time
description: Occupation display text
ValueSetListItem:
type: object
description: Library/system value set summary (from JSON files)
properties:
value:
type: string
description: The value set key/name
label:
type: string
description: The display name/label
count:
type: integer
description: Number of items in this value set

86
src/lib/api/equipment.js Normal file
View File

@ -0,0 +1,86 @@
import { get, post, patch, del } from './client.js';
/**
* Fetch list of equipment with optional filters
* @param {Object} params - Filter parameters
* @param {string} [params.IEID] - Filter by IEID
* @param {string} [params.InstrumentName] - Filter by instrument name
* @param {number} [params.DepartmentID] - Filter by department ID
* @param {number} [params.WorkstationID] - Filter by workstation ID
* @param {number} [params.Enable] - Filter by enable status (0 or 1)
* @returns {Promise<Object>} List of equipment
*/
export async function fetchEquipmentList(params = {}) {
const query = new URLSearchParams(params).toString();
return get(query ? `/api/equipmentlist?${query}` : '/api/equipmentlist');
}
/**
* Fetch single equipment by ID
* @param {number} id - Equipment ID (EID)
* @returns {Promise<Object>} Equipment details
*/
export async function fetchEquipment(id) {
return get(`/api/equipmentlist/${id}`);
}
/**
* Create new equipment
* @param {Object} data - Equipment data
* @param {string} data.IEID - Internal Equipment ID (required)
* @param {number} data.DepartmentID - Department ID (required)
* @param {number} data.Enable - Enable status 0 or 1 (required)
* @param {string} data.EquipmentRole - Equipment role code (required)
* @param {string} [data.InstrumentID] - Instrument identifier
* @param {string} [data.InstrumentName] - Instrument display name
* @param {number} [data.WorkstationID] - Workstation ID
* @returns {Promise<Object>} Created equipment ID
*/
export async function createEquipment(data) {
const payload = {
IEID: data.IEID,
DepartmentID: data.DepartmentID,
Enable: data.Enable,
EquipmentRole: data.EquipmentRole,
InstrumentID: data.InstrumentID || null,
InstrumentName: data.InstrumentName || null,
WorkstationID: data.WorkstationID || null,
};
return post('/api/equipmentlist', payload);
}
/**
* Update existing equipment
* @param {Object} data - Equipment data
* @param {number} data.EID - Equipment ID (required)
* @param {string} [data.IEID] - Internal Equipment ID
* @param {number} [data.DepartmentID] - Department ID
* @param {number} [data.Enable] - Enable status 0 or 1
* @param {string} [data.EquipmentRole] - Equipment role code
* @param {string} [data.InstrumentID] - Instrument identifier
* @param {string} [data.InstrumentName] - Instrument display name
* @param {number} [data.WorkstationID] - Workstation ID
* @returns {Promise<Object>} Updated equipment ID
*/
export async function updateEquipment(data) {
const payload = {
EID: data.EID,
IEID: data.IEID,
DepartmentID: data.DepartmentID,
Enable: data.Enable,
EquipmentRole: data.EquipmentRole,
InstrumentID: data.InstrumentID || null,
InstrumentName: data.InstrumentName || null,
WorkstationID: data.WorkstationID || null,
};
return patch('/api/equipmentlist', payload);
}
/**
* Delete equipment (soft delete)
* @param {number} eid - Equipment ID
* @returns {Promise<Object>} Delete result
*/
export async function deleteEquipment(eid) {
return del('/api/equipmentlist', { EID: eid });
}

View File

@ -20,7 +20,11 @@ import {
TestTube,
FileText,
X,
Link
Link,
LandPlot,
Monitor,
Activity,
User
} from 'lucide-svelte';
import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation';
@ -31,6 +35,7 @@ import {
// Collapsible section states - default collapsed
let laboratoryExpanded = $state(false);
let masterDataExpanded = $state(false);
let organizationExpanded = $state(false);
// Load states from localStorage on mount
$effect(() => {
@ -41,6 +46,7 @@ import {
const parsed = JSON.parse(savedStates);
laboratoryExpanded = parsed.laboratory ?? false;
masterDataExpanded = parsed.masterData ?? false;
organizationExpanded = parsed.organization ?? false;
} catch (e) {
// Keep defaults if parsing fails
}
@ -53,7 +59,8 @@ import {
if (browser) {
localStorage.setItem('sidebar_section_states', JSON.stringify({
laboratory: laboratoryExpanded,
masterData: masterDataExpanded
masterData: masterDataExpanded,
organization: organizationExpanded
}));
}
});
@ -63,6 +70,7 @@ import {
if (!isOpen) {
laboratoryExpanded = false;
masterDataExpanded = false;
organizationExpanded = false;
}
});
@ -94,6 +102,13 @@ function toggleLaboratory() {
}
masterDataExpanded = !masterDataExpanded;
}
function toggleOrganization() {
if (!isOpen) {
expandSidebar();
}
organizationExpanded = !organizationExpanded;
}
</script>
<!-- Mobile Overlay Backdrop -->
@ -213,7 +228,26 @@ function toggleLaboratory() {
{#if isOpen && masterDataExpanded}
<ul class="submenu">
<li><a href="/master-data/organization" class="submenu-link"><Building2 size={16} /> Organization</a></li>
<!-- Organization with nested submenu -->
<li class="nav-group">
<button
onclick={toggleOrganization}
class="submenu-link"
>
<Building2 size={16} /> Organization
<ChevronDown size={14} class="chevron ml-auto {organizationExpanded ? 'expanded' : ''}" />
</button>
{#if organizationExpanded}
<ul class="submenu nested">
<li><a href="/master-data/organization/account" class="submenu-link"><User size={14} /> Account</a></li>
<li><a href="/master-data/organization/site" class="submenu-link"><LandPlot size={14} /> Site</a></li>
<li><a href="/master-data/organization/department" class="submenu-link"><Users size={14} /> Department</a></li>
<li><a href="/master-data/organization/discipline" class="submenu-link"><Building2 size={14} /> Discipline</a></li>
<li><a href="/master-data/organization/workstation" class="submenu-link"><Monitor size={14} /> Workstation</a></li>
<li><a href="/master-data/organization/instrument" class="submenu-link"><Activity size={14} /> Instrument</a></li>
</ul>
{/if}
</li>
<li><a href="/master-data/containers" class="submenu-link"><FlaskConical size={16} /> Containers</a></li>
<li><a href="/master-data/tests" class="submenu-link"><TestTube size={16} /> Test Definitions</a></li>
<li><a href="/master-data/testmap" class="submenu-link"><Link size={16} /> Test Mapping</a></li>
@ -364,7 +398,7 @@ function toggleLaboratory() {
position: relative;
}
.submenu {
.submenu {
margin-left: 1.5rem;
margin-top: 0.25rem;
display: flex;
@ -373,6 +407,13 @@ function toggleLaboratory() {
animation: slideDown 0.2s ease-out;
}
.submenu.nested {
margin-left: 1rem;
margin-top: 0.25rem;
padding-left: 0.5rem;
border-left: 1px solid hsl(var(--bc) / 0.1);
}
.submenu-link {
display: flex;
align-items: center;

View File

@ -9,18 +9,10 @@ import {
Globe,
ChevronRight,
FlaskConical,
TestTube,
Building2
TestTube
} from 'lucide-svelte';
const modules = [
{
title: 'Organization',
description: 'Manage disciplines and departments structure',
icon: Building2,
href: '/master-data/organization',
color: 'bg-indigo-500',
},
const modules = [
{
title: 'Containers',
description: 'Manage specimen containers and tubes',

View File

@ -183,22 +183,24 @@
<!-- Search Bar -->
<div class="mb-4">
<div class="relative max-w-md">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
class="input input-sm input-bordered w-full pl-10"
placeholder="Search by name or initial..."
bind:value={searchQuery}
/>
{#if searchQuery}
<button
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
onclick={() => searchQuery = ''}
>
×
</button>
{/if}
<div class="max-w-md">
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-5 h-5 text-gray-400" />
<input
type="text"
class="grow"
placeholder="Search by name or initial..."
bind:value={searchQuery}
/>
{#if searchQuery}
<button
class="btn btn-ghost btn-xs btn-circle"
onclick={() => searchQuery = ''}
>
×
</button>
{/if}
</label>
</div>
</div>

View File

@ -164,16 +164,16 @@
<!-- Search and Filter -->
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1 relative">
<Search class="w-5 h-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<label class="input input-bordered flex-1 flex items-center gap-2">
<Search class="w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Search by code or name..."
class="input input-bordered w-full pl-10"
class="grow"
bind:value={searchQuery}
onkeydown={handleSearchKeydown}
/>
</div>
</label>
<button class="btn btn-outline" onclick={handleFilter}>
<Filter class="w-4 h-4 mr-2" />
Filter

View File

@ -228,24 +228,24 @@
<!-- Search Bar -->
<div class="flex items-center gap-4 mb-4">
<div class="form-control flex-1 max-w-md">
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/50" />
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-4 h-4 text-base-content/50" />
<input
type="text"
class="input input-sm input-bordered w-full pl-10"
class="grow"
placeholder="Search by counter description..."
bind:value={searchQuery}
/>
{#if searchQuery}
<button
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
class="btn btn-ghost btn-xs btn-circle"
onclick={() => searchQuery = ''}
aria-label="Clear search"
>
×
</button>
{/if}
</div>
</label>
</div>
{#if searchQuery}
<span class="text-sm text-base-content/70">

View File

@ -259,15 +259,15 @@
</div>
<!-- Search -->
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Search provinces by name..."
class="input input-sm input-bordered w-full pl-10"
class="grow"
bind:value={provinceSearch}
/>
</div>
</label>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if loading}
@ -342,18 +342,18 @@
</div>
<!-- Search -->
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-5 h-5 text-gray-400" />
<input
type="text"
placeholder={selectedProvince
? `Search cities in ${selectedProvinceLabel}...`
: "Search cities by name..."
}
class="input input-sm input-bordered w-full pl-10"
class="grow"
bind:value={citySearch}
/>
</div>
</label>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if loading}
@ -407,15 +407,15 @@
</div>
<!-- Search -->
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Search areas by name, code, or class..."
class="input input-sm input-bordered w-full pl-10"
class="grow"
bind:value={areaSearch}
/>
</div>
</label>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if loading}

View File

@ -181,15 +181,15 @@
<div class="bg-base-100 rounded-lg shadow border border-base-200">
<div class="p-4 border-b border-base-200">
<div class="flex items-center gap-3 max-w-md">
<div class="relative flex-1">
<Search class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-4 h-4 text-gray-400" />
<input
type="text"
class="input input-sm input-bordered w-full pl-10"
class="grow"
placeholder="Search by code or name..."
bind:value={searchQuery}
/>
</div>
</label>
{#if searchQuery}
<button class="btn btn-ghost btn-sm" onclick={() => (searchQuery = '')}>
Clear

View File

@ -172,16 +172,16 @@
<!-- Search Section -->
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1 relative">
<Search class="w-5 h-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<label class="input input-sm input-bordered flex-1 flex items-center gap-2">
<Search class="w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Search by code or occupation name..."
class="input input-sm input-bordered w-full pl-10"
class="grow"
bind:value={searchQuery}
onkeydown={handleSearchKeydown}
/>
</div>
</label>
</div>
</div>

View File

@ -1,674 +1,93 @@
<script>
import { onMount } from 'svelte';
import {
fetchDisciplines,
createDiscipline,
updateDiscipline,
deleteDiscipline,
fetchDepartments,
createDepartment,
updateDepartment,
deleteDepartment,
} from '$lib/api/organization.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, Search, Building2, Users } from 'lucide-svelte';
ChevronRight,
User,
LandPlot,
Users,
Building2,
Monitor,
Activity
} from 'lucide-svelte';
import { ArrowLeft } from 'lucide-svelte';
// Active tab state
let activeTab = $state('disciplines');
// Loading states
let loadingDisciplines = $state(false);
let loadingDepartments = $state(false);
// Data states
let disciplines = $state([]);
let departments = $state([]);
// Modal states - Disciplines
let disciplineModalOpen = $state(false);
let disciplineModalMode = $state('create');
let disciplineFormData = $state({
DisciplineID: null,
DisciplineCode: '',
DisciplineName: '',
Parent: null,
});
let savingDiscipline = $state(false);
let deleteDisciplineConfirmOpen = $state(false);
let deleteDisciplineItem = $state(null);
let deletingDiscipline = $state(false);
// Modal states - Departments
let departmentModalOpen = $state(false);
let departmentModalMode = $state('create');
let departmentFormData = $state({
DepartmentID: null,
DepartmentCode: '',
DepartmentName: '',
DisciplineID: null,
});
let savingDepartment = $state(false);
let deleteDepartmentConfirmOpen = $state(false);
let deleteDepartmentItem = $state(null);
let deletingDepartment = $state(false);
// Search states
let disciplineSearch = $state('');
let departmentSearch = $state('');
// Table columns
const disciplineColumns = [
{ key: 'DisciplineCode', label: 'Code', class: 'font-medium w-32' },
{ key: 'DisciplineName', label: 'Name' },
{ key: 'ParentName', label: 'Parent Discipline', class: 'w-48' },
{ key: 'actions', label: 'Actions', class: 'w-24 text-center' },
const modules = [
{
title: 'Account',
description: 'Manage organization accounts',
icon: User,
href: '/master-data/organization/account',
color: 'bg-blue-500',
},
{
title: 'Site',
description: 'Manage organization sites and locations',
icon: LandPlot,
href: '/master-data/organization/site',
color: 'bg-green-500',
},
{
title: 'Department',
description: 'Manage departments structure',
icon: Users,
href: '/master-data/organization/department',
color: 'bg-purple-500',
},
{
title: 'Discipline',
description: 'Manage laboratory disciplines',
icon: Building2,
href: '/master-data/organization/discipline',
color: 'bg-indigo-500',
},
{
title: 'Workstation',
description: 'Manage workstation configurations',
icon: Monitor,
href: '/master-data/organization/workstation',
color: 'bg-cyan-500',
},
{
title: 'Instrument',
description: 'Manage laboratory instruments',
icon: Activity,
href: '/master-data/organization/instrument',
color: 'bg-orange-500',
},
];
const departmentColumns = [
{ key: 'DepartmentCode', label: 'Code', class: 'font-medium w-32' },
{ key: 'DepartmentName', label: 'Name' },
{ key: 'DisciplineName', label: 'Discipline', class: 'w-48' },
{ key: 'actions', label: 'Actions', class: 'w-24 text-center' },
];
// Derived data with computed fields
let disciplinesWithParentName = $derived(
disciplines.map((d) => ({
...d,
ParentName: d.Parent
? disciplines.find((p) => p.DisciplineID === d.Parent)?.DisciplineName || '-'
: '-',
}))
);
let departmentsWithDisciplineName = $derived(
departments.map((d) => ({
...d,
DisciplineName:
disciplines.find((disc) => disc.DisciplineID === d.DisciplineID)?.DisciplineName || '-',
}))
);
// Filtered data
let filteredDisciplines = $derived(
disciplineSearch.trim()
? disciplinesWithParentName.filter(
(d) =>
d.DisciplineCode?.toLowerCase().includes(disciplineSearch.toLowerCase()) ||
d.DisciplineName?.toLowerCase().includes(disciplineSearch.toLowerCase())
)
: disciplinesWithParentName
);
let filteredDepartments = $derived(
departmentSearch.trim()
? departmentsWithDisciplineName.filter(
(d) =>
d.DepartmentCode?.toLowerCase().includes(departmentSearch.toLowerCase()) ||
d.DepartmentName?.toLowerCase().includes(departmentSearch.toLowerCase())
)
: departmentsWithDisciplineName
);
onMount(async () => {
await loadDisciplines();
await loadDepartments();
});
async function loadDisciplines() {
loadingDisciplines = true;
try {
const response = await fetchDisciplines();
disciplines = Array.isArray(response.data) ? response.data : [];
} catch (err) {
toastError(err.message || 'Failed to load disciplines');
disciplines = [];
} finally {
loadingDisciplines = false;
}
}
async function loadDepartments() {
loadingDepartments = true;
try {
const response = await fetchDepartments();
departments = Array.isArray(response.data) ? response.data : [];
} catch (err) {
toastError(err.message || 'Failed to load departments');
departments = [];
} finally {
loadingDepartments = false;
}
}
// Discipline handlers
function openCreateDisciplineModal() {
disciplineModalMode = 'create';
disciplineFormData = {
DisciplineID: null,
DisciplineCode: '',
DisciplineName: '',
Parent: null,
};
disciplineModalOpen = true;
}
function openEditDisciplineModal(row) {
disciplineModalMode = 'edit';
disciplineFormData = {
DisciplineID: row.DisciplineID,
DisciplineCode: row.DisciplineCode,
DisciplineName: row.DisciplineName,
Parent: row.Parent || null,
};
disciplineModalOpen = true;
}
async function handleSaveDiscipline() {
savingDiscipline = true;
try {
if (disciplineModalMode === 'create') {
await createDiscipline(disciplineFormData);
toastSuccess('Discipline created successfully');
} else {
await updateDiscipline(disciplineFormData);
toastSuccess('Discipline updated successfully');
}
disciplineModalOpen = false;
await loadDisciplines();
} catch (err) {
toastError(err.message || 'Failed to save discipline');
} finally {
savingDiscipline = false;
}
}
function confirmDeleteDiscipline(row) {
deleteDisciplineItem = row;
deleteDisciplineConfirmOpen = true;
}
async function handleDeleteDiscipline() {
deletingDiscipline = true;
try {
await deleteDiscipline(deleteDisciplineItem.DisciplineID);
toastSuccess('Discipline deleted successfully');
deleteDisciplineConfirmOpen = false;
deleteDisciplineItem = null;
await loadDisciplines();
} catch (err) {
toastError(err.message || 'Failed to delete discipline');
} finally {
deletingDiscipline = false;
}
}
// Department handlers
function openCreateDepartmentModal() {
departmentModalMode = 'create';
departmentFormData = {
DepartmentID: null,
DepartmentCode: '',
DepartmentName: '',
DisciplineID: null,
};
departmentModalOpen = true;
}
function openEditDepartmentModal(row) {
departmentModalMode = 'edit';
departmentFormData = {
DepartmentID: row.DepartmentID,
DepartmentCode: row.DepartmentCode,
DepartmentName: row.DepartmentName,
DisciplineID: row.DisciplineID,
};
departmentModalOpen = true;
}
async function handleSaveDepartment() {
savingDepartment = true;
try {
if (departmentModalMode === 'create') {
await createDepartment(departmentFormData);
toastSuccess('Department created successfully');
} else {
await updateDepartment(departmentFormData);
toastSuccess('Department updated successfully');
}
departmentModalOpen = false;
await loadDepartments();
} catch (err) {
toastError(err.message || 'Failed to save department');
} finally {
savingDepartment = false;
}
}
function confirmDeleteDepartment(row) {
deleteDepartmentItem = row;
deleteDepartmentConfirmOpen = true;
}
async function handleDeleteDepartment() {
deletingDepartment = true;
try {
await deleteDepartment(deleteDepartmentItem.DepartmentID);
toastSuccess('Department deleted successfully');
deleteDepartmentConfirmOpen = false;
deleteDepartmentItem = null;
await loadDepartments();
} catch (err) {
toastError(err.message || 'Failed to delete department');
} finally {
deletingDepartment = false;
}
}
</script>
<div class="p-4">
<!-- 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 class="flex-1">
<h1 class="text-xl font-bold text-gray-800">Organization Structure</h1>
<p class="text-sm text-gray-600">Manage disciplines and departments</p>
<h1 class="text-xl font-bold text-gray-800">Organization</h1>
<p class="text-sm text-gray-600">Manage organizational structure and resources</p>
</div>
</div>
<!-- Tabs -->
<div class="tabs tabs-boxed bg-base-200 mb-4">
<button
class="tab gap-2 {activeTab === 'disciplines' ? 'tab-active' : ''}"
onclick={() => (activeTab = 'disciplines')}
>
<Building2 class="w-4 h-4" />
Disciplines
<span class="badge badge-sm">{disciplines.length}</span>
</button>
<button
class="tab gap-2 {activeTab === 'departments' ? 'tab-active' : ''}"
onclick={() => (activeTab = 'departments')}
>
<Users class="w-4 h-4" />
Departments
<span class="badge badge-sm">{departments.length}</span>
</button>
<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>
<!-- Disciplines Tab -->
{#if activeTab === 'disciplines'}
<div class="space-y-4">
<!-- Search and Add -->
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1 relative">
<Search class="w-5 h-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search disciplines by code or name..."
class="input input-bordered w-full pl-10"
bind:value={disciplineSearch}
/>
</div>
<button class="btn btn-primary" onclick={openCreateDisciplineModal}>
<Plus class="w-4 h-4 mr-2" />
Add Discipline
</button>
</div>
<!-- Disciplines Table -->
<div class="bg-base-100 rounded-lg shadow border border-base-200">
<DataTable
columns={disciplineColumns}
data={filteredDisciplines}
loading={loadingDisciplines}
emptyMessage="No disciplines found"
hover={true}
bordered={false}
>
{#snippet cell({ column, row })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button
class="btn btn-sm btn-ghost"
onclick={() => openEditDisciplineModal(row)}
title="Edit discipline"
>
<Edit2 class="w-4 h-4" />
</button>
<button
class="btn btn-sm btn-ghost text-error"
onclick={() => confirmDeleteDiscipline(row)}
title="Delete discipline"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else}
{row[column.key] || '-'}
{/if}
{/snippet}
</DataTable>
</div>
</div>
{/if}
<!-- Departments Tab -->
{#if activeTab === 'departments'}
<div class="space-y-4">
<!-- Search and Add -->
<div class="flex flex-col sm:flex-row gap-4">
<div class="flex-1 relative">
<Search class="w-5 h-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search departments by code or name..."
class="input input-bordered w-full pl-10"
bind:value={departmentSearch}
/>
</div>
<button class="btn btn-primary" onclick={openCreateDepartmentModal}>
<Plus class="w-4 h-4 mr-2" />
Add Department
</button>
</div>
<!-- Departments Table -->
<div class="bg-base-100 rounded-lg shadow border border-base-200">
<DataTable
columns={departmentColumns}
data={filteredDepartments}
loading={loadingDepartments}
emptyMessage="No departments found"
hover={true}
bordered={false}
>
{#snippet cell({ column, row })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button
class="btn btn-sm btn-ghost"
onclick={() => openEditDepartmentModal(row)}
title="Edit department"
>
<Edit2 class="w-4 h-4" />
</button>
<button
class="btn btn-sm btn-ghost text-error"
onclick={() => confirmDeleteDepartment(row)}
title="Delete department"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else}
{row[column.key] || '-'}
{/if}
{/snippet}
</DataTable>
</div>
</div>
{/if}
</div>
<!-- Discipline Modal -->
<Modal
bind:open={disciplineModalOpen}
title={disciplineModalMode === 'create' ? 'Add Discipline' : 'Edit Discipline'}
size="md"
>
<form class="space-y-4" onsubmit={(e) => { e.preventDefault(); handleSaveDiscipline(); }}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="disciplineCode">
<span class="label-text text-sm font-medium">Discipline Code</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="disciplineCode"
type="text"
class="input input-sm input-bordered w-full"
bind:value={disciplineFormData.DisciplineCode}
placeholder="e.g., HEM, CHEM"
required
/>
<span class="label-text-alt text-xs text-gray-500">Unique code for this discipline</span>
</div>
<div class="form-control">
<label class="label" for="disciplineName">
<span class="label-text text-sm font-medium">Discipline Name</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="disciplineName"
type="text"
class="input input-sm input-bordered w-full"
bind:value={disciplineFormData.DisciplineName}
placeholder="e.g., Hematology"
required
/>
<span class="label-text-alt text-xs text-gray-500">Display name</span>
</div>
</div>
<div class="form-control">
<label class="label" for="parentDiscipline">
<span class="label-text text-sm font-medium">Parent Discipline</span>
</label>
<select
id="parentDiscipline"
class="select select-sm select-bordered w-full"
bind:value={disciplineFormData.Parent}
>
<option value={null}>None (Top-level discipline)</option>
{#each disciplines.filter((d) => d.DisciplineID !== disciplineFormData.DisciplineID) as discipline}
<option value={discipline.DisciplineID}>{discipline.DisciplineName}</option>
{/each}
</select>
<span class="label-text-alt text-xs text-gray-500">Optional parent for hierarchical structure</span>
</div>
</form>
{#snippet footer()}
<button
class="btn btn-ghost"
onclick={() => (disciplineModalOpen = false)}
type="button"
disabled={savingDiscipline}
>
Cancel
</button>
<button
class="btn btn-primary"
onclick={handleSaveDiscipline}
disabled={savingDiscipline}
type="button"
>
{#if savingDiscipline}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{savingDiscipline ? 'Saving...' : 'Save'}
</button>
{/snippet}
</Modal>
<!-- Department Modal -->
<Modal
bind:open={departmentModalOpen}
title={departmentModalMode === 'create' ? 'Add Department' : 'Edit Department'}
size="md"
>
<form class="space-y-4" onsubmit={(e) => { e.preventDefault(); handleSaveDepartment(); }}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="departmentCode">
<span class="label-text text-sm font-medium">Department Code</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="departmentCode"
type="text"
class="input input-sm input-bordered w-full"
bind:value={departmentFormData.DepartmentCode}
placeholder="e.g., HEM-OUT"
required
/>
<span class="label-text-alt text-xs text-gray-500">Unique code for this department</span>
</div>
<div class="form-control">
<label class="label" for="departmentName">
<span class="label-text text-sm font-medium">Department Name</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="departmentName"
type="text"
class="input input-sm input-bordered w-full"
bind:value={departmentFormData.DepartmentName}
placeholder="e.g., Outpatient Hematology"
required
/>
<span class="label-text-alt text-xs text-gray-500">Display name</span>
</div>
</div>
<div class="form-control">
<label class="label" for="discipline">
<span class="label-text text-sm font-medium">Discipline</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<select
id="discipline"
class="select select-sm select-bordered w-full"
bind:value={departmentFormData.DisciplineID}
required
>
<option value={null}>Select discipline...</option>
{#each disciplines as discipline}
<option value={discipline.DisciplineID}>{discipline.DisciplineName}</option>
{/each}
</select>
<span class="label-text-alt text-xs text-gray-500">The discipline this department belongs to</span>
</div>
</form>
{#snippet footer()}
<button
class="btn btn-ghost"
onclick={() => (departmentModalOpen = false)}
type="button"
disabled={savingDepartment}
>
Cancel
</button>
<button
class="btn btn-primary"
onclick={handleSaveDepartment}
disabled={savingDepartment}
type="button"
>
{#if savingDepartment}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{savingDepartment ? 'Saving...' : 'Save'}
</button>
{/snippet}
</Modal>
<!-- Delete Discipline Confirmation -->
<Modal bind:open={deleteDisciplineConfirmOpen} title="Confirm Delete Discipline" size="sm">
<div class="py-2">
<p class="text-base-content/80">Are you sure you want to delete this discipline?</p>
<div class="bg-base-200 rounded-lg p-3 mt-3">
<p class="text-sm">
<span class="text-gray-500">Code:</span>
<strong class="text-base-content font-mono">{deleteDisciplineItem?.DisciplineCode}</strong>
</p>
<p class="text-sm mt-1">
<span class="text-gray-500">Name:</span>
<strong class="text-base-content">{deleteDisciplineItem?.DisciplineName}</strong>
</p>
</div>
<p class="text-sm text-error mt-3 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
This action cannot be undone.
</p>
</div>
{#snippet footer()}
<button
class="btn btn-ghost"
onclick={() => (deleteDisciplineConfirmOpen = false)}
type="button"
disabled={deletingDiscipline}
>
Cancel
</button>
<button
class="btn btn-error"
onclick={handleDeleteDiscipline}
disabled={deletingDiscipline}
type="button"
>
{#if deletingDiscipline}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{deletingDiscipline ? 'Deleting...' : 'Delete'}
</button>
{/snippet}
</Modal>
<!-- Delete Department Confirmation -->
<Modal bind:open={deleteDepartmentConfirmOpen} title="Confirm Delete Department" size="sm">
<div class="py-2">
<p class="text-base-content/80">Are you sure you want to delete this department?</p>
<div class="bg-base-200 rounded-lg p-3 mt-3">
<p class="text-sm">
<span class="text-gray-500">Code:</span>
<strong class="text-base-content font-mono">{deleteDepartmentItem?.DepartmentCode}</strong>
</p>
<p class="text-sm mt-1">
<span class="text-gray-500">Name:</span>
<strong class="text-base-content">{deleteDepartmentItem?.DepartmentName}</strong>
</p>
</div>
<p class="text-sm text-error mt-3 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
This action cannot be undone.
</p>
</div>
{#snippet footer()}
<button
class="btn btn-ghost"
onclick={() => (deleteDepartmentConfirmOpen = false)}
type="button"
disabled={deletingDepartment}
>
Cancel
</button>
<button
class="btn btn-error"
onclick={handleDeleteDepartment}
disabled={deletingDepartment}
type="button"
>
{#if deletingDepartment}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{deletingDepartment ? 'Deleting...' : 'Delete'}
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,278 @@
<script>
import { onMount } from 'svelte';
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, Search, User } from 'lucide-svelte';
let loading = $state(false);
let items = $state([]);
let modalOpen = $state(false);
let modalMode = $state('create');
let saving = $state(false);
let formData = $state({
AccountID: null,
AccountCode: '',
AccountName: '',
});
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
let deleting = $state(false);
let searchQuery = $state('');
const columns = [
{ key: 'AccountCode', label: 'Code', class: 'font-medium' },
{ key: 'AccountName', label: 'Name' },
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
];
onMount(async () => {
await loadItems();
});
async function loadItems() {
loading = true;
try {
// TODO: Replace with actual API call
// const response = await fetchAccounts();
// items = Array.isArray(response.data) ? response.data : [];
items = []; // Placeholder until API is integrated
} catch (err) {
toastError(err.message || 'Failed to load accounts');
items = [];
} finally {
loading = false;
}
}
const filteredItems = $derived(
items.filter((item) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return (
(item.AccountCode && item.AccountCode.toLowerCase().includes(query)) ||
(item.AccountName && item.AccountName.toLowerCase().includes(query))
);
})
);
function openCreateModal() {
modalMode = 'create';
formData = { AccountID: null, AccountCode: '', AccountName: '' };
modalOpen = true;
}
function openEditModal(row) {
modalMode = 'edit';
formData = {
AccountID: row.AccountID,
AccountCode: row.AccountCode,
AccountName: row.AccountName,
};
modalOpen = true;
}
async function handleSave() {
if (!formData.AccountCode.trim()) {
toastError('Account code is required');
return;
}
if (!formData.AccountName.trim()) {
toastError('Account name is required');
return;
}
saving = true;
try {
// TODO: Replace with actual API call
// if (modalMode === 'create') {
// await createAccount(formData);
// toastSuccess('Account created successfully');
// } else {
// await updateAccount(formData);
// toastSuccess('Account updated successfully');
// }
toastSuccess('Account saved successfully (API integration pending)');
modalOpen = false;
await loadItems();
} catch (err) {
toastError(err.message || 'Failed to save account');
} finally {
saving = false;
}
}
function confirmDelete(row) {
deleteItem = row;
deleteConfirmOpen = true;
}
async function handleDelete() {
deleting = true;
try {
// TODO: Replace with actual API call
// await deleteAccount(deleteItem.AccountID);
toastSuccess('Account deleted successfully (API integration pending)');
deleteConfirmOpen = false;
deleteItem = null;
await loadItems();
} catch (err) {
toastError(err.message || 'Failed to delete account');
} finally {
deleting = false;
}
}
</script>
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/master-data/organization" class="btn btn-ghost btn-circle">
<ArrowLeft class="w-5 h-5" />
</a>
<div class="flex-1">
<h1 class="text-xl font-bold text-gray-800">Accounts</h1>
<p class="text-sm text-gray-600">Manage organization accounts</p>
</div>
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Account
</button>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
<div class="p-4 border-b border-base-200">
<div class="flex items-center gap-3 max-w-md">
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-5 h-5 text-gray-400" />
<input
type="text"
class="grow"
placeholder="Search by code or name..."
bind:value={searchQuery}
/>
</label>
{#if searchQuery}
<button class="btn btn-ghost btn-sm" onclick={() => (searchQuery = '')}>
Clear
</button>
{/if}
</div>
</div>
{#if !loading && filteredItems.length === 0}
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center mb-4">
<User class="w-8 h-8 text-gray-400" />
</div>
<h3 class="text-base font-semibold text-base-content mb-1">
{searchQuery ? 'No accounts found' : 'No accounts yet'}
</h3>
<p class="text-sm text-base-content/60 text-center max-w-sm mb-4">
{searchQuery
? `No accounts match your search "${searchQuery}". Try a different search term.`
: 'Get started by adding your first account.'}
</p>
{#if !searchQuery}
<button class="btn btn-primary btn-sm" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Your First Account
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={filteredItems}
{loading}
emptyMessage="No accounts found"
hover={true}
bordered={false}
>
{#snippet cell({ column, row })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)} aria-label="Edit {row.AccountName}">
<Edit2 class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)} aria-label="Delete {row.AccountName}">
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else}
{row[column.key]}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
</div>
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Account' : 'Edit Account'} size="md">
<form class="space-y-4" 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="accountCode">
<span class="label-text text-sm font-medium">Account Code</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="accountCode"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.AccountCode}
placeholder="e.g., ACC001"
required
/>
<span class="label-text-alt text-xs text-gray-500">Unique identifier for this account</span>
</div>
<div class="form-control">
<label class="label" for="accountName">
<span class="label-text text-sm font-medium">Account Name</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="accountName"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.AccountName}
placeholder="e.g., Main Account"
required
/>
<span class="label-text-alt text-xs text-gray-500">Display name for this account</span>
</div>
</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 the following account?
</p>
<div class="bg-base-200 rounded-lg p-3 mt-3">
<p class="font-semibold text-base-content">{deleteItem?.AccountName}</p>
<p class="text-sm text-base-content/60">Code: {deleteItem?.AccountCode}</p>
</div>
<p class="text-sm text-error mt-4">This action cannot be undone.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} disabled={deleting} type="button">
Cancel
</button>
<button class="btn btn-error" onclick={handleDelete} disabled={deleting} type="button">
{#if deleting}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{deleting ? 'Deleting...' : 'Delete'}
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,320 @@
<script>
import { onMount } from 'svelte';
import {
fetchDepartments,
createDepartment,
updateDepartment,
deleteDepartment,
fetchDisciplines,
} from '$lib/api/organization.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, Search, Users } from 'lucide-svelte';
let loading = $state(false);
let items = $state([]);
let disciplines = $state([]);
let modalOpen = $state(false);
let modalMode = $state('create');
let saving = $state(false);
let formData = $state({
DepartmentID: null,
DepartmentCode: '',
DepartmentName: '',
DisciplineID: null,
});
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
let deleting = $state(false);
let searchQuery = $state('');
const columns = [
{ key: 'DepartmentCode', label: 'Code', class: 'font-medium' },
{ key: 'DepartmentName', label: 'Name' },
{ key: 'DisciplineName', label: 'Discipline', class: 'w-48' },
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
];
onMount(async () => {
await loadDisciplines();
await loadItems();
});
async function loadDisciplines() {
try {
const response = await fetchDisciplines();
disciplines = Array.isArray(response.data) ? response.data : [];
} catch (err) {
disciplines = [];
}
}
async function loadItems() {
loading = true;
try {
const response = await fetchDepartments();
items = Array.isArray(response.data) ? response.data : [];
} catch (err) {
toastError(err.message || 'Failed to load departments');
items = [];
} finally {
loading = false;
}
}
const itemsWithDisciplineName = $derived(
items.map((d) => ({
...d,
DisciplineName:
disciplines.find((disc) => disc.DisciplineID === d.DisciplineID)?.DisciplineName || '-',
}))
);
const filteredItems = $derived(
itemsWithDisciplineName.filter((item) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return (
(item.DepartmentCode && item.DepartmentCode.toLowerCase().includes(query)) ||
(item.DepartmentName && item.DepartmentName.toLowerCase().includes(query))
);
})
);
function openCreateModal() {
modalMode = 'create';
formData = { DepartmentID: null, DepartmentCode: '', DepartmentName: '', DisciplineID: null };
modalOpen = true;
}
function openEditModal(row) {
modalMode = 'edit';
formData = {
DepartmentID: row.DepartmentID,
DepartmentCode: row.DepartmentCode,
DepartmentName: row.DepartmentName,
DisciplineID: row.DisciplineID,
};
modalOpen = true;
}
async function handleSave() {
if (!formData.DepartmentCode.trim()) {
toastError('Department code is required');
return;
}
if (!formData.DepartmentName.trim()) {
toastError('Department name is required');
return;
}
saving = true;
try {
if (modalMode === 'create') {
await createDepartment(formData);
toastSuccess('Department created successfully');
} else {
await updateDepartment(formData);
toastSuccess('Department updated successfully');
}
modalOpen = false;
await loadItems();
} catch (err) {
toastError(err.message || 'Failed to save department');
} finally {
saving = false;
}
}
function confirmDelete(row) {
deleteItem = row;
deleteConfirmOpen = true;
}
async function handleDelete() {
deleting = true;
try {
await deleteDepartment(deleteItem.DepartmentID);
toastSuccess('Department deleted successfully');
deleteConfirmOpen = false;
deleteItem = null;
await loadItems();
} catch (err) {
toastError(err.message || 'Failed to delete department');
} finally {
deleting = false;
}
}
</script>
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/master-data/organization" class="btn btn-ghost btn-circle">
<ArrowLeft class="w-5 h-5" />
</a>
<div class="flex-1">
<h1 class="text-xl font-bold text-gray-800">Departments</h1>
<p class="text-sm text-gray-600">Manage organization departments</p>
</div>
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Department
</button>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
<div class="p-4 border-b border-base-200">
<div class="flex items-center gap-3 max-w-md">
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-4 h-4 text-gray-400" />
<input
type="text"
class="grow"
placeholder="Search by code or name..."
bind:value={searchQuery}
/>
</label>
{#if searchQuery}
<button class="btn btn-ghost btn-sm" onclick={() => (searchQuery = '')}>
Clear
</button>
{/if}
</div>
</div>
{#if !loading && filteredItems.length === 0}
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center mb-4">
<Users class="w-8 h-8 text-gray-400" />
</div>
<h3 class="text-base font-semibold text-base-content mb-1">
{searchQuery ? 'No departments found' : 'No departments yet'}
</h3>
<p class="text-sm text-base-content/60 text-center max-w-sm mb-4">
{searchQuery
? `No departments match your search "${searchQuery}". Try a different search term.`
: 'Get started by adding your first department.'}
</p>
{#if !searchQuery}
<button class="btn btn-primary btn-sm" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Your First Department
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={filteredItems}
{loading}
emptyMessage="No departments found"
hover={true}
bordered={false}
>
{#snippet cell({ column, row })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)} aria-label="Edit {row.DepartmentName}">
<Edit2 class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)} aria-label="Delete {row.DepartmentName}">
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else}
{row[column.key]}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
</div>
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Department' : 'Edit Department'} size="md">
<form class="space-y-4" 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="departmentCode">
<span class="label-text text-sm font-medium">Department Code</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="departmentCode"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.DepartmentCode}
placeholder="e.g., DEPT001"
required
/>
<span class="label-text-alt text-xs text-gray-500">Unique identifier for this department</span>
</div>
<div class="form-control">
<label class="label" for="departmentName">
<span class="label-text text-sm font-medium">Department Name</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="departmentName"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.DepartmentName}
placeholder="e.g., Hematology"
required
/>
<span class="label-text-alt text-xs text-gray-500">Display name for this department</span>
</div>
</div>
<div class="form-control">
<label class="label" for="discipline">
<span class="label-text text-sm font-medium">Discipline</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<select
id="discipline"
class="select select-sm select-bordered w-full"
bind:value={formData.DisciplineID}
required
>
<option value={null}>Select discipline...</option>
{#each disciplines as discipline}
<option value={discipline.DisciplineID}>{discipline.DisciplineName}</option>
{/each}
</select>
<span class="label-text-alt text-xs text-gray-500">The discipline this department belongs to</span>
</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 the following department?
</p>
<div class="bg-base-200 rounded-lg p-3 mt-3">
<p class="font-semibold text-base-content">{deleteItem?.DepartmentName}</p>
<p class="text-sm text-base-content/60">Code: {deleteItem?.DepartmentCode}</p>
</div>
<p class="text-sm text-error mt-4">This action cannot be undone.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} disabled={deleting} type="button">
Cancel
</button>
<button class="btn btn-error" onclick={handleDelete} disabled={deleting} type="button">
{#if deleting}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{deleting ? 'Deleting...' : 'Delete'}
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,307 @@
<script>
import { onMount } from 'svelte';
import {
fetchDisciplines,
createDiscipline,
updateDiscipline,
deleteDiscipline,
} from '$lib/api/organization.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, Search, Building2 } from 'lucide-svelte';
let loading = $state(false);
let items = $state([]);
let modalOpen = $state(false);
let modalMode = $state('create');
let saving = $state(false);
let formData = $state({
DisciplineID: null,
DisciplineCode: '',
DisciplineName: '',
Parent: null,
});
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
let deleting = $state(false);
let searchQuery = $state('');
const columns = [
{ key: 'DisciplineCode', label: 'Code', class: 'font-medium' },
{ key: 'DisciplineName', label: 'Name' },
{ key: 'ParentName', label: 'Parent Discipline', class: 'w-48' },
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
];
onMount(async () => {
await loadItems();
});
async function loadItems() {
loading = true;
try {
const response = await fetchDisciplines();
items = Array.isArray(response.data) ? response.data : [];
} catch (err) {
toastError(err.message || 'Failed to load disciplines');
items = [];
} finally {
loading = false;
}
}
const itemsWithParentName = $derived(
items.map((d) => ({
...d,
ParentName: d.Parent
? items.find((p) => p.DisciplineID === d.Parent)?.DisciplineName || '-'
: '-',
}))
);
const filteredItems = $derived(
itemsWithParentName.filter((item) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return (
(item.DisciplineCode && item.DisciplineCode.toLowerCase().includes(query)) ||
(item.DisciplineName && item.DisciplineName.toLowerCase().includes(query))
);
})
);
function openCreateModal() {
modalMode = 'create';
formData = { DisciplineID: null, DisciplineCode: '', DisciplineName: '', Parent: null };
modalOpen = true;
}
function openEditModal(row) {
modalMode = 'edit';
formData = {
DisciplineID: row.DisciplineID,
DisciplineCode: row.DisciplineCode,
DisciplineName: row.DisciplineName,
Parent: row.Parent || null,
};
modalOpen = true;
}
async function handleSave() {
if (!formData.DisciplineCode.trim()) {
toastError('Discipline code is required');
return;
}
if (!formData.DisciplineName.trim()) {
toastError('Discipline name is required');
return;
}
saving = true;
try {
if (modalMode === 'create') {
await createDiscipline(formData);
toastSuccess('Discipline created successfully');
} else {
await updateDiscipline(formData);
toastSuccess('Discipline updated successfully');
}
modalOpen = false;
await loadItems();
} catch (err) {
toastError(err.message || 'Failed to save discipline');
} finally {
saving = false;
}
}
function confirmDelete(row) {
deleteItem = row;
deleteConfirmOpen = true;
}
async function handleDelete() {
deleting = true;
try {
await deleteDiscipline(deleteItem.DisciplineID);
toastSuccess('Discipline deleted successfully');
deleteConfirmOpen = false;
deleteItem = null;
await loadItems();
} catch (err) {
toastError(err.message || 'Failed to delete discipline');
} finally {
deleting = false;
}
}
</script>
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/master-data/organization" class="btn btn-ghost btn-circle">
<ArrowLeft class="w-5 h-5" />
</a>
<div class="flex-1">
<h1 class="text-xl font-bold text-gray-800">Disciplines</h1>
<p class="text-sm text-gray-600">Manage organization disciplines</p>
</div>
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Discipline
</button>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
<div class="p-4 border-b border-base-200">
<div class="flex items-center gap-3 max-w-md">
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-4 h-4 text-gray-400" />
<input
type="text"
class="grow"
placeholder="Search by code or name..."
bind:value={searchQuery}
/>
</label>
{#if searchQuery}
<button class="btn btn-ghost btn-sm" onclick={() => (searchQuery = '')}>
Clear
</button>
{/if}
</div>
</div>
{#if !loading && filteredItems.length === 0}
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center mb-4">
<Building2 class="w-8 h-8 text-gray-400" />
</div>
<h3 class="text-base font-semibold text-base-content mb-1">
{searchQuery ? 'No disciplines found' : 'No disciplines yet'}
</h3>
<p class="text-sm text-base-content/60 text-center max-w-sm mb-4">
{searchQuery
? `No disciplines match your search "${searchQuery}". Try a different search term.`
: 'Get started by adding your first discipline.'}
</p>
{#if !searchQuery}
<button class="btn btn-primary btn-sm" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Your First Discipline
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={filteredItems}
{loading}
emptyMessage="No disciplines found"
hover={true}
bordered={false}
>
{#snippet cell({ column, row })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)} aria-label="Edit {row.DisciplineName}">
<Edit2 class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)} aria-label="Delete {row.DisciplineName}">
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else}
{row[column.key]}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
</div>
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Discipline' : 'Edit Discipline'} size="md">
<form class="space-y-4" 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="disciplineCode">
<span class="label-text text-sm font-medium">Discipline Code</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="disciplineCode"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.DisciplineCode}
placeholder="e.g., HEM, CHEM"
required
/>
<span class="label-text-alt text-xs text-gray-500">Unique identifier for this discipline</span>
</div>
<div class="form-control">
<label class="label" for="disciplineName">
<span class="label-text text-sm font-medium">Discipline Name</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="disciplineName"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.DisciplineName}
placeholder="e.g., Hematology"
required
/>
<span class="label-text-alt text-xs text-gray-500">Display name for this discipline</span>
</div>
</div>
<div class="form-control">
<label class="label" for="parentDiscipline">
<span class="label-text text-sm font-medium">Parent Discipline</span>
</label>
<select
id="parentDiscipline"
class="select select-sm select-bordered w-full"
bind:value={formData.Parent}
>
<option value={null}>None (Top-level discipline)</option>
{#each items.filter((d) => d.DisciplineID !== formData.DisciplineID) as discipline}
<option value={discipline.DisciplineID}>{discipline.DisciplineName}</option>
{/each}
</select>
<span class="label-text-alt text-xs text-gray-500">Optional parent for hierarchical structure</span>
</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 the following discipline?
</p>
<div class="bg-base-200 rounded-lg p-3 mt-3">
<p class="font-semibold text-base-content">{deleteItem?.DisciplineName}</p>
<p class="text-sm text-base-content/60">Code: {deleteItem?.DisciplineCode}</p>
</div>
<p class="text-sm text-error mt-4">This action cannot be undone.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} disabled={deleting} type="button">
Cancel
</button>
<button class="btn btn-error" onclick={handleDelete} disabled={deleting} type="button">
{#if deleting}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{deleting ? 'Deleting...' : 'Delete'}
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,281 @@
<script>
import { onMount } from 'svelte';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import {
fetchEquipmentList,
createEquipment,
updateEquipment,
deleteEquipment,
} from '$lib/api/equipment.js';
import { fetchDepartments } from '$lib/api/organization.js';
import DataTable from '$lib/components/DataTable.svelte';
import EquipmentModal from './EquipmentModal.svelte';
import DeleteConfirmModal from './DeleteConfirmModal.svelte';
import { Plus, Edit2, Trash2, ArrowLeft, Search, Activity } from 'lucide-svelte';
let loading = $state(false);
let items = $state([]);
let departments = $state([]);
let modalOpen = $state(false);
let modalMode = $state('create');
let saving = $state(false);
let formData = $state({
EID: null,
IEID: '',
InstrumentID: '',
InstrumentName: '',
DepartmentID: null,
WorkstationID: null,
Enable: 1,
EquipmentRole: '',
});
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
let deleting = $state(false);
let searchQuery = $state('');
const columns = [
{ key: 'IEID', label: 'IEID', class: 'font-medium' },
{ key: 'InstrumentName', label: 'Name' },
{ key: 'DepartmentName', label: 'Department' },
{ key: 'Enable', label: 'Status', class: 'w-24 text-center' },
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
];
onMount(async () => {
await Promise.all([loadItems(), loadDepartments()]);
});
async function loadItems() {
loading = true;
try {
const response = await fetchEquipmentList();
items = response.data || [];
} catch (err) {
toastError(err.message || 'Failed to load equipment');
items = [];
} finally {
loading = false;
}
}
async function loadDepartments() {
try {
const response = await fetchDepartments();
departments = response.data || [];
} catch (err) {
console.error('Failed to load departments:', err);
departments = [];
}
}
const filteredItems = $derived(
items.filter((item) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return (
(item.IEID && item.IEID.toLowerCase().includes(query)) ||
(item.InstrumentName && item.InstrumentName.toLowerCase().includes(query)) ||
(item.InstrumentID && item.InstrumentID.toLowerCase().includes(query))
);
})
);
function openCreateModal() {
modalMode = 'create';
formData = {
EID: null,
IEID: '',
InstrumentID: '',
InstrumentName: '',
DepartmentID: departments.length > 0 ? departments[0].DepartmentID : null,
WorkstationID: null,
Enable: 1,
EquipmentRole: '',
};
modalOpen = true;
}
function openEditModal(row) {
modalMode = 'edit';
formData = {
EID: row.EID,
IEID: row.IEID || '',
InstrumentID: row.InstrumentID || '',
InstrumentName: row.InstrumentName || '',
DepartmentID: row.DepartmentID,
WorkstationID: row.WorkstationID,
Enable: row.Enable ?? 1,
EquipmentRole: row.EquipmentRole || '',
};
modalOpen = true;
}
function validateForm() {
if (!formData.IEID.trim()) {
toastError('IEID is required');
return false;
}
if (!formData.DepartmentID) {
toastError('Department is required');
return false;
}
if (!formData.EquipmentRole.trim()) {
toastError('Equipment Role is required');
return false;
}
return true;
}
async function handleSave() {
if (!validateForm()) return;
saving = true;
try {
if (modalMode === 'create') {
await createEquipment(formData);
toastSuccess('Equipment created successfully');
} else {
await updateEquipment(formData);
toastSuccess('Equipment updated successfully');
}
modalOpen = false;
await loadItems();
} catch (err) {
toastError(err.message || 'Failed to save equipment');
} finally {
saving = false;
}
}
function confirmDelete(row) {
deleteItem = row;
deleteConfirmOpen = true;
}
async function handleDelete() {
if (!deleteItem?.EID) return;
deleting = true;
try {
await deleteEquipment(deleteItem.EID);
toastSuccess('Equipment deleted successfully');
deleteConfirmOpen = false;
deleteItem = null;
await loadItems();
} catch (err) {
toastError(err.message || 'Failed to delete equipment');
} finally {
deleting = false;
}
}
function getStatusBadge(enable) {
return enable === 1
? '<span class="badge badge-success badge-sm">Active</span>'
: '<span class="badge badge-error badge-sm">Inactive</span>';
}
</script>
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/master-data/organization" class="btn btn-ghost btn-circle">
<ArrowLeft class="w-5 h-5" />
</a>
<div class="flex-1">
<h1 class="text-xl font-bold text-gray-800">Equipment</h1>
<p class="text-sm text-gray-600">Manage laboratory equipment</p>
</div>
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Equipment
</button>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
<div class="p-4 border-b border-base-200">
<div class="flex items-center gap-3 max-w-md">
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-4 h-4 text-gray-400" />
<input
type="text"
class="grow"
placeholder="Search by IEID or name..."
bind:value={searchQuery}
/>
</label>
{#if searchQuery}
<button class="btn btn-ghost btn-sm" onclick={() => (searchQuery = '')}>
Clear
</button>
{/if}
</div>
</div>
{#if !loading && filteredItems.length === 0}
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center mb-4">
<Activity class="w-8 h-8 text-gray-400" />
</div>
<h3 class="text-base font-semibold text-base-content mb-1">
{searchQuery ? 'No equipment found' : 'No equipment yet'}
</h3>
<p class="text-sm text-base-content/60 text-center max-w-sm mb-4">
{searchQuery
? `No equipment match your search "${searchQuery}". Try a different search term.`
: 'Get started by adding your first equipment.'}
</p>
{#if !searchQuery}
<button class="btn btn-primary btn-sm" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Your First Equipment
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={filteredItems}
{loading}
emptyMessage="No equipment found"
hover={true}
bordered={false}
>
{#snippet cell({ column, row })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)} aria-label="Edit {row.InstrumentName}">
<Edit2 class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)} aria-label="Delete {row.InstrumentName}">
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else if column.key === 'Enable'}
{@html getStatusBadge(row.Enable)}
{:else}
{row[column.key] || '-'}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
</div>
<EquipmentModal
bind:open={modalOpen}
mode={modalMode}
bind:formData
{departments}
{saving}
onSave={handleSave}
onCancel={() => (modalOpen = false)}
/>
<DeleteConfirmModal
bind:open={deleteConfirmOpen}
item={deleteItem}
{deleting}
onConfirm={handleDelete}
onCancel={() => (deleteConfirmOpen = false)}
/>

View File

@ -0,0 +1,43 @@
<script>
import Modal from '$lib/components/Modal.svelte';
let {
open = $bindable(false),
item = null,
deleting = false,
onConfirm,
onCancel,
} = $props();
function handleConfirm() {
onConfirm?.();
}
function handleCancel() {
onCancel?.();
}
</script>
<Modal bind:open title="Confirm Delete" size="sm">
<div class="py-2">
<p class="text-base-content/80">
Are you sure you want to delete the following equipment?
</p>
<div class="bg-base-200 rounded-lg p-3 mt-3">
<p class="font-semibold text-base-content">{item?.InstrumentName || item?.IEID}</p>
<p class="text-sm text-base-content/60">IEID: {item?.IEID}</p>
</div>
<p class="text-sm text-error mt-4">This action cannot be undone.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={handleCancel} disabled={deleting} type="button">
Cancel
</button>
<button class="btn btn-error" onclick={handleConfirm} disabled={deleting} type="button">
{#if deleting}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{deleting ? 'Deleting...' : 'Delete'}
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,155 @@
<script>
import Modal from '$lib/components/Modal.svelte';
let {
open = $bindable(false),
mode = 'create',
formData = $bindable({
EID: null,
IEID: '',
InstrumentID: '',
InstrumentName: '',
DepartmentID: null,
WorkstationID: null,
Enable: 1,
EquipmentRole: '',
}),
departments = [],
saving = false,
onSave,
onCancel,
} = $props();
function handleSubmit(e) {
e.preventDefault();
onSave?.();
}
function handleCancel() {
onCancel?.();
}
</script>
<Modal bind:open title={mode === 'create' ? 'Add Equipment' : 'Edit Equipment'} size="lg">
<form class="space-y-4" onsubmit={handleSubmit}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="ieid">
<span class="label-text text-sm font-medium">IEID</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="ieid"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.IEID}
placeholder="e.g., EQUIP001"
required
/>
<span class="label-text-alt text-xs text-gray-500">Internal Equipment ID</span>
</div>
<div class="form-control">
<label class="label" for="department">
<span class="label-text text-sm font-medium">Department</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<select
id="department"
class="select select-sm select-bordered w-full"
bind:value={formData.DepartmentID}
required
>
<option value={null}>Select Department</option>
{#each departments as dept (dept.DepartmentID)}
<option value={dept.DepartmentID}>{dept.DepartmentName}</option>
{/each}
</select>
<span class="label-text-alt text-xs text-gray-500">Required department assignment</span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="instrumentId">
<span class="label-text text-sm font-medium">Instrument ID</span>
</label>
<input
id="instrumentId"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.InstrumentID}
placeholder="e.g., INSTR001"
/>
<span class="label-text-alt text-xs text-gray-500">Optional instrument identifier</span>
</div>
<div class="form-control">
<label class="label" for="instrumentName">
<span class="label-text text-sm font-medium">Instrument Name</span>
</label>
<input
id="instrumentName"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.InstrumentName}
placeholder="e.g., Chemistry Analyzer"
/>
<span class="label-text-alt text-xs text-gray-500">Display name for this equipment</span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="form-control">
<label class="label" for="equipmentRole">
<span class="label-text text-sm font-medium">Equipment Role</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="equipmentRole"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.EquipmentRole}
placeholder="e.g., A"
maxlength="1"
required
/>
<span class="label-text-alt text-xs text-gray-500">Single character role code</span>
</div>
<div class="form-control">
<label class="label" for="workstation">
<span class="label-text text-sm font-medium">Workstation ID</span>
</label>
<input
id="workstation"
type="number"
class="input input-sm input-bordered w-full"
bind:value={formData.WorkstationID}
placeholder="e.g., 1"
/>
<span class="label-text-alt text-xs text-gray-500">Optional workstation reference</span>
</div>
<div class="form-control">
<label class="label" for="enable">
<span class="label-text text-sm font-medium">Status</span>
</label>
<select
id="enable"
class="select select-sm select-bordered w-full"
bind:value={formData.Enable}
>
<option value={1}>Active</option>
<option value={0}>Inactive</option>
</select>
<span class="label-text-alt text-xs text-gray-500">Equipment active status</span>
</div>
</div>
</form>
{#snippet footer()}
<button class="btn btn-ghost" onclick={handleCancel} type="button" disabled={saving}>Cancel</button>
<button class="btn btn-primary" onclick={handleSubmit} disabled={saving} type="button">
{#if saving}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{saving ? 'Saving...' : 'Save'}
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,265 @@
<script>
import { onMount } from 'svelte';
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, Search, LandPlot } from 'lucide-svelte';
let loading = $state(false);
let items = $state([]);
let modalOpen = $state(false);
let modalMode = $state('create');
let saving = $state(false);
let formData = $state({
SiteID: null,
SiteCode: '',
SiteName: '',
});
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
let deleting = $state(false);
let searchQuery = $state('');
const columns = [
{ key: 'SiteCode', label: 'Code', class: 'font-medium' },
{ key: 'SiteName', label: 'Name' },
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
];
onMount(async () => {
await loadItems();
});
async function loadItems() {
loading = true;
try {
items = [];
} catch (err) {
toastError(err.message || 'Failed to load sites');
items = [];
} finally {
loading = false;
}
}
const filteredItems = $derived(
items.filter((item) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return (
(item.SiteCode && item.SiteCode.toLowerCase().includes(query)) ||
(item.SiteName && item.SiteName.toLowerCase().includes(query))
);
})
);
function openCreateModal() {
modalMode = 'create';
formData = { SiteID: null, SiteCode: '', SiteName: '' };
modalOpen = true;
}
function openEditModal(row) {
modalMode = 'edit';
formData = {
SiteID: row.SiteID,
SiteCode: row.SiteCode,
SiteName: row.SiteName,
};
modalOpen = true;
}
async function handleSave() {
if (!formData.SiteCode.trim()) {
toastError('Site code is required');
return;
}
if (!formData.SiteName.trim()) {
toastError('Site name is required');
return;
}
saving = true;
try {
toastSuccess('Site saved successfully (API integration pending)');
modalOpen = false;
await loadItems();
} catch (err) {
toastError(err.message || 'Failed to save site');
} finally {
saving = false;
}
}
function confirmDelete(row) {
deleteItem = row;
deleteConfirmOpen = true;
}
async function handleDelete() {
deleting = true;
try {
toastSuccess('Site deleted successfully (API integration pending)');
deleteConfirmOpen = false;
deleteItem = null;
await loadItems();
} catch (err) {
toastError(err.message || 'Failed to delete site');
} finally {
deleting = false;
}
}
</script>
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/master-data/organization" class="btn btn-ghost btn-circle">
<ArrowLeft class="w-5 h-5" />
</a>
<div class="flex-1">
<h1 class="text-xl font-bold text-gray-800">Sites</h1>
<p class="text-sm text-gray-600">Manage organization sites</p>
</div>
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Site
</button>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
<div class="p-4 border-b border-base-200">
<div class="flex items-center gap-3 max-w-md">
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-4 h-4 text-gray-400" />
<input
type="text"
class="grow"
placeholder="Search by code or name..."
bind:value={searchQuery}
/>
</label>
{#if searchQuery}
<button class="btn btn-ghost btn-sm" onclick={() => (searchQuery = '')}>
Clear
</button>
{/if}
</div>
</div>
{#if !loading && filteredItems.length === 0}
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center mb-4">
<LandPlot class="w-8 h-8 text-gray-400" />
</div>
<h3 class="text-base font-semibold text-base-content mb-1">
{searchQuery ? 'No sites found' : 'No sites yet'}
</h3>
<p class="text-sm text-base-content/60 text-center max-w-sm mb-4">
{searchQuery
? `No sites match your search "${searchQuery}". Try a different search term.`
: 'Get started by adding your first site.'}
</p>
{#if !searchQuery}
<button class="btn btn-primary btn-sm" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Your First Site
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={filteredItems}
{loading}
emptyMessage="No sites found"
hover={true}
bordered={false}
>
{#snippet cell({ column, row })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)} aria-label="Edit {row.SiteName}">
<Edit2 class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)} aria-label="Delete {row.SiteName}">
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else}
{row[column.key]}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
</div>
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Site' : 'Edit Site'} size="md">
<form class="space-y-4" 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="siteCode">
<span class="label-text text-sm font-medium">Site Code</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="siteCode"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.SiteCode}
placeholder="e.g., SITE001"
required
/>
<span class="label-text-alt text-xs text-gray-500">Unique identifier for this site</span>
</div>
<div class="form-control">
<label class="label" for="siteName">
<span class="label-text text-sm font-medium">Site Name</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="siteName"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.SiteName}
placeholder="e.g., Main Site"
required
/>
<span class="label-text-alt text-xs text-gray-500">Display name for this site</span>
</div>
</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 the following site?
</p>
<div class="bg-base-200 rounded-lg p-3 mt-3">
<p class="font-semibold text-base-content">{deleteItem?.SiteName}</p>
<p class="text-sm text-base-content/60">Code: {deleteItem?.SiteCode}</p>
</div>
<p class="text-sm text-error mt-4">This action cannot be undone.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} disabled={deleting} type="button">
Cancel
</button>
<button class="btn btn-error" onclick={handleDelete} disabled={deleting} type="button">
{#if deleting}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{deleting ? 'Deleting...' : 'Delete'}
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,265 @@
<script>
import { onMount } from 'svelte';
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, Search, Monitor } from 'lucide-svelte';
let loading = $state(false);
let items = $state([]);
let modalOpen = $state(false);
let modalMode = $state('create');
let saving = $state(false);
let formData = $state({
WorkstationID: null,
WorkstationCode: '',
WorkstationName: '',
});
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
let deleting = $state(false);
let searchQuery = $state('');
const columns = [
{ key: 'WorkstationCode', label: 'Code', class: 'font-medium' },
{ key: 'WorkstationName', label: 'Name' },
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
];
onMount(async () => {
await loadItems();
});
async function loadItems() {
loading = true;
try {
items = [];
} catch (err) {
toastError(err.message || 'Failed to load workstations');
items = [];
} finally {
loading = false;
}
}
const filteredItems = $derived(
items.filter((item) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
return (
(item.WorkstationCode && item.WorkstationCode.toLowerCase().includes(query)) ||
(item.WorkstationName && item.WorkstationName.toLowerCase().includes(query))
);
})
);
function openCreateModal() {
modalMode = 'create';
formData = { WorkstationID: null, WorkstationCode: '', WorkstationName: '' };
modalOpen = true;
}
function openEditModal(row) {
modalMode = 'edit';
formData = {
WorkstationID: row.WorkstationID,
WorkstationCode: row.WorkstationCode,
WorkstationName: row.WorkstationName,
};
modalOpen = true;
}
async function handleSave() {
if (!formData.WorkstationCode.trim()) {
toastError('Workstation code is required');
return;
}
if (!formData.WorkstationName.trim()) {
toastError('Workstation name is required');
return;
}
saving = true;
try {
toastSuccess('Workstation saved successfully (API integration pending)');
modalOpen = false;
await loadItems();
} catch (err) {
toastError(err.message || 'Failed to save workstation');
} finally {
saving = false;
}
}
function confirmDelete(row) {
deleteItem = row;
deleteConfirmOpen = true;
}
async function handleDelete() {
deleting = true;
try {
toastSuccess('Workstation deleted successfully (API integration pending)');
deleteConfirmOpen = false;
deleteItem = null;
await loadItems();
} catch (err) {
toastError(err.message || 'Failed to delete workstation');
} finally {
deleting = false;
}
}
</script>
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/master-data/organization" class="btn btn-ghost btn-circle">
<ArrowLeft class="w-5 h-5" />
</a>
<div class="flex-1">
<h1 class="text-xl font-bold text-gray-800">Workstations</h1>
<p class="text-sm text-gray-600">Manage organization workstations</p>
</div>
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Workstation
</button>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
<div class="p-4 border-b border-base-200">
<div class="flex items-center gap-3 max-w-md">
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-4 h-4 text-gray-400" />
<input
type="text"
class="grow"
placeholder="Search by code or name..."
bind:value={searchQuery}
/>
</label>
{#if searchQuery}
<button class="btn btn-ghost btn-sm" onclick={() => (searchQuery = '')}>
Clear
</button>
{/if}
</div>
</div>
{#if !loading && filteredItems.length === 0}
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center mb-4">
<Monitor class="w-8 h-8 text-gray-400" />
</div>
<h3 class="text-base font-semibold text-base-content mb-1">
{searchQuery ? 'No workstations found' : 'No workstations yet'}
</h3>
<p class="text-sm text-base-content/60 text-center max-w-sm mb-4">
{searchQuery
? `No workstations match your search "${searchQuery}". Try a different search term.`
: 'Get started by adding your first workstation.'}
</p>
{#if !searchQuery}
<button class="btn btn-primary btn-sm" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Your First Workstation
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={filteredItems}
{loading}
emptyMessage="No workstations found"
hover={true}
bordered={false}
>
{#snippet cell({ column, row })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)} aria-label="Edit {row.WorkstationName}">
<Edit2 class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)} aria-label="Delete {row.WorkstationName}">
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else}
{row[column.key]}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
</div>
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Workstation' : 'Edit Workstation'} size="md">
<form class="space-y-4" 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="workstationCode">
<span class="label-text text-sm font-medium">Workstation Code</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="workstationCode"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.WorkstationCode}
placeholder="e.g., WS001"
required
/>
<span class="label-text-alt text-xs text-gray-500">Unique identifier for this workstation</span>
</div>
<div class="form-control">
<label class="label" for="workstationName">
<span class="label-text text-sm font-medium">Workstation Name</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="workstationName"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.WorkstationName}
placeholder="e.g., Lab Workstation 1"
required
/>
<span class="label-text-alt text-xs text-gray-500">Display name for this workstation</span>
</div>
</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 the following workstation?
</p>
<div class="bg-base-200 rounded-lg p-3 mt-3">
<p class="font-semibold text-base-content">{deleteItem?.WorkstationName}</p>
<p class="text-sm text-base-content/60">Code: {deleteItem?.WorkstationCode}</p>
</div>
<p class="text-sm text-error mt-4">This action cannot be undone.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} disabled={deleting} type="button">
Cancel
</button>
<button class="btn btn-error" onclick={handleDelete} disabled={deleting} type="button">
{#if deleting}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{deleting ? 'Deleting...' : 'Delete'}
</button>
{/snippet}
</Modal>

View File

@ -160,23 +160,25 @@
<!-- Search Bar -->
<div class="mb-4">
<div class="relative max-w-md">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
class="input input-sm input-bordered w-full pl-10"
placeholder="Search by specialty name, title, or parent..."
bind:value={searchQuery}
/>
{#if searchQuery}
<button
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-xs btn-ghost btn-circle"
onclick={() => (searchQuery = '')}
aria-label="Clear search"
>
×
</button>
{/if}
<div class="max-w-md">
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-4 h-4 text-gray-400" />
<input
type="text"
class="grow"
placeholder="Search by specialty name, title, or parent..."
bind:value={searchQuery}
/>
{#if searchQuery}
<button
class="btn btn-xs btn-ghost btn-circle"
onclick={() => (searchQuery = '')}
aria-label="Clear search"
>
×
</button>
{/if}
</label>
</div>
</div>

View File

@ -19,7 +19,6 @@
Monitor,
Filter,
X,
FileText,
} from 'lucide-svelte';
let loading = $state(false);
@ -40,11 +39,17 @@
let filterClientType = $state('');
let filterClientID = $state('');
// System types for dropdowns
const SYSTEM_TYPES = ['HIS', 'SITE', 'WST', 'INST'];
// Derived unique values for ID dropdowns
let uniqueHostIDs = $derived([...new Set(testMaps.map(m => m.HostID).filter(Boolean))].sort());
let uniqueClientIDs = $derived([...new Set(testMaps.map(m => m.ClientID).filter(Boolean))].sort());
const columns = [
{ key: 'HostInfo', label: 'Host System', class: 'w-48' },
{ key: 'ClientInfo', label: 'Client System', class: 'w-48' },
{ key: 'TestCount', label: 'Tests', class: 'w-24 text-center' },
{ key: 'TestPreview', label: 'Test Codes', class: 'flex-1' },
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
];
@ -63,15 +68,11 @@
ClientType: mapping.ClientType || '',
ClientID: mapping.ClientID || '',
mappings: [],
testCodes: [],
});
}
const group = groups.get(key);
group.mappings.push(mapping);
if (mapping.HostTestCode) {
group.testCodes.push(mapping.HostTestCode);
}
});
return Array.from(groups.values());
@ -81,17 +82,13 @@
let filteredGroupedTestMaps = $derived(
groupedTestMaps().filter((group) => {
const matchesHostType =
!filterHostType ||
(group.HostType && group.HostType.toLowerCase().includes(filterHostType.toLowerCase()));
!filterHostType || group.HostType === filterHostType;
const matchesHostID =
!filterHostID ||
(group.HostID && group.HostID.toLowerCase().includes(filterHostID.toLowerCase()));
!filterHostID || group.HostID === filterHostID;
const matchesClientType =
!filterClientType ||
(group.ClientType && group.ClientType.toLowerCase().includes(filterClientType.toLowerCase()));
!filterClientType || group.ClientType === filterClientType;
const matchesClientID =
!filterClientID ||
(group.ClientID && group.ClientID.toLowerCase().includes(filterClientID.toLowerCase()));
!filterClientID || group.ClientID === filterClientID;
return matchesHostType && matchesHostID && matchesClientType && matchesClientID;
})
@ -181,16 +178,6 @@
filterClientType = '';
filterClientID = '';
}
function getTestCodesPreview(testCodes, maxCount = 3) {
if (!testCodes || testCodes.length === 0) return '-';
const displayCodes = testCodes.slice(0, maxCount);
const remaining = testCodes.length - maxCount;
if (remaining > 0) {
return `${displayCodes.join(', ')} +${remaining} more`;
}
return displayCodes.join(', ');
}
</script>
<div class="p-4">
@ -230,18 +217,24 @@
Host
</h4>
<div class="grid grid-cols-2 gap-3">
<input
type="text"
placeholder="Type"
class="input input-sm input-bordered w-full"
<select
class="select select-sm select-bordered w-full"
bind:value={filterHostType}
/>
<input
type="text"
placeholder="ID"
class="input input-sm input-bordered w-full"
>
<option value="">All Types</option>
{#each SYSTEM_TYPES as type}
<option value={type}>{type}</option>
{/each}
</select>
<select
class="select select-sm select-bordered w-full"
bind:value={filterHostID}
/>
>
<option value="">All IDs</option>
{#each uniqueHostIDs as id}
<option value={id}>{id}</option>
{/each}
</select>
</div>
</div>
<!-- Client Filters -->
@ -251,18 +244,24 @@
Client
</h4>
<div class="grid grid-cols-2 gap-3">
<input
type="text"
placeholder="Type"
class="input input-sm input-bordered w-full"
<select
class="select select-sm select-bordered w-full"
bind:value={filterClientType}
/>
<input
type="text"
placeholder="ID"
class="input input-sm input-bordered w-full"
>
<option value="">All Types</option>
{#each SYSTEM_TYPES as type}
<option value={type}>{type}</option>
{/each}
</select>
<select
class="select select-sm select-bordered w-full"
bind:value={filterClientID}
/>
>
<option value="">All IDs</option>
{#each uniqueClientIDs as id}
<option value={id}>{id}</option>
{/each}
</select>
</div>
</div>
</div>
@ -314,17 +313,15 @@
{#if column.key === 'HostInfo'}
<div class="flex items-center gap-2">
<Server class="w-4 h-4 text-primary flex-shrink-0" />
<div>
<div class="font-medium text-sm">{row.HostType || '-'}</div>
<div class="text-xs text-gray-500">{row.HostID || '-'}</div>
<div class="font-medium text-sm">
{row.HostType || '-'} - {row.HostID || '-'}
</div>
</div>
{:else if column.key === 'ClientInfo'}
<div class="flex items-center gap-2">
<Monitor class="w-4 h-4 text-secondary flex-shrink-0" />
<div>
<div class="font-medium text-sm">{row.ClientType || '-'}</div>
<div class="text-xs text-gray-500">{row.ClientID || '-'}</div>
<div class="font-medium text-sm">
{row.ClientType || '-'} - {row.ClientID || '-'}
</div>
</div>
{:else if column.key === 'TestCount'}
@ -333,13 +330,6 @@
{row.mappings.length}
</span>
</div>
{:else if column.key === 'TestPreview'}
<div class="flex items-center gap-2 text-sm">
<FileText class="w-4 h-4 text-gray-400 flex-shrink-0" />
<span class="text-gray-600 truncate" title={row.testCodes.join(', ')}>
{getTestCodesPreview(row.testCodes)}
</span>
</div>
{:else if column.key === 'actions'}
<div class="flex justify-center gap-1">
<button
@ -395,12 +385,7 @@
<span class="text-gray-500">Client:</span>
<strong class="text-base-content">{deleteItem?.ClientType} / {deleteItem?.ClientID}</strong>
</p>
{#if deleteGroupMode && deleteItem?.testCodes?.length > 0}
<p class="text-sm">
<span class="text-gray-500">Tests:</span>
<strong class="text-base-content">{deleteItem.testCodes.slice(0, 5).join(', ')}{deleteItem.testCodes.length > 5 ? '...' : ''}</strong>
</p>
{/if}
</div>
<p class="text-sm text-error mt-3 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@ -367,11 +367,12 @@
{#if modalContext.ClientType === 'INST'}
<select
class="select select-xs select-bordered w-full m-0"
bind:value={row.ConDefID}
value={row.ConDefID}
onchange={(e) => updateRowField(index, 'ConDefID', e.target.value === '' ? null : parseInt(e.target.value))}
>
<option value={null}>Select container...</option>
<option value="">Select container...</option>
{#each containers as container (container.ConDefID)}
<option value={container.ConDefID}>
<option value={container.ConDefID} selected={container.ConDefID === row.ConDefID}>
{container.ConName}
</option>
{/each}

View File

@ -145,22 +145,24 @@
</div>
<div class="mb-4">
<div class="relative max-w-md">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
class="input input-sm input-bordered w-full pl-10"
placeholder="Search by code or name..."
bind:value={searchQuery}
/>
{#if searchQuery}
<button
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
onclick={() => searchQuery = ''}
>
×
</button>
{/if}
<div class="max-w-md">
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-5 h-5 text-gray-400" />
<input
type="text"
class="grow"
placeholder="Search by code or name..."
bind:value={searchQuery}
/>
{#if searchQuery}
<button
class="btn btn-ghost btn-xs btn-circle"
onclick={() => searchQuery = ''}
>
×
</button>
{/if}
</label>
</div>
</div>

View File

@ -150,16 +150,16 @@
<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">
<label class="input input-sm input-bordered flex-1 flex items-center gap-2">
<Search class="w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Search ValueSets by key (e.g., priority_status, test_category)..."
class="input input-sm input-bordered w-full pl-10"
class="grow"
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>
</label>
<button
class="btn btn-primary"