feat(testmap): update test map page and modal components
This commit is contained in:
parent
beb3235470
commit
8f75a1339c
250
AGENTS.md
250
AGENTS.md
@ -1,114 +1,79 @@
|
|||||||
# AGENTS.md - Coding Guidelines for CLQMS Frontend
|
# AGENTS.md - Coding Guidelines for CLQMS Frontend
|
||||||
|
|
||||||
## Project Overview
|
SvelteKit frontend for Clinical Laboratory Quality Management System. Uses Svelte 5 runes, TailwindCSS 4, DaisyUI, and Lucide icons.
|
||||||
|
|
||||||
SvelteKit frontend for Clinical Laboratory Quality Management System (CLQMS). Uses Svelte 5 runes, TailwindCSS 4, DaisyUI, and Lucide icons.
|
## Commands
|
||||||
|
|
||||||
## Build Commands
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Development server
|
pnpm run dev # Development server
|
||||||
pnpm run dev
|
pnpm run build # Production build
|
||||||
|
pnpm run preview # Preview production build
|
||||||
# Production build
|
pnpm run prepare # Sync SvelteKit
|
||||||
pnpm run build
|
|
||||||
|
|
||||||
# Preview production build
|
|
||||||
pnpm run preview
|
|
||||||
|
|
||||||
# Sync SvelteKit (runs automatically on install)
|
|
||||||
pnpm run prepare
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Testing
|
**No test framework yet.** When adding: use Vitest (`vitest run src/path/to/test.js`), Playwright for E2E.
|
||||||
|
|
||||||
**No test framework configured yet.** When adding tests:
|
## Code Style
|
||||||
- Use Vitest for unit tests (recommended with SvelteKit)
|
|
||||||
- Use Playwright for E2E tests
|
|
||||||
- Run single test: `vitest run src/path/to/test.js`
|
|
||||||
- Run tests in watch mode: `vitest`
|
|
||||||
|
|
||||||
## Code Style Guidelines
|
- **ES Modules**: `import`/`export` (type: "module")
|
||||||
|
- **Semicolons**: Required
|
||||||
### JavaScript/TypeScript
|
- **Quotes**: Single quotes
|
||||||
|
|
||||||
- **ES Modules**: Always use `import`/`export` (type: "module" in package.json)
|
|
||||||
- **Semicolons**: Use semicolons consistently
|
|
||||||
- **Quotes**: Use single quotes for strings
|
|
||||||
- **Indentation**: 2 spaces
|
- **Indentation**: 2 spaces
|
||||||
- **Trailing commas**: Use in multi-line objects/arrays
|
- **Trailing commas**: In multi-line objects/arrays
|
||||||
- **JSDoc**: Document all exported functions with JSDoc comments
|
- **JSDoc**: Document all exported functions
|
||||||
|
|
||||||
### Svelte Components
|
## 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
|
||||||
|
|
||||||
|
1. Svelte (`svelte`, `$app/*`)
|
||||||
|
2. `$lib/*` (stores, api, components, utils)
|
||||||
|
3. External libraries (`lucide-svelte`)
|
||||||
|
4. Relative imports (minimize, prefer `$lib`)
|
||||||
|
|
||||||
|
## Svelte 5 Components
|
||||||
|
|
||||||
```svelte
|
```svelte
|
||||||
<script>
|
<script>
|
||||||
// 1. Imports - Svelte, $app, $lib, external
|
// 1. Imports
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { browser } from '$app/environment';
|
|
||||||
import { auth } from '$lib/stores/auth.js';
|
import { auth } from '$lib/stores/auth.js';
|
||||||
import { login } from '$lib/api/auth.js';
|
import { User } from 'lucide-svelte';
|
||||||
import { User, Lock } from 'lucide-svelte';
|
|
||||||
|
|
||||||
// 2. Props with $bindable for two-way binding
|
// 2. Props with $bindable
|
||||||
let { open = $bindable(false), title = '', children, footer } = $props();
|
let { open = $bindable(false), title = '', children } = $props();
|
||||||
|
|
||||||
// 3. State
|
// 3. State
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let error = $state('');
|
let formData = $state({ name: '' });
|
||||||
|
|
||||||
// 4. Derived state (if needed)
|
// 4. Derived
|
||||||
let isValid = $derived(username.length > 0);
|
let isValid = $derived(formData.name.length > 0);
|
||||||
|
|
||||||
// 5. Effects
|
// 5. Effects
|
||||||
$effect(() => { /* side effects */ });
|
$effect(() => { /* side effects */ });
|
||||||
|
|
||||||
// 6. Functions - prefix handlers with 'handle'
|
// 6. Handlers
|
||||||
function handleSubmit() { /* implementation */ }
|
function handleSubmit() { /* impl */ }
|
||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Naming Conventions
|
## API Patterns
|
||||||
|
|
||||||
- **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`, `userStore`)
|
|
||||||
- **Event handlers**: prefix with `handle` (`handleSubmit`, `handleClick`)
|
|
||||||
- **Form state**: `formLoading`, `formError`, `deleteConfirmOpen`
|
|
||||||
|
|
||||||
### Imports Order
|
|
||||||
|
|
||||||
1. Svelte imports (`svelte`, `$app/*`)
|
|
||||||
2. $lib aliases (`$lib/stores/*`, `$lib/api/*`, `$lib/components/*`, `$lib/utils/*`)
|
|
||||||
3. External libraries (`lucide-svelte`)
|
|
||||||
4. Relative imports (minimize these, prefer `$lib`)
|
|
||||||
|
|
||||||
### Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
lib/
|
|
||||||
api/ # API client and endpoints (per feature)
|
|
||||||
stores/ # Svelte stores (auth, config, valuesets)
|
|
||||||
components/ # Reusable components (DataTable, Modal, Sidebar)
|
|
||||||
utils/ # Utilities (toast, helpers)
|
|
||||||
assets/ # Static assets
|
|
||||||
routes/ # SvelteKit routes
|
|
||||||
(app)/ # Route groups (protected)
|
|
||||||
login/
|
|
||||||
dashboard/
|
|
||||||
```
|
|
||||||
|
|
||||||
### API Client Patterns
|
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// src/lib/api/client.js - Base client handles auth, 401 redirects
|
// src/lib/api/client.js - Use these helpers
|
||||||
import { apiClient, get, post, put, patch, del } from '$lib/api/client.js';
|
import { get, post, put, patch, del } from '$lib/api/client.js';
|
||||||
|
|
||||||
// src/lib/api/feature.js - Feature-specific endpoints with JSDoc
|
// Feature endpoints (with JSDoc)
|
||||||
export async function fetchItems(params = {}) {
|
export async function fetchItems(params = {}) {
|
||||||
const query = new URLSearchParams(params).toString();
|
const query = new URLSearchParams(params).toString();
|
||||||
return get(query ? `/api/items?${query}` : '/api/items');
|
return get(query ? `/api/items?${query}` : '/api/items');
|
||||||
@ -118,8 +83,8 @@ export async function createItem(data) {
|
|||||||
return post('/api/items', data);
|
return post('/api/items', data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateItem(data) {
|
export async function updateItem(id, data) {
|
||||||
return patch('/api/items', data);
|
return patch(`/api/items/${id}`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteItem(id) {
|
export async function deleteItem(id) {
|
||||||
@ -127,45 +92,46 @@ export async function deleteItem(id) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Store Patterns
|
## Store Patterns
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Use writable for stores with localStorage persistence
|
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
function createStore() {
|
function createStore() {
|
||||||
const getInitialState = () => {
|
const getInitialState = () => {
|
||||||
if (!browser) return { data: null };
|
if (!browser) return { data: null };
|
||||||
return { data: JSON.parse(localStorage.getItem('key')) };
|
return JSON.parse(localStorage.getItem('key'));
|
||||||
};
|
};
|
||||||
|
|
||||||
const { subscribe, set, update } = writable(getInitialState());
|
const { subscribe, set } = writable(getInitialState());
|
||||||
|
|
||||||
return {
|
return {
|
||||||
subscribe,
|
subscribe,
|
||||||
setData: (data) => {
|
setData: (data) => {
|
||||||
if (browser) localStorage.setItem('key', JSON.stringify(data));
|
if (browser) localStorage.setItem('key', JSON.stringify(data));
|
||||||
set({ data });
|
set(data);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Component Patterns
|
## Component Patterns
|
||||||
|
|
||||||
```svelte
|
```svelte
|
||||||
<!-- Use $bindable for two-way props -->
|
<!-- Modal with $bindable props -->
|
||||||
let { open = $bindable(false), selected = $bindable(null) } = $props();
|
<Modal bind:open={showModal} title="Edit" size="lg">
|
||||||
|
{#snippet children()}
|
||||||
|
<form>...</form>
|
||||||
|
{/snippet}
|
||||||
|
{#snippet footer()}
|
||||||
|
<button class="btn btn-primary">Save</button>
|
||||||
|
{/snippet}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<!-- Use snippets for slot content -->
|
<!-- Dialog with backdrop -->
|
||||||
{@render children?.()}
|
<dialog class="modal modal-open">
|
||||||
{@render footer()}
|
|
||||||
|
|
||||||
<!-- Dialog with backdrop handling -->
|
|
||||||
<dialog class="modal" class:modal-open={open}>
|
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<button onclick={close}>X</button>
|
|
||||||
{@render children?.()}
|
{@render children?.()}
|
||||||
</div>
|
</div>
|
||||||
<form method="dialog" class="modal-backdrop" onclick={handleBackdropClick}>
|
<form method="dialog" class="modal-backdrop" onclick={handleBackdropClick}>
|
||||||
@ -174,52 +140,22 @@ let { open = $bindable(false), selected = $bindable(null) } = $props();
|
|||||||
</dialog>
|
</dialog>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Error Handling
|
## Error Handling
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
try {
|
try {
|
||||||
const result = await api.fetchData();
|
const result = await api.fetchData();
|
||||||
toastSuccess('Operation successful');
|
success('Operation successful');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err.message || 'An unexpected error occurred';
|
const message = err.message || 'An unexpected error occurred';
|
||||||
toastError(message);
|
error(message);
|
||||||
console.error('Operation failed:', err);
|
console.error('Operation failed:', err);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Styling with Tailwind & DaisyUI
|
## Form Patterns
|
||||||
|
|
||||||
- DaisyUI components: `btn`, `card`, `alert`, `input`, `navbar`, `modal`, `dropdown`, `menu`
|
|
||||||
- Color scheme: `primary` (emerald), `secondary` (dark blue), `accent` (royal blue)
|
|
||||||
- Custom compact classes: `.compact-y`, `.compact-p`, `.compact-input`, `.compact-btn`, `.compact-card`
|
|
||||||
- Size modifiers: `.btn-sm`, `.input-sm`, `.select-sm` for compact forms
|
|
||||||
|
|
||||||
### Authentication Patterns
|
|
||||||
|
|
||||||
- Auth state in `$lib/stores/auth.js` using writable store
|
|
||||||
- Check `$auth.isAuthenticated` in layout `onMount`
|
|
||||||
- Redirect to `/login` if unauthenticated using `goto('/login')`
|
|
||||||
- API client auto-redirects on 401 responses
|
|
||||||
|
|
||||||
### Runtime Config Pattern
|
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Use runtime config for API URL that can be changed at runtime
|
|
||||||
import { config } from '$lib/stores/config.js';
|
|
||||||
function getApiUrl() {
|
|
||||||
return config.getApiUrl() || import.meta.env.VITE_API_URL || '';
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### LocalStorage
|
|
||||||
|
|
||||||
- Only access in browser: check `browser` from `$app/environment`
|
|
||||||
- Use descriptive keys: `clqms_username`, `clqms_remember`, `auth_token`
|
|
||||||
|
|
||||||
### Form Handling Patterns
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Form state with validation
|
|
||||||
let formLoading = $state(false);
|
let formLoading = $state(false);
|
||||||
let formError = $state('');
|
let formError = $state('');
|
||||||
let formData = $state({ username: '', password: '' });
|
let formData = $state({ username: '', password: '' });
|
||||||
@ -238,7 +174,7 @@ async function handleSubmit() {
|
|||||||
formLoading = true;
|
formLoading = true;
|
||||||
try {
|
try {
|
||||||
await api.submit(formData);
|
await api.submit(formData);
|
||||||
toastSuccess('Success');
|
success('Success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
formError = err.message;
|
formError = err.message;
|
||||||
} finally {
|
} finally {
|
||||||
@ -247,31 +183,43 @@ async function handleSubmit() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Toast/Notification System
|
## Styling (DaisyUI + Tailwind)
|
||||||
|
|
||||||
```javascript
|
- **Components**: `btn`, `card`, `alert`, `input`, `navbar`, `modal`, `dropdown`
|
||||||
import { success, error, info, warning } from '$lib/utils/toast.js';
|
- **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`
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
- Check `$auth.isAuthenticated` in layout `onMount`
|
||||||
|
- Redirect to `/login` if unauthenticated using `goto('/login')`
|
||||||
|
- API client auto-redirects on 401
|
||||||
|
|
||||||
|
## LocalStorage
|
||||||
|
|
||||||
|
- Only access in browser: check `browser` from `$app/environment`
|
||||||
|
- Use descriptive keys: `clqms_username`, `auth_token`
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
// Use toast notifications for user feedback
|
|
||||||
success('Item created successfully');
|
|
||||||
error('Failed to create item');
|
|
||||||
info('New message received');
|
|
||||||
warning('Action requires confirmation');
|
|
||||||
```
|
```
|
||||||
|
src/
|
||||||
## Proxy Configuration
|
lib/
|
||||||
|
api/ # API clients per feature
|
||||||
API requests to `/api` are proxied to `http://localhost:8000` in dev. See `vite.config.js`.
|
stores/ # Svelte stores
|
||||||
|
components/ # Reusable components
|
||||||
## API Documentation
|
utils/ # Utilities (toast, helpers)
|
||||||
|
routes/ # SvelteKit routes
|
||||||
API Reference (Swagger UI): https://clqms01-api.services-summit.my.id/swagger/
|
(app)/ # Route groups (protected)
|
||||||
|
login/
|
||||||
|
dashboard/
|
||||||
|
```
|
||||||
|
|
||||||
## Important Notes
|
## Important Notes
|
||||||
|
|
||||||
- No ESLint or Prettier configured yet - add if needed
|
- Uses Svelte 5: `$props`, `$state`, `$derived`, `$effect`, `$bindable`
|
||||||
- No test framework configured yet
|
|
||||||
- Uses Svelte 5 runes: `$props`, `$state`, `$derived`, `$effect`, `$bindable`
|
|
||||||
- SvelteKit file-based routing with `+page.svelte`, `+layout.svelte`
|
|
||||||
- Static adapter configured for static export
|
- Static adapter configured for static export
|
||||||
- Runtime config allows API URL changes without rebuild
|
- Runtime config allows API URL changes without rebuild
|
||||||
|
- API proxy: `/api` → `http://localhost:8000` (dev)
|
||||||
|
- API Docs: https://clqms01-api.services-summit.my.id/swagger/
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
Monitor,
|
Monitor,
|
||||||
Filter,
|
Filter,
|
||||||
X,
|
X,
|
||||||
|
FileText,
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
@ -27,9 +28,11 @@
|
|||||||
let modalOpen = $state(false);
|
let modalOpen = $state(false);
|
||||||
let modalMode = $state('create');
|
let modalMode = $state('create');
|
||||||
let modalData = $state(null);
|
let modalData = $state(null);
|
||||||
|
let modalGroupData = $state(null);
|
||||||
let deleting = $state(false);
|
let deleting = $state(false);
|
||||||
let deleteConfirmOpen = $state(false);
|
let deleteConfirmOpen = $state(false);
|
||||||
let deleteItem = $state(null);
|
let deleteItem = $state(null);
|
||||||
|
let deleteGroupMode = $state(false);
|
||||||
|
|
||||||
// Filter states
|
// Filter states
|
||||||
let filterHostType = $state('');
|
let filterHostType = $state('');
|
||||||
@ -38,28 +41,57 @@
|
|||||||
let filterClientID = $state('');
|
let filterClientID = $state('');
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ key: 'HostType', label: 'Host Type', class: 'w-20' },
|
{ key: 'HostInfo', label: 'Host System', class: 'w-48' },
|
||||||
{ key: 'HostID', label: 'Host ID', class: 'w-32' },
|
{ key: 'ClientInfo', label: 'Client System', class: 'w-48' },
|
||||||
{ key: 'ClientType', label: 'Client Type', class: 'w-24' },
|
{ key: 'TestCount', label: 'Tests', class: 'w-24 text-center' },
|
||||||
{ key: 'ClientID', label: 'Client ID', class: 'w-32' },
|
{ key: 'TestPreview', label: 'Test Codes', class: 'flex-1' },
|
||||||
{ key: 'actions', label: 'Actions', class: 'w-24 text-center' },
|
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Derived filtered test maps
|
// Group test mappings by HostType/HostID/ClientType/ClientID
|
||||||
let filteredTestMaps = $derived(
|
let groupedTestMaps = $derived(() => {
|
||||||
testMaps.filter((mapping) => {
|
const groups = new Map();
|
||||||
|
|
||||||
|
testMaps.forEach((mapping) => {
|
||||||
|
const key = `${mapping.HostType || ''}|${mapping.HostID || ''}|${mapping.ClientType || ''}|${mapping.ClientID || ''}`;
|
||||||
|
|
||||||
|
if (!groups.has(key)) {
|
||||||
|
groups.set(key, {
|
||||||
|
key,
|
||||||
|
HostType: mapping.HostType || '',
|
||||||
|
HostID: mapping.HostID || '',
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derived filtered grouped test maps
|
||||||
|
let filteredGroupedTestMaps = $derived(
|
||||||
|
groupedTestMaps().filter((group) => {
|
||||||
const matchesHostType =
|
const matchesHostType =
|
||||||
!filterHostType ||
|
!filterHostType ||
|
||||||
(mapping.HostType && mapping.HostType.toLowerCase().includes(filterHostType.toLowerCase()));
|
(group.HostType && group.HostType.toLowerCase().includes(filterHostType.toLowerCase()));
|
||||||
const matchesHostID =
|
const matchesHostID =
|
||||||
!filterHostID ||
|
!filterHostID ||
|
||||||
(mapping.HostID && mapping.HostID.toLowerCase().includes(filterHostID.toLowerCase()));
|
(group.HostID && group.HostID.toLowerCase().includes(filterHostID.toLowerCase()));
|
||||||
const matchesClientType =
|
const matchesClientType =
|
||||||
!filterClientType ||
|
!filterClientType ||
|
||||||
(mapping.ClientType && mapping.ClientType.toLowerCase().includes(filterClientType.toLowerCase()));
|
(group.ClientType && group.ClientType.toLowerCase().includes(filterClientType.toLowerCase()));
|
||||||
const matchesClientID =
|
const matchesClientID =
|
||||||
!filterClientID ||
|
!filterClientID ||
|
||||||
(mapping.ClientID && mapping.ClientID.toLowerCase().includes(filterClientID.toLowerCase()));
|
(group.ClientID && group.ClientID.toLowerCase().includes(filterClientID.toLowerCase()));
|
||||||
|
|
||||||
return matchesHostType && matchesHostID && matchesClientType && matchesClientID;
|
return matchesHostType && matchesHostID && matchesClientType && matchesClientID;
|
||||||
})
|
})
|
||||||
@ -95,12 +127,15 @@
|
|||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
modalMode = 'create';
|
modalMode = 'create';
|
||||||
modalData = null;
|
modalData = null;
|
||||||
|
modalGroupData = null;
|
||||||
modalOpen = true;
|
modalOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditModal(row) {
|
function openEditGroupModal(group) {
|
||||||
modalMode = 'edit';
|
modalMode = 'edit';
|
||||||
modalData = row;
|
modalGroupData = group;
|
||||||
|
// Pass the first mapping as initial data, modal will handle the rest
|
||||||
|
modalData = group.mappings[0] || null;
|
||||||
modalOpen = true;
|
modalOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,21 +143,33 @@
|
|||||||
loadTestMaps();
|
loadTestMaps();
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete(row) {
|
function confirmDeleteGroup(group) {
|
||||||
deleteItem = row;
|
deleteItem = group;
|
||||||
|
deleteGroupMode = true;
|
||||||
deleteConfirmOpen = true;
|
deleteConfirmOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
deleting = true;
|
deleting = true;
|
||||||
try {
|
try {
|
||||||
|
if (deleteGroupMode && deleteItem) {
|
||||||
|
// Delete all mappings in the group
|
||||||
|
const deletePromises = deleteItem.mappings.map((mapping) =>
|
||||||
|
deleteTestMap(mapping.TestMapID)
|
||||||
|
);
|
||||||
|
await Promise.all(deletePromises);
|
||||||
|
toastSuccess(`Deleted ${deleteItem.mappings.length} test mapping(s) successfully`);
|
||||||
|
} else if (deleteItem?.TestMapID) {
|
||||||
|
// Delete single mapping (fallback)
|
||||||
await deleteTestMap(deleteItem.TestMapID);
|
await deleteTestMap(deleteItem.TestMapID);
|
||||||
toastSuccess('Test mapping deleted successfully');
|
toastSuccess('Test mapping deleted successfully');
|
||||||
|
}
|
||||||
deleteConfirmOpen = false;
|
deleteConfirmOpen = false;
|
||||||
deleteItem = null;
|
deleteItem = null;
|
||||||
|
deleteGroupMode = false;
|
||||||
await loadTestMaps();
|
await loadTestMaps();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toastError(err.message || 'Failed to delete test mapping');
|
toastError(err.message || 'Failed to delete test mapping(s)');
|
||||||
} finally {
|
} finally {
|
||||||
deleting = false;
|
deleting = false;
|
||||||
}
|
}
|
||||||
@ -134,6 +181,16 @@
|
|||||||
filterClientType = '';
|
filterClientType = '';
|
||||||
filterClientID = '';
|
filterClientID = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTestCodesPreview(testCodes, maxCount = 3) {
|
||||||
|
if (!testCodes || testCodes.length === 0) return '-';
|
||||||
|
const displayCodes = testCodes.slice(0, maxCount);
|
||||||
|
const remaining = testCodes.length - maxCount;
|
||||||
|
if (remaining > 0) {
|
||||||
|
return `${displayCodes.join(', ')} +${remaining} more`;
|
||||||
|
}
|
||||||
|
return displayCodes.join(', ');
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
@ -213,7 +270,7 @@
|
|||||||
|
|
||||||
<!-- Data Table -->
|
<!-- Data Table -->
|
||||||
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
<div class="bg-base-100 rounded-lg shadow border border-base-200">
|
||||||
{#if !loading && filteredTestMaps.length === 0}
|
{#if !loading && filteredGroupedTestMaps.length === 0}
|
||||||
<!-- Empty State -->
|
<!-- Empty State -->
|
||||||
<div class="flex flex-col items-center justify-center py-16 px-4">
|
<div class="flex flex-col items-center justify-center py-16 px-4">
|
||||||
<div class="bg-base-200 rounded-full p-6 mb-4">
|
<div class="bg-base-200 rounded-full p-6 mb-4">
|
||||||
@ -247,26 +304,55 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<DataTable
|
<DataTable
|
||||||
{columns}
|
{columns}
|
||||||
data={filteredTestMaps}
|
data={filteredGroupedTestMaps}
|
||||||
{loading}
|
{loading}
|
||||||
emptyMessage="No test mappings found"
|
emptyMessage="No test mappings found"
|
||||||
hover={true}
|
hover={true}
|
||||||
bordered={false}
|
bordered={false}
|
||||||
>
|
>
|
||||||
{#snippet cell({ column, row, value })}
|
{#snippet cell({ column, row, value })}
|
||||||
{#if column.key === 'actions'}
|
{#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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
{:else if column.key === 'TestCount'}
|
||||||
|
<div class="flex justify-center">
|
||||||
|
<span class="badge badge-primary badge-sm">
|
||||||
|
{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">
|
<div class="flex justify-center gap-1">
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-ghost"
|
class="btn btn-sm btn-ghost"
|
||||||
onclick={() => openEditModal(row)}
|
onclick={() => openEditGroupModal(row)}
|
||||||
title="Edit mapping"
|
title="Edit all {row.mappings.length} test mapping(s)"
|
||||||
>
|
>
|
||||||
<Edit2 class="w-4 h-4" />
|
<Edit2 class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="btn btn-sm btn-ghost text-error"
|
class="btn btn-sm btn-ghost text-error"
|
||||||
onclick={() => confirmDelete(row)}
|
onclick={() => confirmDeleteGroup(row)}
|
||||||
title="Delete mapping"
|
title="Delete all {row.mappings.length} test mapping(s)"
|
||||||
>
|
>
|
||||||
<Trash2 class="w-4 h-4" />
|
<Trash2 class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@ -285,14 +371,21 @@
|
|||||||
bind:open={modalOpen}
|
bind:open={modalOpen}
|
||||||
mode={modalMode}
|
mode={modalMode}
|
||||||
initialData={modalData}
|
initialData={modalData}
|
||||||
|
groupData={modalGroupData}
|
||||||
{containers}
|
{containers}
|
||||||
onSave={handleModalSave}
|
onSave={handleModalSave}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
<!-- Delete Confirmation Modal -->
|
||||||
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete Mapping" size="sm">
|
<Modal bind:open={deleteConfirmOpen} title={deleteGroupMode ? 'Confirm Delete Group' : 'Confirm Delete Mapping'} size="sm">
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
<p class="text-base-content/80">Are you sure you want to delete this test mapping?</p>
|
<p class="text-base-content/80">
|
||||||
|
{#if deleteGroupMode}
|
||||||
|
Are you sure you want to delete all {deleteItem?.mappings?.length || 0} test mapping(s) in this group?
|
||||||
|
{:else}
|
||||||
|
Are you sure you want to delete this test mapping?
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
<div class="bg-base-200 rounded-lg p-3 mt-3 space-y-1">
|
<div class="bg-base-200 rounded-lg p-3 mt-3 space-y-1">
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
<span class="text-gray-500">Host:</span>
|
<span class="text-gray-500">Host:</span>
|
||||||
@ -302,6 +395,12 @@
|
|||||||
<span class="text-gray-500">Client:</span>
|
<span class="text-gray-500">Client:</span>
|
||||||
<strong class="text-base-content">{deleteItem?.ClientType} / {deleteItem?.ClientID}</strong>
|
<strong class="text-base-content">{deleteItem?.ClientType} / {deleteItem?.ClientID}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
{#if deleteGroupMode && deleteItem?.testCodes?.length > 0}
|
||||||
|
<p class="text-sm">
|
||||||
|
<span class="text-gray-500">Tests:</span>
|
||||||
|
<strong class="text-base-content">{deleteItem.testCodes.slice(0, 5).join(', ')}{deleteItem.testCodes.length > 5 ? '...' : ''}</strong>
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-error mt-3 flex items-center gap-2">
|
<p class="text-sm text-error mt-3 flex items-center gap-2">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
|||||||
@ -8,9 +8,10 @@
|
|||||||
Server,
|
Server,
|
||||||
Monitor,
|
Monitor,
|
||||||
FlaskConical,
|
FlaskConical,
|
||||||
|
AlertCircle,
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
let { open = $bindable(false), mode = 'create', initialData = null, containers = [], onSave } = $props();
|
let { open = $bindable(false), mode = 'create', initialData = null, groupData = null, containers = [], onSave } = $props();
|
||||||
|
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
@ -41,7 +42,27 @@
|
|||||||
function initializeModal() {
|
function initializeModal() {
|
||||||
formErrors = {};
|
formErrors = {};
|
||||||
|
|
||||||
if (mode === 'edit' && initialData) {
|
if (mode === 'edit' && groupData) {
|
||||||
|
// Edit mode with group data - load all mappings in the group
|
||||||
|
modalContext = {
|
||||||
|
HostType: groupData.HostType || '',
|
||||||
|
HostID: groupData.HostID || '',
|
||||||
|
ClientType: groupData.ClientType || '',
|
||||||
|
ClientID: groupData.ClientID || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load all mappings from the group
|
||||||
|
modalRows = groupData.mappings.map((mapping) => ({
|
||||||
|
TestMapID: mapping.TestMapID,
|
||||||
|
HostTestCode: mapping.HostTestCode || '',
|
||||||
|
HostTestName: mapping.HostTestName || '',
|
||||||
|
ConDefID: mapping.ConDefID || null,
|
||||||
|
ClientTestCode: mapping.ClientTestCode || '',
|
||||||
|
ClientTestName: mapping.ClientTestName || '',
|
||||||
|
isNew: false,
|
||||||
|
}));
|
||||||
|
} else if (mode === 'edit' && initialData) {
|
||||||
|
// Legacy edit mode (single mapping)
|
||||||
modalContext = {
|
modalContext = {
|
||||||
HostType: initialData.HostType || '',
|
HostType: initialData.HostType || '',
|
||||||
HostID: initialData.HostID || '',
|
HostID: initialData.HostID || '',
|
||||||
@ -58,6 +79,7 @@
|
|||||||
isNew: false,
|
isNew: false,
|
||||||
}];
|
}];
|
||||||
} else {
|
} else {
|
||||||
|
// Create mode
|
||||||
modalContext = {
|
modalContext = {
|
||||||
HostType: '',
|
HostType: '',
|
||||||
HostID: '',
|
HostID: '',
|
||||||
@ -184,10 +206,23 @@
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
bind:open
|
bind:open
|
||||||
title={mode === 'create' ? 'Add Test Mapping' : 'Edit Test Mapping'}
|
title={mode === 'create' ? 'Add Test Mapping' : `Edit Test Mapping${groupData ? 's' : ''} (${modalRows.length})`}
|
||||||
size="xl"
|
size="xl"
|
||||||
>
|
>
|
||||||
<div class="space-y-4 max-h-[70vh] overflow-y-auto">
|
<div class="flex flex-col max-h-[70vh]">
|
||||||
|
<!-- Sticky Top Section: Info banner + Host and Client -->
|
||||||
|
<div class="flex-shrink-0 bg-base-100 z-10">
|
||||||
|
<!-- Info banner for group editing -->
|
||||||
|
{#if mode === 'edit' && groupData}
|
||||||
|
<div class="alert alert-info alert-sm mb-4">
|
||||||
|
<AlertCircle class="w-4 h-4" />
|
||||||
|
<div>
|
||||||
|
<span class="font-medium">Editing {modalRows.length} test mapping(s)</span>
|
||||||
|
<span class="text-sm opacity-80">in this host-client group</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Top Section: Host and Client side-by-side -->
|
<!-- Top Section: Host and Client side-by-side -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 border-b border-base-300 pb-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 border-b border-base-300 pb-4">
|
||||||
<!-- Host Section -->
|
<!-- Host Section -->
|
||||||
@ -289,8 +324,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Middle Section: Editable Table -->
|
<!-- Scrollable Middle Section: Editable Table -->
|
||||||
<div class="space-y-2">
|
<div class="flex-1 overflow-y-auto space-y-1 py-2">
|
||||||
<h3 class="text-xs font-medium text-gray-600 uppercase tracking-wide flex items-center gap-1">
|
<h3 class="text-xs font-medium text-gray-600 uppercase tracking-wide flex items-center gap-1">
|
||||||
<FlaskConical class="w-3 h-3" />
|
<FlaskConical class="w-3 h-3" />
|
||||||
Test Mappings
|
Test Mappings
|
||||||
@ -298,40 +333,40 @@
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="overflow-x-auto border border-base-300 rounded-lg">
|
<div class="overflow-x-auto border border-base-300 rounded-lg">
|
||||||
<table class="table table-compact w-full">
|
<table class="table table-compact w-full [&_td]:py-1 [&_th]:py-1">
|
||||||
<thead class="bg-base-200">
|
<thead class="bg-base-200">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-xs">Host Test Code</th>
|
<th class="text-xs px-2">Host Test Code</th>
|
||||||
<th class="text-xs">Host Test Name</th>
|
<th class="text-xs px-2">Host Test Name</th>
|
||||||
<th class="text-xs w-48">Container</th>
|
<th class="text-xs w-48 px-2">Container</th>
|
||||||
<th class="text-xs">Client Test Code</th>
|
<th class="text-xs px-2">Client Test Code</th>
|
||||||
<th class="text-xs">Client Test Name</th>
|
<th class="text-xs px-2">Client Test Name</th>
|
||||||
<th class="text-xs w-10"></th>
|
<th class="text-xs w-10 px-2"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each modalRows as row, index (index)}
|
{#each modalRows as row, index (index)}
|
||||||
<tr class="hover:bg-base-100">
|
<tr class="hover:bg-base-100">
|
||||||
<td>
|
<td class="px-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-xs input-bordered w-full"
|
class="input input-xs input-bordered w-full m-0"
|
||||||
bind:value={row.HostTestCode}
|
bind:value={row.HostTestCode}
|
||||||
placeholder="Code"
|
placeholder="Code"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="px-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-xs input-bordered w-full"
|
class="input input-xs input-bordered w-full m-0"
|
||||||
bind:value={row.HostTestName}
|
bind:value={row.HostTestName}
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="px-2">
|
||||||
{#if modalContext.ClientType === 'INST'}
|
{#if modalContext.ClientType === 'INST'}
|
||||||
<select
|
<select
|
||||||
class="select select-xs select-bordered w-full"
|
class="select select-xs select-bordered w-full m-0"
|
||||||
bind:value={row.ConDefID}
|
bind:value={row.ConDefID}
|
||||||
>
|
>
|
||||||
<option value={null}>Select container...</option>
|
<option value={null}>Select container...</option>
|
||||||
@ -345,32 +380,32 @@
|
|||||||
<span class="text-xs text-error">{formErrors.rows[index].ConDefID}</span>
|
<span class="text-xs text-error">{formErrors.rows[index].ConDefID}</span>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<select class="select select-xs select-bordered w-full" disabled>
|
<select class="select select-xs select-bordered w-full m-0" disabled>
|
||||||
<option>Only for INST</option>
|
<option>Only for INST</option>
|
||||||
</select>
|
</select>
|
||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="px-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-xs input-bordered w-full"
|
class="input input-xs input-bordered w-full m-0"
|
||||||
bind:value={row.ClientTestCode}
|
bind:value={row.ClientTestCode}
|
||||||
placeholder="Code"
|
placeholder="Code"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="px-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-xs input-bordered w-full"
|
class="input input-xs input-bordered w-full m-0"
|
||||||
bind:value={row.ClientTestName}
|
bind:value={row.ClientTestName}
|
||||||
placeholder="Name"
|
placeholder="Name"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td class="px-2">
|
||||||
<button
|
<button
|
||||||
class="btn btn-ghost btn-xs text-error"
|
class="btn btn-ghost btn-xs text-error"
|
||||||
onclick={() => removeMappingRow(index)}
|
onclick={() => removeMappingRow(index)}
|
||||||
disabled={modalRows.length === 1}
|
disabled={modalRows.length === 1 && mode === 'edit'}
|
||||||
title="Remove row"
|
title="Remove row"
|
||||||
>
|
>
|
||||||
<Trash2 class="w-3 h-3" />
|
<Trash2 class="w-3 h-3" />
|
||||||
@ -391,8 +426,8 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Bottom Section: Add Button -->
|
<!-- Sticky Bottom Section: Add Button -->
|
||||||
<div class="flex justify-center pt-2 border-t border-base-300">
|
<div class="flex-shrink-0 flex justify-center pt-2 border-t border-base-300 bg-base-100 pb-2">
|
||||||
<button class="btn btn-sm btn-outline" onclick={addMappingRow}>
|
<button class="btn btn-sm btn-outline" onclick={addMappingRow}>
|
||||||
<Plus class="w-4 h-4 mr-1" />
|
<Plus class="w-4 h-4 mr-1" />
|
||||||
Add Mapping
|
Add Mapping
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user