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:
parent
8f75a1339c
commit
ae806911be
141
AGENTS.md
141
AGENTS.md
@ -5,13 +5,22 @@ SvelteKit frontend for Clinical Laboratory Quality Management System. Uses Svelt
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm run dev # Development server
|
# Development
|
||||||
pnpm run build # Production build
|
pnpm run dev # Start development server
|
||||||
|
pnpm run build # Production build (outputs to build/)
|
||||||
pnpm run preview # Preview production 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
|
## Code Style
|
||||||
|
|
||||||
@ -20,26 +29,26 @@ pnpm run prepare # Sync SvelteKit
|
|||||||
- **Quotes**: Single quotes
|
- **Quotes**: Single quotes
|
||||||
- **Indentation**: 2 spaces
|
- **Indentation**: 2 spaces
|
||||||
- **Trailing commas**: In multi-line objects/arrays
|
- **Trailing commas**: In multi-line objects/arrays
|
||||||
- **JSDoc**: Document all exported functions
|
- **JSDoc**: Document all exported functions with `@param` and `@returns`
|
||||||
|
|
||||||
## Naming Conventions
|
### Imports Order
|
||||||
|
|
||||||
- **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
|
|
||||||
|
|
||||||
1. Svelte (`svelte`, `$app/*`)
|
1. Svelte (`svelte`, `$app/*`)
|
||||||
2. `$lib/*` (stores, api, components, utils)
|
2. `$lib/*` (stores, api, components, utils)
|
||||||
3. External libraries (`lucide-svelte`)
|
3. External libraries (`lucide-svelte`)
|
||||||
4. Relative imports (minimize, prefer `$lib`)
|
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
|
```svelte
|
||||||
<script>
|
<script>
|
||||||
@ -50,7 +59,7 @@ pnpm run prepare # Sync SvelteKit
|
|||||||
import { User } from 'lucide-svelte';
|
import { User } from 'lucide-svelte';
|
||||||
|
|
||||||
// 2. Props with $bindable
|
// 2. Props with $bindable
|
||||||
let { open = $bindable(false), title = '', children } = $props();
|
let { open = $bindable(false), title = '', children, footer } = $props();
|
||||||
|
|
||||||
// 3. State
|
// 3. State
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
@ -74,6 +83,7 @@ pnpm run prepare # Sync SvelteKit
|
|||||||
import { get, post, put, patch, del } from '$lib/api/client.js';
|
import { get, post, put, patch, del } from '$lib/api/client.js';
|
||||||
|
|
||||||
// Feature endpoints (with JSDoc)
|
// Feature endpoints (with JSDoc)
|
||||||
|
/** @param {Object} params - Query parameters */
|
||||||
export async function fetchItems(params = {}) {
|
export async function fetchItems(params = {}) {
|
||||||
const query = new URLSearchParams(params).toString();
|
const query = new URLSearchParams(params).toString();
|
||||||
return get(query ? `/api/items?${query}` : '/api/items');
|
return get(query ? `/api/items?${query}` : '/api/items');
|
||||||
@ -104,13 +114,13 @@ function createStore() {
|
|||||||
return JSON.parse(localStorage.getItem('key'));
|
return JSON.parse(localStorage.getItem('key'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const { subscribe, set } = writable(getInitialState());
|
const { subscribe, set, update } = writable(getInitialState());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe,
|
subscribe,
|
||||||
setData: (data) => {
|
setData: (data) => {
|
||||||
if (browser) localStorage.setItem('key', JSON.stringify(data));
|
if (browser) localStorage.setItem('key', JSON.stringify(data));
|
||||||
set(data);
|
set({ data });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -118,42 +128,23 @@ function createStore() {
|
|||||||
|
|
||||||
## Component Patterns
|
## Component Patterns
|
||||||
|
|
||||||
|
### Modal with Snippets
|
||||||
|
|
||||||
```svelte
|
```svelte
|
||||||
<!-- Modal with $bindable props -->
|
<Modal bind:open={showModal} title="Edit Item" size="lg">
|
||||||
<Modal bind:open={showModal} title="Edit" size="lg">
|
|
||||||
{#snippet children()}
|
{#snippet children()}
|
||||||
<form>...</form>
|
<form onsubmit={handleSubmit}>
|
||||||
|
<input class="input input-bordered" bind:value={formData.name} />
|
||||||
|
</form>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
{#snippet footer()}
|
{#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}
|
{/snippet}
|
||||||
</Modal>
|
</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
|
### Form with Validation
|
||||||
|
|
||||||
```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
|
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
let formLoading = $state(false);
|
let formLoading = $state(false);
|
||||||
@ -174,7 +165,7 @@ async function handleSubmit() {
|
|||||||
formLoading = true;
|
formLoading = true;
|
||||||
try {
|
try {
|
||||||
await api.submit(formData);
|
await api.submit(formData);
|
||||||
success('Success');
|
toastSuccess('Success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
formError = err.message;
|
formError = err.message;
|
||||||
} finally {
|
} 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)
|
## 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)
|
- **Colors**: `primary` (emerald), `secondary` (dark blue), `accent` (royal blue)
|
||||||
- **Sizes**: `btn-sm`, `input-sm`, `select-sm` for compact forms
|
- **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
|
## Authentication
|
||||||
|
|
||||||
@ -199,22 +221,7 @@ async function handleSubmit() {
|
|||||||
## LocalStorage
|
## LocalStorage
|
||||||
|
|
||||||
- Only access in browser: check `browser` from `$app/environment`
|
- Only access in browser: check `browser` from `$app/environment`
|
||||||
- Use descriptive keys: `clqms_username`, `auth_token`
|
- Use descriptive keys: `clqms_username`, `clqms_remember`, `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/
|
|
||||||
```
|
|
||||||
|
|
||||||
## Important Notes
|
## Important Notes
|
||||||
|
|
||||||
|
|||||||
155
COMPONENT_ORGANIZATION.md
Normal file
155
COMPONENT_ORGANIZATION.md
Normal 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
|
||||||
@ -47,6 +47,8 @@ tags:
|
|||||||
description: Value set definitions and items
|
description: Value set definitions and items
|
||||||
- name: Demo
|
- name: Demo
|
||||||
description: Demo/test endpoints (no authentication)
|
description: Demo/test endpoints (no authentication)
|
||||||
|
- name: EquipmentList
|
||||||
|
description: Laboratory equipment and instrument management
|
||||||
paths:
|
paths:
|
||||||
/api/auth/login:
|
/api/auth/login:
|
||||||
post:
|
post:
|
||||||
@ -594,6 +596,225 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Status logged
|
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:
|
/api/location:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@ -2556,6 +2777,326 @@ paths:
|
|||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Collection method details
|
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:
|
/api/tests:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@ -2674,14 +3215,18 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- NMRIC
|
- NMRIC
|
||||||
|
- RANGE
|
||||||
|
- TEXT
|
||||||
- VSET
|
- VSET
|
||||||
|
- NORES
|
||||||
RefType:
|
RefType:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- NMRC
|
- RANGE
|
||||||
- TEXT
|
|
||||||
- THOLD
|
- THOLD
|
||||||
- VSET
|
- VSET
|
||||||
|
- TEXT
|
||||||
|
- NOREF
|
||||||
VSet:
|
VSet:
|
||||||
type: integer
|
type: integer
|
||||||
ReqQty:
|
ReqQty:
|
||||||
@ -2794,14 +3339,18 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- NMRIC
|
- NMRIC
|
||||||
|
- RANGE
|
||||||
|
- TEXT
|
||||||
- VSET
|
- VSET
|
||||||
|
- NORES
|
||||||
RefType:
|
RefType:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- NMRC
|
- RANGE
|
||||||
- TEXT
|
|
||||||
- THOLD
|
- THOLD
|
||||||
- VSET
|
- VSET
|
||||||
|
- TEXT
|
||||||
|
- NOREF
|
||||||
VSet:
|
VSet:
|
||||||
type: integer
|
type: integer
|
||||||
ReqQty:
|
ReqQty:
|
||||||
@ -2955,7 +3504,7 @@ paths:
|
|||||||
tags:
|
tags:
|
||||||
- ValueSets
|
- ValueSets
|
||||||
summary: List lib value sets
|
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:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
parameters:
|
parameters:
|
||||||
@ -2963,7 +3512,7 @@ paths:
|
|||||||
in: query
|
in: query
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
description: Optional search term to filter value set names
|
description: Optional search term to filter value set names or labels
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: List of lib value sets with item counts
|
description: List of lib value sets with item counts
|
||||||
@ -2976,14 +3525,19 @@ paths:
|
|||||||
type: string
|
type: string
|
||||||
example: success
|
example: success
|
||||||
data:
|
data:
|
||||||
type: object
|
type: array
|
||||||
additionalProperties:
|
items:
|
||||||
type: integer
|
$ref: '#/components/schemas/ValueSetListItem'
|
||||||
description: Number of items in each value set
|
|
||||||
example:
|
example:
|
||||||
sex: 3
|
- value: sex
|
||||||
marital_status: 6
|
label: Sex
|
||||||
order_status: 6
|
count: 3
|
||||||
|
- value: marital_status
|
||||||
|
label: Marital Status
|
||||||
|
count: 6
|
||||||
|
- value: order_status
|
||||||
|
label: Order Status
|
||||||
|
count: 6
|
||||||
/api/valueset/{key}:
|
/api/valueset/{key}:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
@ -4139,22 +4693,46 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- NMRIC
|
- NMRIC
|
||||||
|
- RANGE
|
||||||
|
- TEXT
|
||||||
- VSET
|
- VSET
|
||||||
|
- NORES
|
||||||
description: |
|
description: |
|
||||||
NMRIC: Numeric result
|
Result type determines the format of test results:
|
||||||
VSET: Value set result
|
- 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:
|
RefType:
|
||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- NMRC
|
- RANGE
|
||||||
- TEXT
|
|
||||||
- THOLD
|
- THOLD
|
||||||
- VSET
|
- VSET
|
||||||
|
- TEXT
|
||||||
|
- NOREF
|
||||||
description: |
|
description: |
|
||||||
NMRC: Numeric reference range
|
Reference type determines which reference range table to use:
|
||||||
TEXT: Text reference
|
- RANGE: Numeric reference range
|
||||||
THOLD: Threshold reference
|
- THOLD: Threshold/panic range
|
||||||
VSET: Value set reference
|
- 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:
|
VSet:
|
||||||
type: integer
|
type: integer
|
||||||
description: Value set ID for VSET result type
|
description: Value set ID for VSET result type
|
||||||
@ -4530,9 +5108,6 @@ components:
|
|||||||
HostID:
|
HostID:
|
||||||
type: string
|
type: string
|
||||||
description: Host identifier
|
description: Host identifier
|
||||||
HostDataSource:
|
|
||||||
type: string
|
|
||||||
description: Host data source
|
|
||||||
HostTestCode:
|
HostTestCode:
|
||||||
type: string
|
type: string
|
||||||
description: Test code in host system
|
description: Test code in host system
|
||||||
@ -4545,9 +5120,6 @@ components:
|
|||||||
ClientID:
|
ClientID:
|
||||||
type: string
|
type: string
|
||||||
description: Client identifier
|
description: Client identifier
|
||||||
ClientDataSource:
|
|
||||||
type: string
|
|
||||||
description: Client data source
|
|
||||||
ConDefID:
|
ConDefID:
|
||||||
type: integer
|
type: integer
|
||||||
description: Connection definition ID
|
description: Connection definition ID
|
||||||
@ -4812,6 +5384,55 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
nullable: true
|
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:
|
Contact:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
@ -4864,3 +5485,16 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
description: Occupation display text
|
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
86
src/lib/api/equipment.js
Normal 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 });
|
||||||
|
}
|
||||||
@ -20,7 +20,11 @@ import {
|
|||||||
TestTube,
|
TestTube,
|
||||||
FileText,
|
FileText,
|
||||||
X,
|
X,
|
||||||
Link
|
Link,
|
||||||
|
LandPlot,
|
||||||
|
Monitor,
|
||||||
|
Activity,
|
||||||
|
User
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
import { auth } from '$lib/stores/auth.js';
|
import { auth } from '$lib/stores/auth.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
@ -31,6 +35,7 @@ import {
|
|||||||
// Collapsible section states - default collapsed
|
// Collapsible section states - default collapsed
|
||||||
let laboratoryExpanded = $state(false);
|
let laboratoryExpanded = $state(false);
|
||||||
let masterDataExpanded = $state(false);
|
let masterDataExpanded = $state(false);
|
||||||
|
let organizationExpanded = $state(false);
|
||||||
|
|
||||||
// Load states from localStorage on mount
|
// Load states from localStorage on mount
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@ -41,6 +46,7 @@ import {
|
|||||||
const parsed = JSON.parse(savedStates);
|
const parsed = JSON.parse(savedStates);
|
||||||
laboratoryExpanded = parsed.laboratory ?? false;
|
laboratoryExpanded = parsed.laboratory ?? false;
|
||||||
masterDataExpanded = parsed.masterData ?? false;
|
masterDataExpanded = parsed.masterData ?? false;
|
||||||
|
organizationExpanded = parsed.organization ?? false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Keep defaults if parsing fails
|
// Keep defaults if parsing fails
|
||||||
}
|
}
|
||||||
@ -53,7 +59,8 @@ import {
|
|||||||
if (browser) {
|
if (browser) {
|
||||||
localStorage.setItem('sidebar_section_states', JSON.stringify({
|
localStorage.setItem('sidebar_section_states', JSON.stringify({
|
||||||
laboratory: laboratoryExpanded,
|
laboratory: laboratoryExpanded,
|
||||||
masterData: masterDataExpanded
|
masterData: masterDataExpanded,
|
||||||
|
organization: organizationExpanded
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -63,6 +70,7 @@ import {
|
|||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
laboratoryExpanded = false;
|
laboratoryExpanded = false;
|
||||||
masterDataExpanded = false;
|
masterDataExpanded = false;
|
||||||
|
organizationExpanded = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -94,6 +102,13 @@ function toggleLaboratory() {
|
|||||||
}
|
}
|
||||||
masterDataExpanded = !masterDataExpanded;
|
masterDataExpanded = !masterDataExpanded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleOrganization() {
|
||||||
|
if (!isOpen) {
|
||||||
|
expandSidebar();
|
||||||
|
}
|
||||||
|
organizationExpanded = !organizationExpanded;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Mobile Overlay Backdrop -->
|
<!-- Mobile Overlay Backdrop -->
|
||||||
@ -213,7 +228,26 @@ function toggleLaboratory() {
|
|||||||
|
|
||||||
{#if isOpen && masterDataExpanded}
|
{#if isOpen && masterDataExpanded}
|
||||||
<ul class="submenu">
|
<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/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/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>
|
<li><a href="/master-data/testmap" class="submenu-link"><Link size={16} /> Test Mapping</a></li>
|
||||||
@ -364,7 +398,7 @@ function toggleLaboratory() {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.submenu {
|
.submenu {
|
||||||
margin-left: 1.5rem;
|
margin-left: 1.5rem;
|
||||||
margin-top: 0.25rem;
|
margin-top: 0.25rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -373,6 +407,13 @@ function toggleLaboratory() {
|
|||||||
animation: slideDown 0.2s ease-out;
|
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 {
|
.submenu-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -9,18 +9,10 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
TestTube,
|
TestTube
|
||||||
Building2
|
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
const modules = [
|
const modules = [
|
||||||
{
|
|
||||||
title: 'Organization',
|
|
||||||
description: 'Manage disciplines and departments structure',
|
|
||||||
icon: Building2,
|
|
||||||
href: '/master-data/organization',
|
|
||||||
color: 'bg-indigo-500',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'Containers',
|
title: 'Containers',
|
||||||
description: 'Manage specimen containers and tubes',
|
description: 'Manage specimen containers and tubes',
|
||||||
|
|||||||
@ -183,22 +183,24 @@
|
|||||||
|
|
||||||
<!-- Search Bar -->
|
<!-- Search Bar -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="relative max-w-md">
|
<div class="max-w-md">
|
||||||
<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">
|
||||||
<input
|
<Search class="w-5 h-5 text-gray-400" />
|
||||||
type="text"
|
<input
|
||||||
class="input input-sm input-bordered w-full pl-10"
|
type="text"
|
||||||
placeholder="Search by name or initial..."
|
class="grow"
|
||||||
bind:value={searchQuery}
|
placeholder="Search by name or initial..."
|
||||||
/>
|
bind:value={searchQuery}
|
||||||
{#if searchQuery}
|
/>
|
||||||
<button
|
{#if searchQuery}
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
|
<button
|
||||||
onclick={() => searchQuery = ''}
|
class="btn btn-ghost btn-xs btn-circle"
|
||||||
>
|
onclick={() => searchQuery = ''}
|
||||||
×
|
>
|
||||||
</button>
|
×
|
||||||
{/if}
|
</button>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -164,16 +164,16 @@
|
|||||||
<!-- Search and Filter -->
|
<!-- Search and Filter -->
|
||||||
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
|
<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 flex-col sm:flex-row gap-4">
|
||||||
<div class="flex-1 relative">
|
<label class="input input-bordered flex-1 flex items-center gap-2">
|
||||||
<Search class="w-5 h-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
|
<Search class="w-5 h-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by code or name..."
|
placeholder="Search by code or name..."
|
||||||
class="input input-bordered w-full pl-10"
|
class="grow"
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
onkeydown={handleSearchKeydown}
|
onkeydown={handleSearchKeydown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
<button class="btn btn-outline" onclick={handleFilter}>
|
<button class="btn btn-outline" onclick={handleFilter}>
|
||||||
<Filter class="w-4 h-4 mr-2" />
|
<Filter class="w-4 h-4 mr-2" />
|
||||||
Filter
|
Filter
|
||||||
|
|||||||
@ -228,24 +228,24 @@
|
|||||||
<!-- Search Bar -->
|
<!-- Search Bar -->
|
||||||
<div class="flex items-center gap-4 mb-4">
|
<div class="flex items-center gap-4 mb-4">
|
||||||
<div class="form-control flex-1 max-w-md">
|
<div class="form-control flex-1 max-w-md">
|
||||||
<div class="relative">
|
<label class="input input-sm input-bordered w-full flex items-center gap-2">
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/50" />
|
<Search class="w-4 h-4 text-base-content/50" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-sm input-bordered w-full pl-10"
|
class="grow"
|
||||||
placeholder="Search by counter description..."
|
placeholder="Search by counter description..."
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
/>
|
/>
|
||||||
{#if searchQuery}
|
{#if searchQuery}
|
||||||
<button
|
<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 = ''}
|
onclick={() => searchQuery = ''}
|
||||||
aria-label="Clear search"
|
aria-label="Clear search"
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{#if searchQuery}
|
{#if searchQuery}
|
||||||
<span class="text-sm text-base-content/70">
|
<span class="text-sm text-base-content/70">
|
||||||
|
|||||||
@ -259,15 +259,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="relative">
|
<label class="input input-sm input-bordered w-full flex items-center gap-2">
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
<Search class="w-5 h-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search provinces by name..."
|
placeholder="Search provinces by name..."
|
||||||
class="input input-sm input-bordered w-full pl-10"
|
class="grow"
|
||||||
bind:value={provinceSearch}
|
bind:value={provinceSearch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@ -342,18 +342,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="relative">
|
<label class="input input-sm input-bordered w-full flex items-center gap-2">
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
<Search class="w-5 h-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={selectedProvince
|
placeholder={selectedProvince
|
||||||
? `Search cities in ${selectedProvinceLabel}...`
|
? `Search cities in ${selectedProvinceLabel}...`
|
||||||
: "Search cities by name..."
|
: "Search cities by name..."
|
||||||
}
|
}
|
||||||
class="input input-sm input-bordered w-full pl-10"
|
class="grow"
|
||||||
bind:value={citySearch}
|
bind:value={citySearch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
@ -407,15 +407,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="relative">
|
<label class="input input-sm input-bordered w-full flex items-center gap-2">
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
<Search class="w-5 h-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search areas by name, code, or class..."
|
placeholder="Search areas by name, code, or class..."
|
||||||
class="input input-sm input-bordered w-full pl-10"
|
class="grow"
|
||||||
bind:value={areaSearch}
|
bind:value={areaSearch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
|
|
||||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
|
|||||||
@ -181,15 +181,15 @@
|
|||||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||||
<div class="p-4 border-b border-base-200">
|
<div class="p-4 border-b border-base-200">
|
||||||
<div class="flex items-center gap-3 max-w-md">
|
<div class="flex items-center gap-3 max-w-md">
|
||||||
<div class="relative flex-1">
|
<label class="input input-sm input-bordered w-full flex items-center gap-2">
|
||||||
<Search class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
<Search class="w-4 h-4 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-sm input-bordered w-full pl-10"
|
class="grow"
|
||||||
placeholder="Search by code or name..."
|
placeholder="Search by code or name..."
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
{#if searchQuery}
|
{#if searchQuery}
|
||||||
<button class="btn btn-ghost btn-sm" onclick={() => (searchQuery = '')}>
|
<button class="btn btn-ghost btn-sm" onclick={() => (searchQuery = '')}>
|
||||||
Clear
|
Clear
|
||||||
|
|||||||
@ -172,16 +172,16 @@
|
|||||||
<!-- Search Section -->
|
<!-- Search Section -->
|
||||||
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
|
<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 flex-col sm:flex-row gap-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 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
|
<Search class="w-5 h-5 text-gray-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by code or occupation name..."
|
placeholder="Search by code or occupation name..."
|
||||||
class="input input-sm input-bordered w-full pl-10"
|
class="grow"
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
onkeydown={handleSearchKeydown}
|
onkeydown={handleSearchKeydown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,674 +1,93 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import {
|
||||||
import {
|
ChevronRight,
|
||||||
fetchDisciplines,
|
User,
|
||||||
createDiscipline,
|
LandPlot,
|
||||||
updateDiscipline,
|
Users,
|
||||||
deleteDiscipline,
|
Building2,
|
||||||
fetchDepartments,
|
Monitor,
|
||||||
createDepartment,
|
Activity
|
||||||
updateDepartment,
|
} from 'lucide-svelte';
|
||||||
deleteDepartment,
|
import { ArrowLeft } from 'lucide-svelte';
|
||||||
} 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';
|
|
||||||
|
|
||||||
// Active tab state
|
const modules = [
|
||||||
let activeTab = $state('disciplines');
|
{
|
||||||
|
title: 'Account',
|
||||||
// Loading states
|
description: 'Manage organization accounts',
|
||||||
let loadingDisciplines = $state(false);
|
icon: User,
|
||||||
let loadingDepartments = $state(false);
|
href: '/master-data/organization/account',
|
||||||
|
color: 'bg-blue-500',
|
||||||
// Data states
|
},
|
||||||
let disciplines = $state([]);
|
{
|
||||||
let departments = $state([]);
|
title: 'Site',
|
||||||
|
description: 'Manage organization sites and locations',
|
||||||
// Modal states - Disciplines
|
icon: LandPlot,
|
||||||
let disciplineModalOpen = $state(false);
|
href: '/master-data/organization/site',
|
||||||
let disciplineModalMode = $state('create');
|
color: 'bg-green-500',
|
||||||
let disciplineFormData = $state({
|
},
|
||||||
DisciplineID: null,
|
{
|
||||||
DisciplineCode: '',
|
title: 'Department',
|
||||||
DisciplineName: '',
|
description: 'Manage departments structure',
|
||||||
Parent: null,
|
icon: Users,
|
||||||
});
|
href: '/master-data/organization/department',
|
||||||
let savingDiscipline = $state(false);
|
color: 'bg-purple-500',
|
||||||
let deleteDisciplineConfirmOpen = $state(false);
|
},
|
||||||
let deleteDisciplineItem = $state(null);
|
{
|
||||||
let deletingDiscipline = $state(false);
|
title: 'Discipline',
|
||||||
|
description: 'Manage laboratory disciplines',
|
||||||
// Modal states - Departments
|
icon: Building2,
|
||||||
let departmentModalOpen = $state(false);
|
href: '/master-data/organization/discipline',
|
||||||
let departmentModalMode = $state('create');
|
color: 'bg-indigo-500',
|
||||||
let departmentFormData = $state({
|
},
|
||||||
DepartmentID: null,
|
{
|
||||||
DepartmentCode: '',
|
title: 'Workstation',
|
||||||
DepartmentName: '',
|
description: 'Manage workstation configurations',
|
||||||
DisciplineID: null,
|
icon: Monitor,
|
||||||
});
|
href: '/master-data/organization/workstation',
|
||||||
let savingDepartment = $state(false);
|
color: 'bg-cyan-500',
|
||||||
let deleteDepartmentConfirmOpen = $state(false);
|
},
|
||||||
let deleteDepartmentItem = $state(null);
|
{
|
||||||
let deletingDepartment = $state(false);
|
title: 'Instrument',
|
||||||
|
description: 'Manage laboratory instruments',
|
||||||
// Search states
|
icon: Activity,
|
||||||
let disciplineSearch = $state('');
|
href: '/master-data/organization/instrument',
|
||||||
let departmentSearch = $state('');
|
color: 'bg-orange-500',
|
||||||
|
},
|
||||||
// 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 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>
|
</script>
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center gap-4 mb-6">
|
<div class="flex items-center gap-4 mb-6">
|
||||||
<a href="/master-data" class="btn btn-ghost btn-circle">
|
<a href="/master-data" class="btn btn-ghost btn-circle">
|
||||||
<ArrowLeft class="w-5 h-5" />
|
<ArrowLeft class="w-5 h-5" />
|
||||||
</a>
|
</a>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<h1 class="text-xl font-bold text-gray-800">Organization Structure</h1>
|
<h1 class="text-xl font-bold text-gray-800">Organization</h1>
|
||||||
<p class="text-sm text-gray-600">Manage disciplines and departments</p>
|
<p class="text-sm text-gray-600">Manage organizational structure and resources</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<div class="tabs tabs-boxed bg-base-200 mb-4">
|
{#each modules as module}
|
||||||
<button
|
<a
|
||||||
class="tab gap-2 {activeTab === 'disciplines' ? 'tab-active' : ''}"
|
href={module.href}
|
||||||
onclick={() => (activeTab = 'disciplines')}
|
class="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow border border-base-200 group"
|
||||||
>
|
>
|
||||||
<Building2 class="w-4 h-4" />
|
<div class="card-body">
|
||||||
Disciplines
|
<div class="flex items-start gap-4">
|
||||||
<span class="badge badge-sm">{disciplines.length}</span>
|
<div class="{module.color} text-white p-3 rounded-lg">
|
||||||
</button>
|
<svelte:component this={module.icon} class="w-6 h-6" />
|
||||||
<button
|
</div>
|
||||||
class="tab gap-2 {activeTab === 'departments' ? 'tab-active' : ''}"
|
<div class="flex-1">
|
||||||
onclick={() => (activeTab = 'departments')}
|
<h2 class="card-title text-lg group-hover:text-primary transition-colors">
|
||||||
>
|
{module.title}
|
||||||
<Users class="w-4 h-4" />
|
</h2>
|
||||||
Departments
|
<p class="text-sm text-gray-600 mt-1">{module.description}</p>
|
||||||
<span class="badge badge-sm">{departments.length}</span>
|
</div>
|
||||||
</button>
|
<ChevronRight class="w-5 h-5 text-gray-400 group-hover:text-primary transition-colors" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{/each}
|
||||||
</div>
|
</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>
|
</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>
|
|
||||||
|
|||||||
278
src/routes/(app)/master-data/organization/account/+page.svelte
Normal file
278
src/routes/(app)/master-data/organization/account/+page.svelte
Normal 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>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
@ -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)}
|
||||||
|
/>
|
||||||
@ -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>
|
||||||
@ -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>
|
||||||
265
src/routes/(app)/master-data/organization/site/+page.svelte
Normal file
265
src/routes/(app)/master-data/organization/site/+page.svelte
Normal 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>
|
||||||
@ -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>
|
||||||
@ -160,23 +160,25 @@
|
|||||||
|
|
||||||
<!-- Search Bar -->
|
<!-- Search Bar -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="relative max-w-md">
|
<div class="max-w-md">
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
<label class="input input-sm input-bordered w-full flex items-center gap-2">
|
||||||
<input
|
<Search class="w-4 h-4 text-gray-400" />
|
||||||
type="text"
|
<input
|
||||||
class="input input-sm input-bordered w-full pl-10"
|
type="text"
|
||||||
placeholder="Search by specialty name, title, or parent..."
|
class="grow"
|
||||||
bind:value={searchQuery}
|
placeholder="Search by specialty name, title, or parent..."
|
||||||
/>
|
bind:value={searchQuery}
|
||||||
{#if searchQuery}
|
/>
|
||||||
<button
|
{#if searchQuery}
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-xs btn-ghost btn-circle"
|
<button
|
||||||
onclick={() => (searchQuery = '')}
|
class="btn btn-xs btn-ghost btn-circle"
|
||||||
aria-label="Clear search"
|
onclick={() => (searchQuery = '')}
|
||||||
>
|
aria-label="Clear search"
|
||||||
×
|
>
|
||||||
</button>
|
×
|
||||||
{/if}
|
</button>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -19,7 +19,6 @@
|
|||||||
Monitor,
|
Monitor,
|
||||||
Filter,
|
Filter,
|
||||||
X,
|
X,
|
||||||
FileText,
|
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
@ -40,11 +39,17 @@
|
|||||||
let filterClientType = $state('');
|
let filterClientType = $state('');
|
||||||
let filterClientID = $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 = [
|
const columns = [
|
||||||
{ key: 'HostInfo', label: 'Host System', class: 'w-48' },
|
{ key: 'HostInfo', label: 'Host System', class: 'w-48' },
|
||||||
{ key: 'ClientInfo', label: 'Client System', class: 'w-48' },
|
{ key: 'ClientInfo', label: 'Client System', class: 'w-48' },
|
||||||
{ key: 'TestCount', label: 'Tests', class: 'w-24 text-center' },
|
{ 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' },
|
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -63,15 +68,11 @@
|
|||||||
ClientType: mapping.ClientType || '',
|
ClientType: mapping.ClientType || '',
|
||||||
ClientID: mapping.ClientID || '',
|
ClientID: mapping.ClientID || '',
|
||||||
mappings: [],
|
mappings: [],
|
||||||
testCodes: [],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const group = groups.get(key);
|
const group = groups.get(key);
|
||||||
group.mappings.push(mapping);
|
group.mappings.push(mapping);
|
||||||
if (mapping.HostTestCode) {
|
|
||||||
group.testCodes.push(mapping.HostTestCode);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return Array.from(groups.values());
|
return Array.from(groups.values());
|
||||||
@ -81,17 +82,13 @@
|
|||||||
let filteredGroupedTestMaps = $derived(
|
let filteredGroupedTestMaps = $derived(
|
||||||
groupedTestMaps().filter((group) => {
|
groupedTestMaps().filter((group) => {
|
||||||
const matchesHostType =
|
const matchesHostType =
|
||||||
!filterHostType ||
|
!filterHostType || group.HostType === filterHostType;
|
||||||
(group.HostType && group.HostType.toLowerCase().includes(filterHostType.toLowerCase()));
|
|
||||||
const matchesHostID =
|
const matchesHostID =
|
||||||
!filterHostID ||
|
!filterHostID || group.HostID === filterHostID;
|
||||||
(group.HostID && group.HostID.toLowerCase().includes(filterHostID.toLowerCase()));
|
|
||||||
const matchesClientType =
|
const matchesClientType =
|
||||||
!filterClientType ||
|
!filterClientType || group.ClientType === filterClientType;
|
||||||
(group.ClientType && group.ClientType.toLowerCase().includes(filterClientType.toLowerCase()));
|
|
||||||
const matchesClientID =
|
const matchesClientID =
|
||||||
!filterClientID ||
|
!filterClientID || group.ClientID === filterClientID;
|
||||||
(group.ClientID && group.ClientID.toLowerCase().includes(filterClientID.toLowerCase()));
|
|
||||||
|
|
||||||
return matchesHostType && matchesHostID && matchesClientType && matchesClientID;
|
return matchesHostType && matchesHostID && matchesClientType && matchesClientID;
|
||||||
})
|
})
|
||||||
@ -181,16 +178,6 @@
|
|||||||
filterClientType = '';
|
filterClientType = '';
|
||||||
filterClientID = '';
|
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>
|
</script>
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
@ -230,18 +217,24 @@
|
|||||||
Host
|
Host
|
||||||
</h4>
|
</h4>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<input
|
<select
|
||||||
type="text"
|
class="select select-sm select-bordered w-full"
|
||||||
placeholder="Type"
|
|
||||||
class="input input-sm input-bordered w-full"
|
|
||||||
bind:value={filterHostType}
|
bind:value={filterHostType}
|
||||||
/>
|
>
|
||||||
<input
|
<option value="">All Types</option>
|
||||||
type="text"
|
{#each SYSTEM_TYPES as type}
|
||||||
placeholder="ID"
|
<option value={type}>{type}</option>
|
||||||
class="input input-sm input-bordered w-full"
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
class="select select-sm select-bordered w-full"
|
||||||
bind:value={filterHostID}
|
bind:value={filterHostID}
|
||||||
/>
|
>
|
||||||
|
<option value="">All IDs</option>
|
||||||
|
{#each uniqueHostIDs as id}
|
||||||
|
<option value={id}>{id}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Client Filters -->
|
<!-- Client Filters -->
|
||||||
@ -251,18 +244,24 @@
|
|||||||
Client
|
Client
|
||||||
</h4>
|
</h4>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<input
|
<select
|
||||||
type="text"
|
class="select select-sm select-bordered w-full"
|
||||||
placeholder="Type"
|
|
||||||
class="input input-sm input-bordered w-full"
|
|
||||||
bind:value={filterClientType}
|
bind:value={filterClientType}
|
||||||
/>
|
>
|
||||||
<input
|
<option value="">All Types</option>
|
||||||
type="text"
|
{#each SYSTEM_TYPES as type}
|
||||||
placeholder="ID"
|
<option value={type}>{type}</option>
|
||||||
class="input input-sm input-bordered w-full"
|
{/each}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
class="select select-sm select-bordered w-full"
|
||||||
bind:value={filterClientID}
|
bind:value={filterClientID}
|
||||||
/>
|
>
|
||||||
|
<option value="">All IDs</option>
|
||||||
|
{#each uniqueClientIDs as id}
|
||||||
|
<option value={id}>{id}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -314,17 +313,15 @@
|
|||||||
{#if column.key === 'HostInfo'}
|
{#if column.key === 'HostInfo'}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Server class="w-4 h-4 text-primary flex-shrink-0" />
|
<Server class="w-4 h-4 text-primary flex-shrink-0" />
|
||||||
<div>
|
<div class="font-medium text-sm">
|
||||||
<div class="font-medium text-sm">{row.HostType || '-'}</div>
|
{row.HostType || '-'} - {row.HostID || '-'}
|
||||||
<div class="text-xs text-gray-500">{row.HostID || '-'}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if column.key === 'ClientInfo'}
|
{:else if column.key === 'ClientInfo'}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Monitor class="w-4 h-4 text-secondary flex-shrink-0" />
|
<Monitor class="w-4 h-4 text-secondary flex-shrink-0" />
|
||||||
<div>
|
<div class="font-medium text-sm">
|
||||||
<div class="font-medium text-sm">{row.ClientType || '-'}</div>
|
{row.ClientType || '-'} - {row.ClientID || '-'}
|
||||||
<div class="text-xs text-gray-500">{row.ClientID || '-'}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if column.key === 'TestCount'}
|
{:else if column.key === 'TestCount'}
|
||||||
@ -333,13 +330,6 @@
|
|||||||
{row.mappings.length}
|
{row.mappings.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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'}
|
{:else if column.key === 'actions'}
|
||||||
<div class="flex justify-center gap-1">
|
<div class="flex justify-center gap-1">
|
||||||
<button
|
<button
|
||||||
@ -395,12 +385,7 @@
|
|||||||
<span class="text-gray-500">Client:</span>
|
<span class="text-gray-500">Client:</span>
|
||||||
<strong class="text-base-content">{deleteItem?.ClientType} / {deleteItem?.ClientID}</strong>
|
<strong class="text-base-content">{deleteItem?.ClientType} / {deleteItem?.ClientID}</strong>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<p class="text-sm text-error mt-3 flex items-center gap-2">
|
<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">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
@ -367,11 +367,12 @@
|
|||||||
{#if modalContext.ClientType === 'INST'}
|
{#if modalContext.ClientType === 'INST'}
|
||||||
<select
|
<select
|
||||||
class="select select-xs select-bordered w-full m-0"
|
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)}
|
{#each containers as container (container.ConDefID)}
|
||||||
<option value={container.ConDefID}>
|
<option value={container.ConDefID} selected={container.ConDefID === row.ConDefID}>
|
||||||
{container.ConName}
|
{container.ConName}
|
||||||
</option>
|
</option>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@ -145,22 +145,24 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="relative max-w-md">
|
<div class="max-w-md">
|
||||||
<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">
|
||||||
<input
|
<Search class="w-5 h-5 text-gray-400" />
|
||||||
type="text"
|
<input
|
||||||
class="input input-sm input-bordered w-full pl-10"
|
type="text"
|
||||||
placeholder="Search by code or name..."
|
class="grow"
|
||||||
bind:value={searchQuery}
|
placeholder="Search by code or name..."
|
||||||
/>
|
bind:value={searchQuery}
|
||||||
{#if searchQuery}
|
/>
|
||||||
<button
|
{#if searchQuery}
|
||||||
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
|
<button
|
||||||
onclick={() => searchQuery = ''}
|
class="btn btn-ghost btn-xs btn-circle"
|
||||||
>
|
onclick={() => searchQuery = ''}
|
||||||
×
|
>
|
||||||
</button>
|
×
|
||||||
{/if}
|
</button>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -150,16 +150,16 @@
|
|||||||
<div class="flex flex-col min-h-0">
|
<div class="flex flex-col min-h-0">
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<div class="flex flex-col sm:flex-row gap-4 mb-4">
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search ValueSets by key (e.g., priority_status, test_category)..."
|
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}
|
bind:value={searchQuery}
|
||||||
onkeydown={handleSearchKeydown}
|
onkeydown={handleSearchKeydown}
|
||||||
/>
|
/>
|
||||||
<Search class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
</label>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user