feat(testmap): update test map page and modal components

This commit is contained in:
mahdahar 2026-02-24 06:12:17 +07:00
parent beb3235470
commit 8f75a1339c
3 changed files with 291 additions and 209 deletions

250
AGENTS.md
View File

@ -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/

View File

@ -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">

View File

@ -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