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
|
||||
|
||||
```bash
|
||||
pnpm run dev # Development server
|
||||
pnpm run build # Production build
|
||||
# Development
|
||||
pnpm run dev # Start development server
|
||||
pnpm run build # Production build (outputs to build/)
|
||||
pnpm run preview # Preview production build
|
||||
pnpm run prepare # Sync SvelteKit
|
||||
pnpm run prepare # Sync SvelteKit (runs on install)
|
||||
|
||||
# Package management
|
||||
pnpm install # Install dependencies (use pnpm, not npm/yarn)
|
||||
|
||||
# Testing (not configured yet - add when needed)
|
||||
# vitest run src/path/to/test.js # Run single test
|
||||
# vitest # Run tests in watch mode
|
||||
# npx playwright test # E2E tests
|
||||
```
|
||||
|
||||
**No test framework yet.** When adding: use Vitest (`vitest run src/path/to/test.js`), Playwright for E2E.
|
||||
**No ESLint/Prettier configured yet.** When adding: configure in `vite.config.js` or separate config files.
|
||||
|
||||
## Code Style
|
||||
|
||||
@ -20,26 +29,26 @@ pnpm run prepare # Sync SvelteKit
|
||||
- **Quotes**: Single quotes
|
||||
- **Indentation**: 2 spaces
|
||||
- **Trailing commas**: In multi-line objects/arrays
|
||||
- **JSDoc**: Document all exported functions
|
||||
- **JSDoc**: Document all exported functions with `@param` and `@returns`
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- **Components**: PascalCase (`LoginForm.svelte`)
|
||||
- **Files/Routes**: lowercase with hyphens (`+page.svelte`)
|
||||
- **Variables**: camelCase (`isLoading`, `userName`)
|
||||
- **Constants**: UPPER_SNAKE_CASE (`API_URL`)
|
||||
- **Stores**: camelCase (`auth`, `config`)
|
||||
- **Handlers**: prefix with `handle` (`handleSubmit`)
|
||||
- **Form state**: `formLoading`, `formError`
|
||||
|
||||
## Imports Order
|
||||
### Imports Order
|
||||
|
||||
1. Svelte (`svelte`, `$app/*`)
|
||||
2. `$lib/*` (stores, api, components, utils)
|
||||
3. External libraries (`lucide-svelte`)
|
||||
4. Relative imports (minimize, prefer `$lib`)
|
||||
|
||||
## Svelte 5 Components
|
||||
## Naming Conventions
|
||||
|
||||
- **Components**: PascalCase (`LoginForm.svelte`, `PatientFormModal.svelte`)
|
||||
- **Files/Routes**: lowercase with hyphens (`+page.svelte`, `user-profile/`)
|
||||
- **Variables**: camelCase (`isLoading`, `userName`)
|
||||
- **Constants**: UPPER_SNAKE_CASE (`API_URL`, `STORAGE_KEY`)
|
||||
- **Stores**: camelCase, descriptive (`auth`, `config`, `userStore`)
|
||||
- **Event handlers**: prefix with `handle` (`handleSubmit`, `handleClick`)
|
||||
- **Form state**: `formLoading`, `formError`, `deleteConfirmOpen`
|
||||
|
||||
## Svelte 5 Component Structure
|
||||
|
||||
```svelte
|
||||
<script>
|
||||
@ -50,7 +59,7 @@ pnpm run prepare # Sync SvelteKit
|
||||
import { User } from 'lucide-svelte';
|
||||
|
||||
// 2. Props with $bindable
|
||||
let { open = $bindable(false), title = '', children } = $props();
|
||||
let { open = $bindable(false), title = '', children, footer } = $props();
|
||||
|
||||
// 3. State
|
||||
let loading = $state(false);
|
||||
@ -74,6 +83,7 @@ pnpm run prepare # Sync SvelteKit
|
||||
import { get, post, put, patch, del } from '$lib/api/client.js';
|
||||
|
||||
// Feature endpoints (with JSDoc)
|
||||
/** @param {Object} params - Query parameters */
|
||||
export async function fetchItems(params = {}) {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return get(query ? `/api/items?${query}` : '/api/items');
|
||||
@ -104,13 +114,13 @@ function createStore() {
|
||||
return JSON.parse(localStorage.getItem('key'));
|
||||
};
|
||||
|
||||
const { subscribe, set } = writable(getInitialState());
|
||||
const { subscribe, set, update } = writable(getInitialState());
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
setData: (data) => {
|
||||
if (browser) localStorage.setItem('key', JSON.stringify(data));
|
||||
set(data);
|
||||
set({ data });
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -118,42 +128,23 @@ function createStore() {
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Modal with Snippets
|
||||
|
||||
```svelte
|
||||
<!-- Modal with $bindable props -->
|
||||
<Modal bind:open={showModal} title="Edit" size="lg">
|
||||
<Modal bind:open={showModal} title="Edit Item" size="lg">
|
||||
{#snippet children()}
|
||||
<form>...</form>
|
||||
<form onsubmit={handleSubmit}>
|
||||
<input class="input input-bordered" bind:value={formData.name} />
|
||||
</form>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<button class="btn btn-primary">Save</button>
|
||||
<button class="btn btn-ghost" onclick={() => showModal = false}>Cancel</button>
|
||||
<button class="btn btn-primary" onclick={handleSubmit}>Save</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<!-- Dialog with backdrop -->
|
||||
<dialog class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop" onclick={handleBackdropClick}>
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```javascript
|
||||
try {
|
||||
const result = await api.fetchData();
|
||||
success('Operation successful');
|
||||
} catch (err) {
|
||||
const message = err.message || 'An unexpected error occurred';
|
||||
error(message);
|
||||
console.error('Operation failed:', err);
|
||||
}
|
||||
```
|
||||
|
||||
## Form Patterns
|
||||
### Form with Validation
|
||||
|
||||
```javascript
|
||||
let formLoading = $state(false);
|
||||
@ -174,7 +165,7 @@ async function handleSubmit() {
|
||||
formLoading = true;
|
||||
try {
|
||||
await api.submit(formData);
|
||||
success('Success');
|
||||
toastSuccess('Success');
|
||||
} catch (err) {
|
||||
formError = err.message;
|
||||
} finally {
|
||||
@ -183,12 +174,43 @@ async function handleSubmit() {
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```javascript
|
||||
try {
|
||||
const result = await api.fetchData();
|
||||
toastSuccess('Operation successful');
|
||||
} catch (err) {
|
||||
const message = err.message || 'An unexpected error occurred';
|
||||
toastError(message);
|
||||
console.error('Operation failed:', err);
|
||||
}
|
||||
```
|
||||
|
||||
## Styling (DaisyUI + Tailwind)
|
||||
|
||||
- **Components**: `btn`, `card`, `alert`, `input`, `navbar`, `modal`, `dropdown`
|
||||
- **Components**: `btn`, `card`, `alert`, `input`, `navbar`, `modal`, `dropdown`, `menu`
|
||||
- **Colors**: `primary` (emerald), `secondary` (dark blue), `accent` (royal blue)
|
||||
- **Sizes**: `btn-sm`, `input-sm`, `select-sm` for compact forms
|
||||
- **Custom**: `.compact-y`, `.compact-p`, `.compact-input`
|
||||
- **Custom**: `.compact-y`, `.compact-p`, `.compact-input`, `.compact-btn`, `.compact-card`
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
lib/
|
||||
api/ # API clients per feature
|
||||
stores/ # Svelte stores (auth, config, valuesets)
|
||||
components/ # Reusable components (Modal, DataTable, Sidebar)
|
||||
utils/ # Utilities (toast, helpers)
|
||||
types/ # TypeScript type definitions
|
||||
routes/ # SvelteKit routes
|
||||
(app)/ # Route groups (protected)
|
||||
dashboard/
|
||||
patients/
|
||||
master-data/
|
||||
login/ # Public routes
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
@ -199,22 +221,7 @@ async function handleSubmit() {
|
||||
## LocalStorage
|
||||
|
||||
- Only access in browser: check `browser` from `$app/environment`
|
||||
- Use descriptive keys: `clqms_username`, `auth_token`
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
lib/
|
||||
api/ # API clients per feature
|
||||
stores/ # Svelte stores
|
||||
components/ # Reusable components
|
||||
utils/ # Utilities (toast, helpers)
|
||||
routes/ # SvelteKit routes
|
||||
(app)/ # Route groups (protected)
|
||||
login/
|
||||
dashboard/
|
||||
```
|
||||
- Use descriptive keys: `clqms_username`, `clqms_remember`, `auth_token`
|
||||
|
||||
## Important Notes
|
||||
|
||||
|
||||
155
COMPONENT_ORGANIZATION.md
Normal file
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
|
||||
- name: Demo
|
||||
description: Demo/test endpoints (no authentication)
|
||||
- name: EquipmentList
|
||||
description: Laboratory equipment and instrument management
|
||||
paths:
|
||||
/api/auth/login:
|
||||
post:
|
||||
@ -594,6 +596,225 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: Status logged
|
||||
/api/equipmentlist:
|
||||
get:
|
||||
tags:
|
||||
- EquipmentList
|
||||
summary: List equipment
|
||||
description: Get list of equipment with optional filters
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: IEID
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Filter by IEID
|
||||
- name: InstrumentName
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Filter by instrument name
|
||||
- name: DepartmentID
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
description: Filter by department ID
|
||||
- name: WorkstationID
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
description: Filter by workstation ID
|
||||
- name: Enable
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
enum:
|
||||
- 0
|
||||
- 1
|
||||
description: Filter by enable status
|
||||
responses:
|
||||
'200':
|
||||
description: List of equipment
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/EquipmentList'
|
||||
post:
|
||||
tags:
|
||||
- EquipmentList
|
||||
summary: Create equipment
|
||||
description: Create a new equipment entry
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- IEID
|
||||
- DepartmentID
|
||||
- Enable
|
||||
- EquipmentRole
|
||||
properties:
|
||||
IEID:
|
||||
type: string
|
||||
maxLength: 50
|
||||
DepartmentID:
|
||||
type: integer
|
||||
InstrumentID:
|
||||
type: string
|
||||
maxLength: 150
|
||||
InstrumentName:
|
||||
type: string
|
||||
maxLength: 150
|
||||
WorkstationID:
|
||||
type: integer
|
||||
Enable:
|
||||
type: integer
|
||||
enum:
|
||||
- 0
|
||||
- 1
|
||||
EquipmentRole:
|
||||
type: string
|
||||
maxLength: 1
|
||||
responses:
|
||||
'201':
|
||||
description: Equipment created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
type: integer
|
||||
patch:
|
||||
tags:
|
||||
- EquipmentList
|
||||
summary: Update equipment
|
||||
description: Update an existing equipment entry
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- EID
|
||||
properties:
|
||||
EID:
|
||||
type: integer
|
||||
IEID:
|
||||
type: string
|
||||
maxLength: 50
|
||||
DepartmentID:
|
||||
type: integer
|
||||
InstrumentID:
|
||||
type: string
|
||||
maxLength: 150
|
||||
InstrumentName:
|
||||
type: string
|
||||
maxLength: 150
|
||||
WorkstationID:
|
||||
type: integer
|
||||
Enable:
|
||||
type: integer
|
||||
enum:
|
||||
- 0
|
||||
- 1
|
||||
EquipmentRole:
|
||||
type: string
|
||||
maxLength: 1
|
||||
responses:
|
||||
'200':
|
||||
description: Equipment updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
type: integer
|
||||
delete:
|
||||
tags:
|
||||
- EquipmentList
|
||||
summary: Delete equipment
|
||||
description: Soft delete an equipment entry
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- EID
|
||||
properties:
|
||||
EID:
|
||||
type: integer
|
||||
responses:
|
||||
'200':
|
||||
description: Equipment deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
/api/equipmentlist/{id}:
|
||||
get:
|
||||
tags:
|
||||
- EquipmentList
|
||||
summary: Get equipment by ID
|
||||
description: Get a single equipment entry by its EID
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: Equipment ID
|
||||
responses:
|
||||
'200':
|
||||
description: Equipment details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/EquipmentList'
|
||||
/api/location:
|
||||
get:
|
||||
tags:
|
||||
@ -2556,6 +2777,326 @@ paths:
|
||||
responses:
|
||||
'200':
|
||||
description: Collection method details
|
||||
/api/test/testmap:
|
||||
get:
|
||||
tags:
|
||||
- Tests
|
||||
summary: List all test mappings
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 1
|
||||
description: Page number for pagination
|
||||
- name: perPage
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
default: 20
|
||||
description: Number of items per page
|
||||
responses:
|
||||
'200':
|
||||
description: List of test mappings
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: success
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TestMap'
|
||||
post:
|
||||
tags:
|
||||
- Tests
|
||||
summary: Create test mapping
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
TestSiteID:
|
||||
type: integer
|
||||
description: Test Site ID (required)
|
||||
HostType:
|
||||
type: string
|
||||
description: Host type code
|
||||
HostID:
|
||||
type: string
|
||||
description: Host identifier
|
||||
HostTestCode:
|
||||
type: string
|
||||
description: Test code in host system
|
||||
HostTestName:
|
||||
type: string
|
||||
description: Test name in host system
|
||||
ClientType:
|
||||
type: string
|
||||
description: Client type code
|
||||
ClientID:
|
||||
type: string
|
||||
description: Client identifier
|
||||
ConDefID:
|
||||
type: integer
|
||||
description: Connection definition ID
|
||||
ClientTestCode:
|
||||
type: string
|
||||
description: Test code in client system
|
||||
ClientTestName:
|
||||
type: string
|
||||
description: Test name in client system
|
||||
required:
|
||||
- TestSiteID
|
||||
responses:
|
||||
'201':
|
||||
description: Test mapping created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: success
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
type: integer
|
||||
description: Created TestMapID
|
||||
patch:
|
||||
tags:
|
||||
- Tests
|
||||
summary: Update test mapping
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
TestMapID:
|
||||
type: integer
|
||||
description: Test Map ID (required)
|
||||
TestSiteID:
|
||||
type: integer
|
||||
HostType:
|
||||
type: string
|
||||
HostID:
|
||||
type: string
|
||||
HostTestCode:
|
||||
type: string
|
||||
HostTestName:
|
||||
type: string
|
||||
ClientType:
|
||||
type: string
|
||||
ClientID:
|
||||
type: string
|
||||
ConDefID:
|
||||
type: integer
|
||||
ClientTestCode:
|
||||
type: string
|
||||
ClientTestName:
|
||||
type: string
|
||||
required:
|
||||
- TestMapID
|
||||
responses:
|
||||
'200':
|
||||
description: Test mapping updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: success
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
type: integer
|
||||
description: Updated TestMapID
|
||||
delete:
|
||||
tags:
|
||||
- Tests
|
||||
summary: Soft delete test mapping
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
TestMapID:
|
||||
type: integer
|
||||
description: Test Map ID to delete (required)
|
||||
required:
|
||||
- TestMapID
|
||||
responses:
|
||||
'200':
|
||||
description: Test mapping deleted successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: success
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
type: integer
|
||||
description: Deleted TestMapID
|
||||
'404':
|
||||
description: Test mapping not found or already deleted
|
||||
/api/test/testmap/{id}:
|
||||
get:
|
||||
tags:
|
||||
- Tests
|
||||
summary: Get test mapping by ID
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: Test Map ID
|
||||
responses:
|
||||
'200':
|
||||
description: Test mapping details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
$ref: '#/components/schemas/TestMap'
|
||||
'404':
|
||||
description: Test mapping not found
|
||||
/api/test/testmap/by-testsite/{testSiteID}:
|
||||
get:
|
||||
tags:
|
||||
- Tests
|
||||
summary: Get test mappings by test site
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: testSiteID
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
description: Test Site ID
|
||||
responses:
|
||||
'200':
|
||||
description: List of test mappings for the test site
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TestMap'
|
||||
/api/test/testmap/by-host/{hostType}/{hostID}:
|
||||
get:
|
||||
tags:
|
||||
- Tests
|
||||
summary: Get test mappings by host
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: hostType
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Host type code
|
||||
- name: hostID
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Host identifier
|
||||
responses:
|
||||
'200':
|
||||
description: List of test mappings for the host
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TestMap'
|
||||
/api/test/testmap/by-client/{clientType}/{clientID}:
|
||||
get:
|
||||
tags:
|
||||
- Tests
|
||||
summary: Get test mappings by client
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
- name: clientType
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Client type code
|
||||
- name: clientID
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Client identifier
|
||||
responses:
|
||||
'200':
|
||||
description: List of test mappings for the client
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TestMap'
|
||||
/api/tests:
|
||||
get:
|
||||
tags:
|
||||
@ -2674,14 +3215,18 @@ paths:
|
||||
type: string
|
||||
enum:
|
||||
- NMRIC
|
||||
- RANGE
|
||||
- TEXT
|
||||
- VSET
|
||||
- NORES
|
||||
RefType:
|
||||
type: string
|
||||
enum:
|
||||
- NMRC
|
||||
- TEXT
|
||||
- RANGE
|
||||
- THOLD
|
||||
- VSET
|
||||
- TEXT
|
||||
- NOREF
|
||||
VSet:
|
||||
type: integer
|
||||
ReqQty:
|
||||
@ -2794,14 +3339,18 @@ paths:
|
||||
type: string
|
||||
enum:
|
||||
- NMRIC
|
||||
- RANGE
|
||||
- TEXT
|
||||
- VSET
|
||||
- NORES
|
||||
RefType:
|
||||
type: string
|
||||
enum:
|
||||
- NMRC
|
||||
- TEXT
|
||||
- RANGE
|
||||
- THOLD
|
||||
- VSET
|
||||
- TEXT
|
||||
- NOREF
|
||||
VSet:
|
||||
type: integer
|
||||
ReqQty:
|
||||
@ -2955,7 +3504,7 @@ paths:
|
||||
tags:
|
||||
- ValueSets
|
||||
summary: List lib value sets
|
||||
description: List all library/system value sets from JSON files with item counts. Returns an object where keys are value set names and values are item counts.
|
||||
description: List all library/system value sets from JSON files with item counts. Returns an array of objects with value, label, and count properties.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
parameters:
|
||||
@ -2963,7 +3512,7 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: Optional search term to filter value set names
|
||||
description: Optional search term to filter value set names or labels
|
||||
responses:
|
||||
'200':
|
||||
description: List of lib value sets with item counts
|
||||
@ -2976,14 +3525,19 @@ paths:
|
||||
type: string
|
||||
example: success
|
||||
data:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: integer
|
||||
description: Number of items in each value set
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ValueSetListItem'
|
||||
example:
|
||||
sex: 3
|
||||
marital_status: 6
|
||||
order_status: 6
|
||||
- value: sex
|
||||
label: Sex
|
||||
count: 3
|
||||
- value: marital_status
|
||||
label: Marital Status
|
||||
count: 6
|
||||
- value: order_status
|
||||
label: Order Status
|
||||
count: 6
|
||||
/api/valueset/{key}:
|
||||
get:
|
||||
tags:
|
||||
@ -4139,22 +4693,46 @@ components:
|
||||
type: string
|
||||
enum:
|
||||
- NMRIC
|
||||
- RANGE
|
||||
- TEXT
|
||||
- VSET
|
||||
- NORES
|
||||
description: |
|
||||
NMRIC: Numeric result
|
||||
VSET: Value set result
|
||||
Result type determines the format of test results:
|
||||
- NMRIC: Single numeric value
|
||||
- RANGE: Numeric range (min-max)
|
||||
- TEXT: Free text result
|
||||
- VSET: Value set/enum result
|
||||
- NORES: No result (for GROUP and TITLE types)
|
||||
|
||||
TestType to ResultType mapping:
|
||||
- TEST: NMRIC | RANGE | TEXT | VSET
|
||||
- PARAM: NMRIC | RANGE | TEXT | VSET
|
||||
- CALC: NMRIC (calculated result is always numeric)
|
||||
- GROUP: NORES (no result, container only)
|
||||
- TITLE: NORES (no result, header only)
|
||||
RefType:
|
||||
type: string
|
||||
enum:
|
||||
- NMRC
|
||||
- TEXT
|
||||
- RANGE
|
||||
- THOLD
|
||||
- VSET
|
||||
- TEXT
|
||||
- NOREF
|
||||
description: |
|
||||
NMRC: Numeric reference range
|
||||
TEXT: Text reference
|
||||
THOLD: Threshold reference
|
||||
VSET: Value set reference
|
||||
Reference type determines which reference range table to use:
|
||||
- RANGE: Numeric reference range
|
||||
- THOLD: Threshold/panic range
|
||||
- VSET: Value set reference
|
||||
- TEXT: Free text reference
|
||||
- NOREF: No reference (for NORES result type)
|
||||
|
||||
ResultType to RefType mapping:
|
||||
- NMRIC: RANGE | THOLD → refnum table
|
||||
- RANGE: RANGE | THOLD → refnum table
|
||||
- VSET: VSET → reftxt table
|
||||
- TEXT: TEXT → reftxt table
|
||||
- NORES: NOREF → (no reference table)
|
||||
VSet:
|
||||
type: integer
|
||||
description: Value set ID for VSET result type
|
||||
@ -4530,9 +5108,6 @@ components:
|
||||
HostID:
|
||||
type: string
|
||||
description: Host identifier
|
||||
HostDataSource:
|
||||
type: string
|
||||
description: Host data source
|
||||
HostTestCode:
|
||||
type: string
|
||||
description: Test code in host system
|
||||
@ -4545,9 +5120,6 @@ components:
|
||||
ClientID:
|
||||
type: string
|
||||
description: Client identifier
|
||||
ClientDataSource:
|
||||
type: string
|
||||
description: Client data source
|
||||
ConDefID:
|
||||
type: integer
|
||||
description: Connection definition ID
|
||||
@ -4812,6 +5384,55 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
EquipmentList:
|
||||
type: object
|
||||
properties:
|
||||
EID:
|
||||
type: integer
|
||||
description: Equipment ID (auto-increment)
|
||||
IEID:
|
||||
type: string
|
||||
maxLength: 50
|
||||
description: Internal Equipment ID
|
||||
DepartmentID:
|
||||
type: integer
|
||||
description: Reference to department
|
||||
InstrumentID:
|
||||
type: string
|
||||
maxLength: 150
|
||||
description: Instrument identifier
|
||||
InstrumentName:
|
||||
type: string
|
||||
maxLength: 150
|
||||
description: Instrument display name
|
||||
WorkstationID:
|
||||
type: integer
|
||||
description: Reference to workstation
|
||||
Enable:
|
||||
type: integer
|
||||
enum:
|
||||
- 0
|
||||
- 1
|
||||
description: Equipment status (0=disabled, 1=enabled)
|
||||
EquipmentRole:
|
||||
type: string
|
||||
maxLength: 1
|
||||
description: Equipment role code
|
||||
CreateDate:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Creation timestamp
|
||||
EndDate:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: Deletion timestamp (soft delete)
|
||||
DepartmentName:
|
||||
type: string
|
||||
description: Joined department name
|
||||
WorkstationName:
|
||||
type: string
|
||||
description: Joined workstation name
|
||||
Contact:
|
||||
type: object
|
||||
properties:
|
||||
@ -4864,3 +5485,16 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Occupation display text
|
||||
ValueSetListItem:
|
||||
type: object
|
||||
description: Library/system value set summary (from JSON files)
|
||||
properties:
|
||||
value:
|
||||
type: string
|
||||
description: The value set key/name
|
||||
label:
|
||||
type: string
|
||||
description: The display name/label
|
||||
count:
|
||||
type: integer
|
||||
description: Number of items in this value set
|
||||
|
||||
86
src/lib/api/equipment.js
Normal file
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,
|
||||
FileText,
|
||||
X,
|
||||
Link
|
||||
Link,
|
||||
LandPlot,
|
||||
Monitor,
|
||||
Activity,
|
||||
User
|
||||
} from 'lucide-svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { goto } from '$app/navigation';
|
||||
@ -31,6 +35,7 @@ import {
|
||||
// Collapsible section states - default collapsed
|
||||
let laboratoryExpanded = $state(false);
|
||||
let masterDataExpanded = $state(false);
|
||||
let organizationExpanded = $state(false);
|
||||
|
||||
// Load states from localStorage on mount
|
||||
$effect(() => {
|
||||
@ -41,6 +46,7 @@ import {
|
||||
const parsed = JSON.parse(savedStates);
|
||||
laboratoryExpanded = parsed.laboratory ?? false;
|
||||
masterDataExpanded = parsed.masterData ?? false;
|
||||
organizationExpanded = parsed.organization ?? false;
|
||||
} catch (e) {
|
||||
// Keep defaults if parsing fails
|
||||
}
|
||||
@ -53,7 +59,8 @@ import {
|
||||
if (browser) {
|
||||
localStorage.setItem('sidebar_section_states', JSON.stringify({
|
||||
laboratory: laboratoryExpanded,
|
||||
masterData: masterDataExpanded
|
||||
masterData: masterDataExpanded,
|
||||
organization: organizationExpanded
|
||||
}));
|
||||
}
|
||||
});
|
||||
@ -63,6 +70,7 @@ import {
|
||||
if (!isOpen) {
|
||||
laboratoryExpanded = false;
|
||||
masterDataExpanded = false;
|
||||
organizationExpanded = false;
|
||||
}
|
||||
});
|
||||
|
||||
@ -94,6 +102,13 @@ function toggleLaboratory() {
|
||||
}
|
||||
masterDataExpanded = !masterDataExpanded;
|
||||
}
|
||||
|
||||
function toggleOrganization() {
|
||||
if (!isOpen) {
|
||||
expandSidebar();
|
||||
}
|
||||
organizationExpanded = !organizationExpanded;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile Overlay Backdrop -->
|
||||
@ -213,7 +228,26 @@ function toggleLaboratory() {
|
||||
|
||||
{#if isOpen && masterDataExpanded}
|
||||
<ul class="submenu">
|
||||
<li><a href="/master-data/organization" class="submenu-link"><Building2 size={16} /> Organization</a></li>
|
||||
<!-- Organization with nested submenu -->
|
||||
<li class="nav-group">
|
||||
<button
|
||||
onclick={toggleOrganization}
|
||||
class="submenu-link"
|
||||
>
|
||||
<Building2 size={16} /> Organization
|
||||
<ChevronDown size={14} class="chevron ml-auto {organizationExpanded ? 'expanded' : ''}" />
|
||||
</button>
|
||||
{#if organizationExpanded}
|
||||
<ul class="submenu nested">
|
||||
<li><a href="/master-data/organization/account" class="submenu-link"><User size={14} /> Account</a></li>
|
||||
<li><a href="/master-data/organization/site" class="submenu-link"><LandPlot size={14} /> Site</a></li>
|
||||
<li><a href="/master-data/organization/department" class="submenu-link"><Users size={14} /> Department</a></li>
|
||||
<li><a href="/master-data/organization/discipline" class="submenu-link"><Building2 size={14} /> Discipline</a></li>
|
||||
<li><a href="/master-data/organization/workstation" class="submenu-link"><Monitor size={14} /> Workstation</a></li>
|
||||
<li><a href="/master-data/organization/instrument" class="submenu-link"><Activity size={14} /> Instrument</a></li>
|
||||
</ul>
|
||||
{/if}
|
||||
</li>
|
||||
<li><a href="/master-data/containers" class="submenu-link"><FlaskConical size={16} /> Containers</a></li>
|
||||
<li><a href="/master-data/tests" class="submenu-link"><TestTube size={16} /> Test Definitions</a></li>
|
||||
<li><a href="/master-data/testmap" class="submenu-link"><Link size={16} /> Test Mapping</a></li>
|
||||
@ -364,7 +398,7 @@ function toggleLaboratory() {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.submenu {
|
||||
.submenu {
|
||||
margin-left: 1.5rem;
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
@ -373,6 +407,13 @@ function toggleLaboratory() {
|
||||
animation: slideDown 0.2s ease-out;
|
||||
}
|
||||
|
||||
.submenu.nested {
|
||||
margin-left: 1rem;
|
||||
margin-top: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 1px solid hsl(var(--bc) / 0.1);
|
||||
}
|
||||
|
||||
.submenu-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@ -9,18 +9,10 @@ import {
|
||||
Globe,
|
||||
ChevronRight,
|
||||
FlaskConical,
|
||||
TestTube,
|
||||
Building2
|
||||
TestTube
|
||||
} from 'lucide-svelte';
|
||||
|
||||
const modules = [
|
||||
{
|
||||
title: 'Organization',
|
||||
description: 'Manage disciplines and departments structure',
|
||||
icon: Building2,
|
||||
href: '/master-data/organization',
|
||||
color: 'bg-indigo-500',
|
||||
},
|
||||
const modules = [
|
||||
{
|
||||
title: 'Containers',
|
||||
description: 'Manage specimen containers and tubes',
|
||||
|
||||
@ -183,22 +183,24 @@
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="mb-4">
|
||||
<div class="relative max-w-md">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full pl-10"
|
||||
placeholder="Search by name or initial..."
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
|
||||
onclick={() => searchQuery = ''}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
<div class="max-w-md">
|
||||
<label class="input input-sm input-bordered w-full flex items-center gap-2">
|
||||
<Search class="w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow"
|
||||
placeholder="Search by name or initial..."
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
class="btn btn-ghost btn-xs btn-circle"
|
||||
onclick={() => searchQuery = ''}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -164,16 +164,16 @@
|
||||
<!-- Search and Filter -->
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="flex-1 relative">
|
||||
<Search class="w-5 h-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
|
||||
<label class="input input-bordered flex-1 flex items-center gap-2">
|
||||
<Search class="w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by code or name..."
|
||||
class="input input-bordered w-full pl-10"
|
||||
class="grow"
|
||||
bind:value={searchQuery}
|
||||
onkeydown={handleSearchKeydown}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<button class="btn btn-outline" onclick={handleFilter}>
|
||||
<Filter class="w-4 h-4 mr-2" />
|
||||
Filter
|
||||
|
||||
@ -228,24 +228,24 @@
|
||||
<!-- Search Bar -->
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="form-control flex-1 max-w-md">
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-base-content/50" />
|
||||
<label class="input input-sm input-bordered w-full flex items-center gap-2">
|
||||
<Search class="w-4 h-4 text-base-content/50" />
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full pl-10"
|
||||
class="grow"
|
||||
placeholder="Search by counter description..."
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
|
||||
class="btn btn-ghost btn-xs btn-circle"
|
||||
onclick={() => searchQuery = ''}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{#if searchQuery}
|
||||
<span class="text-sm text-base-content/70">
|
||||
|
||||
@ -259,15 +259,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<label class="input input-sm input-bordered w-full flex items-center gap-2">
|
||||
<Search class="w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search provinces by name..."
|
||||
class="input input-sm input-bordered w-full pl-10"
|
||||
class="grow"
|
||||
bind:value={provinceSearch}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||
{#if loading}
|
||||
@ -342,18 +342,18 @@
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<label class="input input-sm input-bordered w-full flex items-center gap-2">
|
||||
<Search class="w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={selectedProvince
|
||||
? `Search cities in ${selectedProvinceLabel}...`
|
||||
: "Search cities by name..."
|
||||
}
|
||||
class="input input-sm input-bordered w-full pl-10"
|
||||
class="grow"
|
||||
bind:value={citySearch}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||
{#if loading}
|
||||
@ -407,15 +407,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="relative">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<label class="input input-sm input-bordered w-full flex items-center gap-2">
|
||||
<Search class="w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search areas by name, code, or class..."
|
||||
class="input input-sm input-bordered w-full pl-10"
|
||||
class="grow"
|
||||
bind:value={areaSearch}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||
{#if loading}
|
||||
|
||||
@ -181,15 +181,15 @@
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||
<div class="p-4 border-b border-base-200">
|
||||
<div class="flex items-center gap-3 max-w-md">
|
||||
<div class="relative flex-1">
|
||||
<Search class="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
<label class="input input-sm input-bordered w-full flex items-center gap-2">
|
||||
<Search class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full pl-10"
|
||||
class="grow"
|
||||
placeholder="Search by code or name..."
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
{#if searchQuery}
|
||||
<button class="btn btn-ghost btn-sm" onclick={() => (searchQuery = '')}>
|
||||
Clear
|
||||
|
||||
@ -172,16 +172,16 @@
|
||||
<!-- Search Section -->
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="flex-1 relative">
|
||||
<Search class="w-5 h-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
|
||||
<label class="input input-sm input-bordered flex-1 flex items-center gap-2">
|
||||
<Search class="w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by code or occupation name..."
|
||||
class="input input-sm input-bordered w-full pl-10"
|
||||
class="grow"
|
||||
bind:value={searchQuery}
|
||||
onkeydown={handleSearchKeydown}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,674 +1,93 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
fetchDisciplines,
|
||||
createDiscipline,
|
||||
updateDiscipline,
|
||||
deleteDiscipline,
|
||||
fetchDepartments,
|
||||
createDepartment,
|
||||
updateDepartment,
|
||||
deleteDepartment,
|
||||
} from '$lib/api/organization.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { Plus, Edit2, Trash2, ArrowLeft, Search, Building2, Users } from 'lucide-svelte';
|
||||
ChevronRight,
|
||||
User,
|
||||
LandPlot,
|
||||
Users,
|
||||
Building2,
|
||||
Monitor,
|
||||
Activity
|
||||
} from 'lucide-svelte';
|
||||
import { ArrowLeft } from 'lucide-svelte';
|
||||
|
||||
// Active tab state
|
||||
let activeTab = $state('disciplines');
|
||||
|
||||
// Loading states
|
||||
let loadingDisciplines = $state(false);
|
||||
let loadingDepartments = $state(false);
|
||||
|
||||
// Data states
|
||||
let disciplines = $state([]);
|
||||
let departments = $state([]);
|
||||
|
||||
// Modal states - Disciplines
|
||||
let disciplineModalOpen = $state(false);
|
||||
let disciplineModalMode = $state('create');
|
||||
let disciplineFormData = $state({
|
||||
DisciplineID: null,
|
||||
DisciplineCode: '',
|
||||
DisciplineName: '',
|
||||
Parent: null,
|
||||
});
|
||||
let savingDiscipline = $state(false);
|
||||
let deleteDisciplineConfirmOpen = $state(false);
|
||||
let deleteDisciplineItem = $state(null);
|
||||
let deletingDiscipline = $state(false);
|
||||
|
||||
// Modal states - Departments
|
||||
let departmentModalOpen = $state(false);
|
||||
let departmentModalMode = $state('create');
|
||||
let departmentFormData = $state({
|
||||
DepartmentID: null,
|
||||
DepartmentCode: '',
|
||||
DepartmentName: '',
|
||||
DisciplineID: null,
|
||||
});
|
||||
let savingDepartment = $state(false);
|
||||
let deleteDepartmentConfirmOpen = $state(false);
|
||||
let deleteDepartmentItem = $state(null);
|
||||
let deletingDepartment = $state(false);
|
||||
|
||||
// Search states
|
||||
let disciplineSearch = $state('');
|
||||
let departmentSearch = $state('');
|
||||
|
||||
// Table columns
|
||||
const disciplineColumns = [
|
||||
{ key: 'DisciplineCode', label: 'Code', class: 'font-medium w-32' },
|
||||
{ key: 'DisciplineName', label: 'Name' },
|
||||
{ key: 'ParentName', label: 'Parent Discipline', class: 'w-48' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-24 text-center' },
|
||||
const modules = [
|
||||
{
|
||||
title: 'Account',
|
||||
description: 'Manage organization accounts',
|
||||
icon: User,
|
||||
href: '/master-data/organization/account',
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
title: 'Site',
|
||||
description: 'Manage organization sites and locations',
|
||||
icon: LandPlot,
|
||||
href: '/master-data/organization/site',
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
title: 'Department',
|
||||
description: 'Manage departments structure',
|
||||
icon: Users,
|
||||
href: '/master-data/organization/department',
|
||||
color: 'bg-purple-500',
|
||||
},
|
||||
{
|
||||
title: 'Discipline',
|
||||
description: 'Manage laboratory disciplines',
|
||||
icon: Building2,
|
||||
href: '/master-data/organization/discipline',
|
||||
color: 'bg-indigo-500',
|
||||
},
|
||||
{
|
||||
title: 'Workstation',
|
||||
description: 'Manage workstation configurations',
|
||||
icon: Monitor,
|
||||
href: '/master-data/organization/workstation',
|
||||
color: 'bg-cyan-500',
|
||||
},
|
||||
{
|
||||
title: 'Instrument',
|
||||
description: 'Manage laboratory instruments',
|
||||
icon: Activity,
|
||||
href: '/master-data/organization/instrument',
|
||||
color: 'bg-orange-500',
|
||||
},
|
||||
];
|
||||
|
||||
const departmentColumns = [
|
||||
{ key: 'DepartmentCode', label: 'Code', class: 'font-medium w-32' },
|
||||
{ key: 'DepartmentName', label: 'Name' },
|
||||
{ key: 'DisciplineName', label: 'Discipline', class: 'w-48' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-24 text-center' },
|
||||
];
|
||||
|
||||
// Derived data with computed fields
|
||||
let disciplinesWithParentName = $derived(
|
||||
disciplines.map((d) => ({
|
||||
...d,
|
||||
ParentName: d.Parent
|
||||
? disciplines.find((p) => p.DisciplineID === d.Parent)?.DisciplineName || '-'
|
||||
: '-',
|
||||
}))
|
||||
);
|
||||
|
||||
let departmentsWithDisciplineName = $derived(
|
||||
departments.map((d) => ({
|
||||
...d,
|
||||
DisciplineName:
|
||||
disciplines.find((disc) => disc.DisciplineID === d.DisciplineID)?.DisciplineName || '-',
|
||||
}))
|
||||
);
|
||||
|
||||
// Filtered data
|
||||
let filteredDisciplines = $derived(
|
||||
disciplineSearch.trim()
|
||||
? disciplinesWithParentName.filter(
|
||||
(d) =>
|
||||
d.DisciplineCode?.toLowerCase().includes(disciplineSearch.toLowerCase()) ||
|
||||
d.DisciplineName?.toLowerCase().includes(disciplineSearch.toLowerCase())
|
||||
)
|
||||
: disciplinesWithParentName
|
||||
);
|
||||
|
||||
let filteredDepartments = $derived(
|
||||
departmentSearch.trim()
|
||||
? departmentsWithDisciplineName.filter(
|
||||
(d) =>
|
||||
d.DepartmentCode?.toLowerCase().includes(departmentSearch.toLowerCase()) ||
|
||||
d.DepartmentName?.toLowerCase().includes(departmentSearch.toLowerCase())
|
||||
)
|
||||
: departmentsWithDisciplineName
|
||||
);
|
||||
|
||||
onMount(async () => {
|
||||
await loadDisciplines();
|
||||
await loadDepartments();
|
||||
});
|
||||
|
||||
async function loadDisciplines() {
|
||||
loadingDisciplines = true;
|
||||
try {
|
||||
const response = await fetchDisciplines();
|
||||
disciplines = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load disciplines');
|
||||
disciplines = [];
|
||||
} finally {
|
||||
loadingDisciplines = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDepartments() {
|
||||
loadingDepartments = true;
|
||||
try {
|
||||
const response = await fetchDepartments();
|
||||
departments = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load departments');
|
||||
departments = [];
|
||||
} finally {
|
||||
loadingDepartments = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Discipline handlers
|
||||
function openCreateDisciplineModal() {
|
||||
disciplineModalMode = 'create';
|
||||
disciplineFormData = {
|
||||
DisciplineID: null,
|
||||
DisciplineCode: '',
|
||||
DisciplineName: '',
|
||||
Parent: null,
|
||||
};
|
||||
disciplineModalOpen = true;
|
||||
}
|
||||
|
||||
function openEditDisciplineModal(row) {
|
||||
disciplineModalMode = 'edit';
|
||||
disciplineFormData = {
|
||||
DisciplineID: row.DisciplineID,
|
||||
DisciplineCode: row.DisciplineCode,
|
||||
DisciplineName: row.DisciplineName,
|
||||
Parent: row.Parent || null,
|
||||
};
|
||||
disciplineModalOpen = true;
|
||||
}
|
||||
|
||||
async function handleSaveDiscipline() {
|
||||
savingDiscipline = true;
|
||||
try {
|
||||
if (disciplineModalMode === 'create') {
|
||||
await createDiscipline(disciplineFormData);
|
||||
toastSuccess('Discipline created successfully');
|
||||
} else {
|
||||
await updateDiscipline(disciplineFormData);
|
||||
toastSuccess('Discipline updated successfully');
|
||||
}
|
||||
disciplineModalOpen = false;
|
||||
await loadDisciplines();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to save discipline');
|
||||
} finally {
|
||||
savingDiscipline = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteDiscipline(row) {
|
||||
deleteDisciplineItem = row;
|
||||
deleteDisciplineConfirmOpen = true;
|
||||
}
|
||||
|
||||
async function handleDeleteDiscipline() {
|
||||
deletingDiscipline = true;
|
||||
try {
|
||||
await deleteDiscipline(deleteDisciplineItem.DisciplineID);
|
||||
toastSuccess('Discipline deleted successfully');
|
||||
deleteDisciplineConfirmOpen = false;
|
||||
deleteDisciplineItem = null;
|
||||
await loadDisciplines();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to delete discipline');
|
||||
} finally {
|
||||
deletingDiscipline = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Department handlers
|
||||
function openCreateDepartmentModal() {
|
||||
departmentModalMode = 'create';
|
||||
departmentFormData = {
|
||||
DepartmentID: null,
|
||||
DepartmentCode: '',
|
||||
DepartmentName: '',
|
||||
DisciplineID: null,
|
||||
};
|
||||
departmentModalOpen = true;
|
||||
}
|
||||
|
||||
function openEditDepartmentModal(row) {
|
||||
departmentModalMode = 'edit';
|
||||
departmentFormData = {
|
||||
DepartmentID: row.DepartmentID,
|
||||
DepartmentCode: row.DepartmentCode,
|
||||
DepartmentName: row.DepartmentName,
|
||||
DisciplineID: row.DisciplineID,
|
||||
};
|
||||
departmentModalOpen = true;
|
||||
}
|
||||
|
||||
async function handleSaveDepartment() {
|
||||
savingDepartment = true;
|
||||
try {
|
||||
if (departmentModalMode === 'create') {
|
||||
await createDepartment(departmentFormData);
|
||||
toastSuccess('Department created successfully');
|
||||
} else {
|
||||
await updateDepartment(departmentFormData);
|
||||
toastSuccess('Department updated successfully');
|
||||
}
|
||||
departmentModalOpen = false;
|
||||
await loadDepartments();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to save department');
|
||||
} finally {
|
||||
savingDepartment = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteDepartment(row) {
|
||||
deleteDepartmentItem = row;
|
||||
deleteDepartmentConfirmOpen = true;
|
||||
}
|
||||
|
||||
async function handleDeleteDepartment() {
|
||||
deletingDepartment = true;
|
||||
try {
|
||||
await deleteDepartment(deleteDepartmentItem.DepartmentID);
|
||||
toastSuccess('Department deleted successfully');
|
||||
deleteDepartmentConfirmOpen = false;
|
||||
deleteDepartmentItem = null;
|
||||
await loadDepartments();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to delete department');
|
||||
} finally {
|
||||
deletingDepartment = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href="/master-data" class="btn btn-ghost btn-circle">
|
||||
<ArrowLeft class="w-5 h-5" />
|
||||
</a>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-xl font-bold text-gray-800">Organization Structure</h1>
|
||||
<p class="text-sm text-gray-600">Manage disciplines and departments</p>
|
||||
<h1 class="text-xl font-bold text-gray-800">Organization</h1>
|
||||
<p class="text-sm text-gray-600">Manage organizational structure and resources</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs tabs-boxed bg-base-200 mb-4">
|
||||
<button
|
||||
class="tab gap-2 {activeTab === 'disciplines' ? 'tab-active' : ''}"
|
||||
onclick={() => (activeTab = 'disciplines')}
|
||||
>
|
||||
<Building2 class="w-4 h-4" />
|
||||
Disciplines
|
||||
<span class="badge badge-sm">{disciplines.length}</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab gap-2 {activeTab === 'departments' ? 'tab-active' : ''}"
|
||||
onclick={() => (activeTab = 'departments')}
|
||||
>
|
||||
<Users class="w-4 h-4" />
|
||||
Departments
|
||||
<span class="badge badge-sm">{departments.length}</span>
|
||||
</button>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each modules as module}
|
||||
<a
|
||||
href={module.href}
|
||||
class="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow border border-base-200 group"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="{module.color} text-white p-3 rounded-lg">
|
||||
<svelte:component this={module.icon} class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="card-title text-lg group-hover:text-primary transition-colors">
|
||||
{module.title}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 mt-1">{module.description}</p>
|
||||
</div>
|
||||
<ChevronRight class="w-5 h-5 text-gray-400 group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Disciplines Tab -->
|
||||
{#if activeTab === 'disciplines'}
|
||||
<div class="space-y-4">
|
||||
<!-- Search and Add -->
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="flex-1 relative">
|
||||
<Search class="w-5 h-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search disciplines by code or name..."
|
||||
class="input input-bordered w-full pl-10"
|
||||
bind:value={disciplineSearch}
|
||||
/>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick={openCreateDisciplineModal}>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add Discipline
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Disciplines Table -->
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||
<DataTable
|
||||
columns={disciplineColumns}
|
||||
data={filteredDisciplines}
|
||||
loading={loadingDisciplines}
|
||||
emptyMessage="No disciplines found"
|
||||
hover={true}
|
||||
bordered={false}
|
||||
>
|
||||
{#snippet cell({ column, row })}
|
||||
{#if column.key === 'actions'}
|
||||
<div class="flex justify-center gap-2">
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
onclick={() => openEditDisciplineModal(row)}
|
||||
title="Edit discipline"
|
||||
>
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost text-error"
|
||||
onclick={() => confirmDeleteDiscipline(row)}
|
||||
title="Delete discipline"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{row[column.key] || '-'}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Departments Tab -->
|
||||
{#if activeTab === 'departments'}
|
||||
<div class="space-y-4">
|
||||
<!-- Search and Add -->
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="flex-1 relative">
|
||||
<Search class="w-5 h-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search departments by code or name..."
|
||||
class="input input-bordered w-full pl-10"
|
||||
bind:value={departmentSearch}
|
||||
/>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick={openCreateDepartmentModal}>
|
||||
<Plus class="w-4 h-4 mr-2" />
|
||||
Add Department
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Departments Table -->
|
||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||
<DataTable
|
||||
columns={departmentColumns}
|
||||
data={filteredDepartments}
|
||||
loading={loadingDepartments}
|
||||
emptyMessage="No departments found"
|
||||
hover={true}
|
||||
bordered={false}
|
||||
>
|
||||
{#snippet cell({ column, row })}
|
||||
{#if column.key === 'actions'}
|
||||
<div class="flex justify-center gap-2">
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
onclick={() => openEditDepartmentModal(row)}
|
||||
title="Edit department"
|
||||
>
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost text-error"
|
||||
onclick={() => confirmDeleteDepartment(row)}
|
||||
title="Delete department"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{row[column.key] || '-'}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Discipline Modal -->
|
||||
<Modal
|
||||
bind:open={disciplineModalOpen}
|
||||
title={disciplineModalMode === 'create' ? 'Add Discipline' : 'Edit Discipline'}
|
||||
size="md"
|
||||
>
|
||||
<form class="space-y-4" onsubmit={(e) => { e.preventDefault(); handleSaveDiscipline(); }}>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="disciplineCode">
|
||||
<span class="label-text text-sm font-medium">Discipline Code</span>
|
||||
<span class="label-text-alt text-xs text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="disciplineCode"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={disciplineFormData.DisciplineCode}
|
||||
placeholder="e.g., HEM, CHEM"
|
||||
required
|
||||
/>
|
||||
<span class="label-text-alt text-xs text-gray-500">Unique code for this discipline</span>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="disciplineName">
|
||||
<span class="label-text text-sm font-medium">Discipline Name</span>
|
||||
<span class="label-text-alt text-xs text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="disciplineName"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={disciplineFormData.DisciplineName}
|
||||
placeholder="e.g., Hematology"
|
||||
required
|
||||
/>
|
||||
<span class="label-text-alt text-xs text-gray-500">Display name</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="parentDiscipline">
|
||||
<span class="label-text text-sm font-medium">Parent Discipline</span>
|
||||
</label>
|
||||
<select
|
||||
id="parentDiscipline"
|
||||
class="select select-sm select-bordered w-full"
|
||||
bind:value={disciplineFormData.Parent}
|
||||
>
|
||||
<option value={null}>None (Top-level discipline)</option>
|
||||
{#each disciplines.filter((d) => d.DisciplineID !== disciplineFormData.DisciplineID) as discipline}
|
||||
<option value={discipline.DisciplineID}>{discipline.DisciplineName}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="label-text-alt text-xs text-gray-500">Optional parent for hierarchical structure</span>
|
||||
</div>
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
onclick={() => (disciplineModalOpen = false)}
|
||||
type="button"
|
||||
disabled={savingDiscipline}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={handleSaveDiscipline}
|
||||
disabled={savingDiscipline}
|
||||
type="button"
|
||||
>
|
||||
{#if savingDiscipline}
|
||||
<span class="loading loading-spinner loading-sm mr-2"></span>
|
||||
{/if}
|
||||
{savingDiscipline ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<!-- Department Modal -->
|
||||
<Modal
|
||||
bind:open={departmentModalOpen}
|
||||
title={departmentModalMode === 'create' ? 'Add Department' : 'Edit Department'}
|
||||
size="md"
|
||||
>
|
||||
<form class="space-y-4" onsubmit={(e) => { e.preventDefault(); handleSaveDepartment(); }}>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="departmentCode">
|
||||
<span class="label-text text-sm font-medium">Department Code</span>
|
||||
<span class="label-text-alt text-xs text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="departmentCode"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={departmentFormData.DepartmentCode}
|
||||
placeholder="e.g., HEM-OUT"
|
||||
required
|
||||
/>
|
||||
<span class="label-text-alt text-xs text-gray-500">Unique code for this department</span>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="departmentName">
|
||||
<span class="label-text text-sm font-medium">Department Name</span>
|
||||
<span class="label-text-alt text-xs text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="departmentName"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={departmentFormData.DepartmentName}
|
||||
placeholder="e.g., Outpatient Hematology"
|
||||
required
|
||||
/>
|
||||
<span class="label-text-alt text-xs text-gray-500">Display name</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="discipline">
|
||||
<span class="label-text text-sm font-medium">Discipline</span>
|
||||
<span class="label-text-alt text-xs text-error">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="discipline"
|
||||
class="select select-sm select-bordered w-full"
|
||||
bind:value={departmentFormData.DisciplineID}
|
||||
required
|
||||
>
|
||||
<option value={null}>Select discipline...</option>
|
||||
{#each disciplines as discipline}
|
||||
<option value={discipline.DisciplineID}>{discipline.DisciplineName}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="label-text-alt text-xs text-gray-500">The discipline this department belongs to</span>
|
||||
</div>
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
onclick={() => (departmentModalOpen = false)}
|
||||
type="button"
|
||||
disabled={savingDepartment}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={handleSaveDepartment}
|
||||
disabled={savingDepartment}
|
||||
type="button"
|
||||
>
|
||||
{#if savingDepartment}
|
||||
<span class="loading loading-spinner loading-sm mr-2"></span>
|
||||
{/if}
|
||||
{savingDepartment ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<!-- Delete Discipline Confirmation -->
|
||||
<Modal bind:open={deleteDisciplineConfirmOpen} title="Confirm Delete Discipline" size="sm">
|
||||
<div class="py-2">
|
||||
<p class="text-base-content/80">Are you sure you want to delete this discipline?</p>
|
||||
<div class="bg-base-200 rounded-lg p-3 mt-3">
|
||||
<p class="text-sm">
|
||||
<span class="text-gray-500">Code:</span>
|
||||
<strong class="text-base-content font-mono">{deleteDisciplineItem?.DisciplineCode}</strong>
|
||||
</p>
|
||||
<p class="text-sm mt-1">
|
||||
<span class="text-gray-500">Name:</span>
|
||||
<strong class="text-base-content">{deleteDisciplineItem?.DisciplineName}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-error mt-3 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
onclick={() => (deleteDisciplineConfirmOpen = false)}
|
||||
type="button"
|
||||
disabled={deletingDiscipline}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-error"
|
||||
onclick={handleDeleteDiscipline}
|
||||
disabled={deletingDiscipline}
|
||||
type="button"
|
||||
>
|
||||
{#if deletingDiscipline}
|
||||
<span class="loading loading-spinner loading-sm mr-2"></span>
|
||||
{/if}
|
||||
{deletingDiscipline ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
<!-- Delete Department Confirmation -->
|
||||
<Modal bind:open={deleteDepartmentConfirmOpen} title="Confirm Delete Department" size="sm">
|
||||
<div class="py-2">
|
||||
<p class="text-base-content/80">Are you sure you want to delete this department?</p>
|
||||
<div class="bg-base-200 rounded-lg p-3 mt-3">
|
||||
<p class="text-sm">
|
||||
<span class="text-gray-500">Code:</span>
|
||||
<strong class="text-base-content font-mono">{deleteDepartmentItem?.DepartmentCode}</strong>
|
||||
</p>
|
||||
<p class="text-sm mt-1">
|
||||
<span class="text-gray-500">Name:</span>
|
||||
<strong class="text-base-content">{deleteDepartmentItem?.DepartmentName}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-error mt-3 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
onclick={() => (deleteDepartmentConfirmOpen = false)}
|
||||
type="button"
|
||||
disabled={deletingDepartment}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-error"
|
||||
onclick={handleDeleteDepartment}
|
||||
disabled={deletingDepartment}
|
||||
type="button"
|
||||
>
|
||||
{#if deletingDepartment}
|
||||
<span class="loading loading-spinner loading-sm mr-2"></span>
|
||||
{/if}
|
||||
{deletingDepartment ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
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 -->
|
||||
<div class="mb-4">
|
||||
<div class="relative max-w-md">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full pl-10"
|
||||
placeholder="Search by specialty name, title, or parent..."
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-xs btn-ghost btn-circle"
|
||||
onclick={() => (searchQuery = '')}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
<div class="max-w-md">
|
||||
<label class="input input-sm input-bordered w-full flex items-center gap-2">
|
||||
<Search class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow"
|
||||
placeholder="Search by specialty name, title, or parent..."
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
class="btn btn-xs btn-ghost btn-circle"
|
||||
onclick={() => (searchQuery = '')}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -19,7 +19,6 @@
|
||||
Monitor,
|
||||
Filter,
|
||||
X,
|
||||
FileText,
|
||||
} from 'lucide-svelte';
|
||||
|
||||
let loading = $state(false);
|
||||
@ -40,11 +39,17 @@
|
||||
let filterClientType = $state('');
|
||||
let filterClientID = $state('');
|
||||
|
||||
// System types for dropdowns
|
||||
const SYSTEM_TYPES = ['HIS', 'SITE', 'WST', 'INST'];
|
||||
|
||||
// Derived unique values for ID dropdowns
|
||||
let uniqueHostIDs = $derived([...new Set(testMaps.map(m => m.HostID).filter(Boolean))].sort());
|
||||
let uniqueClientIDs = $derived([...new Set(testMaps.map(m => m.ClientID).filter(Boolean))].sort());
|
||||
|
||||
const columns = [
|
||||
{ key: 'HostInfo', label: 'Host System', class: 'w-48' },
|
||||
{ key: 'ClientInfo', label: 'Client System', class: 'w-48' },
|
||||
{ key: 'TestCount', label: 'Tests', class: 'w-24 text-center' },
|
||||
{ key: 'TestPreview', label: 'Test Codes', class: 'flex-1' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
|
||||
];
|
||||
|
||||
@ -63,15 +68,11 @@
|
||||
ClientType: mapping.ClientType || '',
|
||||
ClientID: mapping.ClientID || '',
|
||||
mappings: [],
|
||||
testCodes: [],
|
||||
});
|
||||
}
|
||||
|
||||
const group = groups.get(key);
|
||||
group.mappings.push(mapping);
|
||||
if (mapping.HostTestCode) {
|
||||
group.testCodes.push(mapping.HostTestCode);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(groups.values());
|
||||
@ -81,17 +82,13 @@
|
||||
let filteredGroupedTestMaps = $derived(
|
||||
groupedTestMaps().filter((group) => {
|
||||
const matchesHostType =
|
||||
!filterHostType ||
|
||||
(group.HostType && group.HostType.toLowerCase().includes(filterHostType.toLowerCase()));
|
||||
!filterHostType || group.HostType === filterHostType;
|
||||
const matchesHostID =
|
||||
!filterHostID ||
|
||||
(group.HostID && group.HostID.toLowerCase().includes(filterHostID.toLowerCase()));
|
||||
!filterHostID || group.HostID === filterHostID;
|
||||
const matchesClientType =
|
||||
!filterClientType ||
|
||||
(group.ClientType && group.ClientType.toLowerCase().includes(filterClientType.toLowerCase()));
|
||||
!filterClientType || group.ClientType === filterClientType;
|
||||
const matchesClientID =
|
||||
!filterClientID ||
|
||||
(group.ClientID && group.ClientID.toLowerCase().includes(filterClientID.toLowerCase()));
|
||||
!filterClientID || group.ClientID === filterClientID;
|
||||
|
||||
return matchesHostType && matchesHostID && matchesClientType && matchesClientID;
|
||||
})
|
||||
@ -181,16 +178,6 @@
|
||||
filterClientType = '';
|
||||
filterClientID = '';
|
||||
}
|
||||
|
||||
function getTestCodesPreview(testCodes, maxCount = 3) {
|
||||
if (!testCodes || testCodes.length === 0) return '-';
|
||||
const displayCodes = testCodes.slice(0, maxCount);
|
||||
const remaining = testCodes.length - maxCount;
|
||||
if (remaining > 0) {
|
||||
return `${displayCodes.join(', ')} +${remaining} more`;
|
||||
}
|
||||
return displayCodes.join(', ');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-4">
|
||||
@ -230,18 +217,24 @@
|
||||
Host
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type"
|
||||
class="input input-sm input-bordered w-full"
|
||||
<select
|
||||
class="select select-sm select-bordered w-full"
|
||||
bind:value={filterHostType}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ID"
|
||||
class="input input-sm input-bordered w-full"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{#each SYSTEM_TYPES as type}
|
||||
<option value={type}>{type}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select
|
||||
class="select select-sm select-bordered w-full"
|
||||
bind:value={filterHostID}
|
||||
/>
|
||||
>
|
||||
<option value="">All IDs</option>
|
||||
{#each uniqueHostIDs as id}
|
||||
<option value={id}>{id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Client Filters -->
|
||||
@ -251,18 +244,24 @@
|
||||
Client
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Type"
|
||||
class="input input-sm input-bordered w-full"
|
||||
<select
|
||||
class="select select-sm select-bordered w-full"
|
||||
bind:value={filterClientType}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="ID"
|
||||
class="input input-sm input-bordered w-full"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{#each SYSTEM_TYPES as type}
|
||||
<option value={type}>{type}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select
|
||||
class="select select-sm select-bordered w-full"
|
||||
bind:value={filterClientID}
|
||||
/>
|
||||
>
|
||||
<option value="">All IDs</option>
|
||||
{#each uniqueClientIDs as id}
|
||||
<option value={id}>{id}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -314,17 +313,15 @@
|
||||
{#if column.key === 'HostInfo'}
|
||||
<div class="flex items-center gap-2">
|
||||
<Server class="w-4 h-4 text-primary flex-shrink-0" />
|
||||
<div>
|
||||
<div class="font-medium text-sm">{row.HostType || '-'}</div>
|
||||
<div class="text-xs text-gray-500">{row.HostID || '-'}</div>
|
||||
<div class="font-medium text-sm">
|
||||
{row.HostType || '-'} - {row.HostID || '-'}
|
||||
</div>
|
||||
</div>
|
||||
{:else if column.key === 'ClientInfo'}
|
||||
<div class="flex items-center gap-2">
|
||||
<Monitor class="w-4 h-4 text-secondary flex-shrink-0" />
|
||||
<div>
|
||||
<div class="font-medium text-sm">{row.ClientType || '-'}</div>
|
||||
<div class="text-xs text-gray-500">{row.ClientID || '-'}</div>
|
||||
<div class="font-medium text-sm">
|
||||
{row.ClientType || '-'} - {row.ClientID || '-'}
|
||||
</div>
|
||||
</div>
|
||||
{:else if column.key === 'TestCount'}
|
||||
@ -333,13 +330,6 @@
|
||||
{row.mappings.length}
|
||||
</span>
|
||||
</div>
|
||||
{:else if column.key === 'TestPreview'}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<FileText class="w-4 h-4 text-gray-400 flex-shrink-0" />
|
||||
<span class="text-gray-600 truncate" title={row.testCodes.join(', ')}>
|
||||
{getTestCodesPreview(row.testCodes)}
|
||||
</span>
|
||||
</div>
|
||||
{:else if column.key === 'actions'}
|
||||
<div class="flex justify-center gap-1">
|
||||
<button
|
||||
@ -395,12 +385,7 @@
|
||||
<span class="text-gray-500">Client:</span>
|
||||
<strong class="text-base-content">{deleteItem?.ClientType} / {deleteItem?.ClientID}</strong>
|
||||
</p>
|
||||
{#if deleteGroupMode && deleteItem?.testCodes?.length > 0}
|
||||
<p class="text-sm">
|
||||
<span class="text-gray-500">Tests:</span>
|
||||
<strong class="text-base-content">{deleteItem.testCodes.slice(0, 5).join(', ')}{deleteItem.testCodes.length > 5 ? '...' : ''}</strong>
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
<p class="text-sm text-error mt-3 flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
@ -367,11 +367,12 @@
|
||||
{#if modalContext.ClientType === 'INST'}
|
||||
<select
|
||||
class="select select-xs select-bordered w-full m-0"
|
||||
bind:value={row.ConDefID}
|
||||
value={row.ConDefID}
|
||||
onchange={(e) => updateRowField(index, 'ConDefID', e.target.value === '' ? null : parseInt(e.target.value))}
|
||||
>
|
||||
<option value={null}>Select container...</option>
|
||||
<option value="">Select container...</option>
|
||||
{#each containers as container (container.ConDefID)}
|
||||
<option value={container.ConDefID}>
|
||||
<option value={container.ConDefID} selected={container.ConDefID === row.ConDefID}>
|
||||
{container.ConName}
|
||||
</option>
|
||||
{/each}
|
||||
|
||||
@ -145,22 +145,24 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="relative max-w-md">
|
||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full pl-10"
|
||||
placeholder="Search by code or name..."
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
|
||||
onclick={() => searchQuery = ''}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
<div class="max-w-md">
|
||||
<label class="input input-sm input-bordered w-full flex items-center gap-2">
|
||||
<Search class="w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow"
|
||||
placeholder="Search by code or name..."
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button
|
||||
class="btn btn-ghost btn-xs btn-circle"
|
||||
onclick={() => searchQuery = ''}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -150,16 +150,16 @@
|
||||
<div class="flex flex-col min-h-0">
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 mb-4">
|
||||
<div class="flex-1 relative">
|
||||
<label class="input input-sm input-bordered flex-1 flex items-center gap-2">
|
||||
<Search class="w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search ValueSets by key (e.g., priority_status, test_category)..."
|
||||
class="input input-sm input-bordered w-full pl-10"
|
||||
class="grow"
|
||||
bind:value={searchQuery}
|
||||
onkeydown={handleSearchKeydown}
|
||||
/>
|
||||
<Search class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user