feat: add test map management module

This commit is contained in:
mahdahar 2026-02-23 16:53:52 +07:00
parent 3b8a935b46
commit beb3235470
5 changed files with 940 additions and 83 deletions

178
src/lib/api/testmap.js Normal file
View File

@ -0,0 +1,178 @@
import { get, post, patch, del } from './client.js';
/**
* @typedef {Object} TestMap
* @property {number} TestMapID - Mapping ID
* @property {number} TestSiteID - Test Site ID
* @property {string} TestSiteCode - Test Code
* @property {string} TestSiteName - Test Name
* @property {string} HostType - Host Type (HIS, SITE, WST, INST)
* @property {string} HostID - Host ID
* @property {string} HostTestCode - Host Test Code
* @property {string} HostTestName - Host Test Name
* @property {string} ClientType - Client Type (HIS, SITE, WST, INST)
* @property {string} ClientID - Client ID
* @property {number} ConDefID - Container Definition ID
* @property {string} ClientTestCode - Client Test Code
* @property {string} ClientTestName - Client Test Name
* @property {string} IsActive - Active status ('1' or '0')
*/
/**
* @typedef {Object} TestMapFilterOptions
* @property {string} [hostType] - Filter by Host Type
* @property {string} [hostID] - Filter by Host ID
* @property {string} [clientType] - Filter by Client Type
* @property {string} [clientID] - Filter by Client ID
* @property {string} [search] - Search term
*/
/**
* @typedef {Object} TestMapListResponse
* @property {boolean} success
* @property {TestMap[]} data
* @property {string} [message]
*/
/**
* @typedef {Object} TestMapResponse
* @property {boolean} success
* @property {TestMap} data
* @property {string} [message]
*/
/**
* @typedef {Object} CreateTestMapPayload
* @property {number} TestSiteID - Test Site ID
* @property {string} HostType - Host Type
* @property {string} HostID - Host ID
* @property {string} HostTestCode - Host Test Code
* @property {string} HostTestName - Host Test Name
* @property {string} ClientType - Client Type
* @property {string} ClientID - Client ID
* @property {number} ConDefID - Container Definition ID
* @property {string} ClientTestCode - Client Test Code
* @property {string} ClientTestName - Client Test Name
*/
/**
* @typedef {Object} UpdateTestMapPayload
* @property {number} TestMapID - Mapping ID
* @property {number} TestSiteID - Test Site ID
* @property {string} HostType - Host Type
* @property {string} HostID - Host ID
* @property {string} HostTestCode - Host Test Code
* @property {string} HostTestName - Host Test Name
* @property {string} ClientType - Client Type
* @property {string} ClientID - Client ID
* @property {number} ConDefID - Container Definition ID
* @property {string} ClientTestCode - Client Test Code
* @property {string} ClientTestName - Client Test Name
*/
/**
* Fetch all test mappings
* @returns {Promise<TestMapListResponse>} API response with mappings list
*/
export async function fetchTestMaps() {
return get('/api/test/testmap');
}
/**
* Fetch a single test mapping by ID
* @param {number} id - Test Map ID
* @returns {Promise<TestMapResponse>} API response with mapping detail
*/
export async function fetchTestMap(id) {
return get(`/api/test/testmap/${id}`);
}
/**
* Fetch test mappings by test site ID
* @param {number} testSiteID - Test Site ID
* @returns {Promise<TestMapListResponse>} API response with mappings list
*/
export async function fetchTestMapsByTestSite(testSiteID) {
return get(`/api/test/testmap/by-testsite/${testSiteID}`);
}
/**
* Fetch test mappings by host
* @param {string} hostType - Host Type
* @param {string} hostID - Host ID
* @returns {Promise<TestMapListResponse>} API response with mappings list
*/
export async function fetchTestMapsByHost(hostType, hostID) {
return get(`/api/test/testmap/by-host/${hostType}/${hostID}`);
}
/**
* Fetch test mappings by client
* @param {string} clientType - Client Type
* @param {string} clientID - Client ID
* @returns {Promise<TestMapListResponse>} API response with mappings list
*/
export async function fetchTestMapsByClient(clientType, clientID) {
return get(`/api/test/testmap/by-client/${clientType}/${clientID}`);
}
/**
* Create a new test mapping
* @param {CreateTestMapPayload} data - Mapping data
* @returns {Promise<TestMapResponse>} API response
*/
export async function createTestMap(data) {
return post('/api/test/testmap', data);
}
/**
* Update an existing test mapping
* @param {UpdateTestMapPayload} data - Mapping data
* @returns {Promise<TestMapResponse>} API response
*/
export async function updateTestMap(data) {
return patch('/api/test/testmap', data);
}
/**
* Soft delete a test mapping (set IsActive to '0')
* @param {number} id - Test Map ID
* @returns {Promise<{success: boolean, message?: string}>} API response
*/
export async function deleteTestMap(id) {
return del('/api/test/testmap', { body: JSON.stringify({ TestMapID: id }) });
}
/**
* Validate mapping form data
* @param {Object} data - Form data to validate
* @returns {{valid: boolean, errors: Object}}
*/
export function validateTestMap(data) {
const errors = {};
if (!data.TestSiteID) {
errors.TestSiteID = 'Test is required';
}
if (!data.HostType) {
errors.HostType = 'Host Type is required';
}
if (!data.HostID) {
errors.HostID = 'Host ID is required';
}
if (!data.ClientType) {
errors.ClientType = 'Client Type is required';
}
if (!data.ClientID) {
errors.ClientID = 'Client ID is required';
}
return {
valid: Object.keys(errors).length === 0,
errors
};
}

View File

@ -18,9 +18,9 @@ import {
Globe, Globe,
ChevronDown, ChevronDown,
TestTube, TestTube,
ShieldCheck,
FileText, FileText,
X X,
Link
} from 'lucide-svelte'; } from 'lucide-svelte';
import { auth } from '$lib/stores/auth.js'; import { auth } from '$lib/stores/auth.js';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
@ -30,9 +30,7 @@ import {
// Collapsible section states - default collapsed // Collapsible section states - default collapsed
let laboratoryExpanded = $state(false); let laboratoryExpanded = $state(false);
let qualityControlExpanded = $state(false);
let masterDataExpanded = $state(false); let masterDataExpanded = $state(false);
let administrationExpanded = $state(false);
// Load states from localStorage on mount // Load states from localStorage on mount
$effect(() => { $effect(() => {
@ -42,9 +40,7 @@ import {
try { try {
const parsed = JSON.parse(savedStates); const parsed = JSON.parse(savedStates);
laboratoryExpanded = parsed.laboratory ?? false; laboratoryExpanded = parsed.laboratory ?? false;
qualityControlExpanded = parsed.qualityControl ?? false;
masterDataExpanded = parsed.masterData ?? false; masterDataExpanded = parsed.masterData ?? false;
administrationExpanded = parsed.administration ?? false;
} catch (e) { } catch (e) {
// Keep defaults if parsing fails // Keep defaults if parsing fails
} }
@ -57,9 +53,7 @@ import {
if (browser) { if (browser) {
localStorage.setItem('sidebar_section_states', JSON.stringify({ localStorage.setItem('sidebar_section_states', JSON.stringify({
laboratory: laboratoryExpanded, laboratory: laboratoryExpanded,
qualityControl: qualityControlExpanded, masterData: masterDataExpanded
masterData: masterDataExpanded,
administration: administrationExpanded
})); }));
} }
}); });
@ -68,9 +62,7 @@ import {
$effect(() => { $effect(() => {
if (!isOpen) { if (!isOpen) {
laboratoryExpanded = false; laboratoryExpanded = false;
qualityControlExpanded = false;
masterDataExpanded = false; masterDataExpanded = false;
administrationExpanded = false;
} }
}); });
@ -96,26 +88,12 @@ function toggleLaboratory() {
laboratoryExpanded = !laboratoryExpanded; laboratoryExpanded = !laboratoryExpanded;
} }
function toggleQualityControl() {
if (!isOpen) {
expandSidebar();
}
qualityControlExpanded = !qualityControlExpanded;
}
function toggleMasterData() { function toggleMasterData() {
if (!isOpen) { if (!isOpen) {
expandSidebar(); expandSidebar();
} }
masterDataExpanded = !masterDataExpanded; masterDataExpanded = !masterDataExpanded;
} }
function toggleAdministration() {
if (!isOpen) {
expandSidebar();
}
administrationExpanded = !administrationExpanded;
}
</script> </script>
<!-- Mobile Overlay Backdrop --> <!-- Mobile Overlay Backdrop -->
@ -195,28 +173,6 @@ function toggleLaboratory() {
{/if} {/if}
</li> </li>
<!-- Quality Control -->
<li class="nav-group" class:collapsed={!isOpen}>
<button
onclick={toggleQualityControl}
class="nav-link"
class:centered={!isOpen}
title={!isOpen ? 'Quality Control' : ''}
>
<ShieldCheck size={20} class="text-secondary flex-shrink-0" />
{#if isOpen}
<span class="nav-text">Quality Control</span>
<ChevronDown size={16} class="chevron {qualityControlExpanded ? 'expanded' : ''}" />
{/if}
</button>
{#if isOpen && qualityControlExpanded}
<ul class="submenu">
<li><a href="/master-data/tests" class="submenu-link"><TestTube size={16} /> Test Definitions</a></li>
</ul>
{/if}
</li>
{#if isOpen} {#if isOpen}
<li class="menu-title uppercase font-bold text-xs text-secondary/70 mt-4">Analytics</li> <li class="menu-title uppercase font-bold text-xs text-secondary/70 mt-4">Analytics</li>
{/if} {/if}
@ -259,36 +215,16 @@ function toggleLaboratory() {
<ul class="submenu"> <ul class="submenu">
<li><a href="/master-data/organization" class="submenu-link"><Building2 size={16} /> Organization</a></li> <li><a href="/master-data/organization" class="submenu-link"><Building2 size={16} /> Organization</a></li>
<li><a href="/master-data/containers" class="submenu-link"><FlaskConical size={16} /> Containers</a></li> <li><a href="/master-data/containers" class="submenu-link"><FlaskConical size={16} /> Containers</a></li>
<li><a href="/master-data/tests" class="submenu-link"><TestTube size={16} /> Test Definitions</a></li>
<li><a href="/master-data/testmap" class="submenu-link"><Link size={16} /> Test Mapping</a></li>
<li><a href="/master-data/users" class="submenu-link"><UserCircle size={16} /> Users</a></li>
<li><a href="/master-data/valuesets" class="submenu-link"><List size={16} /> ValueSets</a></li> <li><a href="/master-data/valuesets" class="submenu-link"><List size={16} /> ValueSets</a></li>
<li><a href="/master-data/locations" class="submenu-link"><MapPin size={16} /> Locations</a></li> <li><a href="/master-data/locations" class="submenu-link"><MapPin size={16} /> Locations</a></li>
<li><a href="/master-data/contacts" class="submenu-link"><Users size={16} /> Contacts</a></li> <li><a href="/master-data/contacts" class="submenu-link"><Users size={16} /> Contacts</a></li>
<li><a href="/master-data/specialties" class="submenu-link"><Stethoscope size={16} /> Specialties</a></li> <li><a href="/master-data/specialties" class="submenu-link"><Stethoscope size={16} /> Specialties</a></li>
<li><a href="/master-data/occupations" class="submenu-link"><Briefcase size={16} /> Occupations</a></li> <li><a href="/master-data/occupations" class="submenu-link"><Briefcase size={16} /> Occupations</a></li>
<li><a href="/master-data/geography" class="submenu-link"><Globe size={16} /> Geography</a></li>
</ul>
{/if}
</li>
<!-- Administration -->
<li class="nav-group" class:collapsed={!isOpen}>
<button
onclick={toggleAdministration}
class="nav-link"
class:centered={!isOpen}
title={!isOpen ? 'Administration' : ''}
>
<Building2 size={20} class="text-secondary flex-shrink-0" />
{#if isOpen}
<span class="nav-text">Administration</span>
<ChevronDown size={16} class="chevron {administrationExpanded ? 'expanded' : ''}" />
{/if}
</button>
{#if isOpen && administrationExpanded}
<ul class="submenu">
<li><a href="/organization" class="submenu-link"><Building2 size={16} /> Organization</a></li>
<li><a href="/users" class="submenu-link"><UserCircle size={16} /> Users</a></li>
<li><a href="/master-data/counters" class="submenu-link"><Hash size={16} /> Counters</a></li> <li><a href="/master-data/counters" class="submenu-link"><Hash size={16} /> Counters</a></li>
<li><a href="/master-data/geography" class="submenu-link"><Globe size={16} /> Geography</a></li>
</ul> </ul>
{/if} {/if}
</li> </li>

View File

@ -0,0 +1,329 @@
<script>
import { onMount } from 'svelte';
import {
fetchTestMaps,
deleteTestMap,
} from '$lib/api/testmap.js';
import { fetchContainers } from '$lib/api/containers.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 TestMapModal from './TestMapModal.svelte';
import {
Plus,
Edit2,
Trash2,
ArrowLeft,
Link,
Server,
Monitor,
Filter,
X,
} from 'lucide-svelte';
let loading = $state(false);
let testMaps = $state([]);
let containers = $state([]);
let modalOpen = $state(false);
let modalMode = $state('create');
let modalData = $state(null);
let deleting = $state(false);
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
// Filter states
let filterHostType = $state('');
let filterHostID = $state('');
let filterClientType = $state('');
let filterClientID = $state('');
const columns = [
{ key: 'HostType', label: 'Host Type', class: 'w-20' },
{ key: 'HostID', label: 'Host ID', class: 'w-32' },
{ key: 'ClientType', label: 'Client Type', class: 'w-24' },
{ key: 'ClientID', label: 'Client ID', class: 'w-32' },
{ key: 'actions', label: 'Actions', class: 'w-24 text-center' },
];
// Derived filtered test maps
let filteredTestMaps = $derived(
testMaps.filter((mapping) => {
const matchesHostType =
!filterHostType ||
(mapping.HostType && mapping.HostType.toLowerCase().includes(filterHostType.toLowerCase()));
const matchesHostID =
!filterHostID ||
(mapping.HostID && mapping.HostID.toLowerCase().includes(filterHostID.toLowerCase()));
const matchesClientType =
!filterClientType ||
(mapping.ClientType && mapping.ClientType.toLowerCase().includes(filterClientType.toLowerCase()));
const matchesClientID =
!filterClientID ||
(mapping.ClientID && mapping.ClientID.toLowerCase().includes(filterClientID.toLowerCase()));
return matchesHostType && matchesHostID && matchesClientType && matchesClientID;
})
);
onMount(async () => {
await Promise.all([loadTestMaps(), loadContainers()]);
});
async function loadTestMaps() {
loading = true;
try {
const response = await fetchTestMaps();
testMaps = Array.isArray(response.data) ? response.data : [];
} catch (err) {
toastError(err.message || 'Failed to load test mappings');
testMaps = [];
} finally {
loading = false;
}
}
async function loadContainers() {
try {
const response = await fetchContainers();
containers = Array.isArray(response.data) ? response.data : [];
} catch (err) {
console.error('Failed to load containers:', err);
containers = [];
}
}
function openCreateModal() {
modalMode = 'create';
modalData = null;
modalOpen = true;
}
function openEditModal(row) {
modalMode = 'edit';
modalData = row;
modalOpen = true;
}
function handleModalSave() {
loadTestMaps();
}
function confirmDelete(row) {
deleteItem = row;
deleteConfirmOpen = true;
}
async function handleDelete() {
deleting = true;
try {
await deleteTestMap(deleteItem.TestMapID);
toastSuccess('Test mapping deleted successfully');
deleteConfirmOpen = false;
deleteItem = null;
await loadTestMaps();
} catch (err) {
toastError(err.message || 'Failed to delete test mapping');
} finally {
deleting = false;
}
}
function clearFilters() {
filterHostType = '';
filterHostID = '';
filterClientType = '';
filterClientID = '';
}
</script>
<div class="p-4">
<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">Test Mapping</h1>
<p class="text-sm text-gray-600">
Manage test mappings between host and client systems
</p>
</div>
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Mapping
</button>
</div>
<!-- Filters -->
<div class="bg-base-100 rounded-lg shadow border border-base-200 p-4 mb-4">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-gray-700 flex items-center gap-2">
<Filter class="w-4 h-4" />
Filters
</h3>
<button class="btn btn-sm btn-ghost" onclick={clearFilters}>
<X class="w-3 h-3 mr-1" />
Clear
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Host Filters -->
<div class="space-y-3">
<h4 class="text-xs font-medium text-gray-500 uppercase flex items-center gap-2">
<Server class="w-3 h-3" />
Host
</h4>
<div class="grid grid-cols-2 gap-3">
<input
type="text"
placeholder="Type"
class="input input-sm input-bordered w-full"
bind:value={filterHostType}
/>
<input
type="text"
placeholder="ID"
class="input input-sm input-bordered w-full"
bind:value={filterHostID}
/>
</div>
</div>
<!-- Client Filters -->
<div class="space-y-3">
<h4 class="text-xs font-medium text-gray-500 uppercase flex items-center gap-2">
<Monitor class="w-3 h-3" />
Client
</h4>
<div class="grid grid-cols-2 gap-3">
<input
type="text"
placeholder="Type"
class="input input-sm input-bordered w-full"
bind:value={filterClientType}
/>
<input
type="text"
placeholder="ID"
class="input input-sm input-bordered w-full"
bind:value={filterClientID}
/>
</div>
</div>
</div>
</div>
<!-- Data Table -->
<div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if !loading && filteredTestMaps.length === 0}
<!-- Empty State -->
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="bg-base-200 rounded-full p-6 mb-4">
<Link class="w-12 h-12 text-gray-400" />
</div>
<h3 class="text-base font-semibold text-gray-700 mb-2">
{#if filterHostType || filterHostID || filterClientType || filterClientID}
No mappings match your filters
{:else}
No test mappings found
{/if}
</h3>
<p class="text-xs text-gray-500 text-center max-w-md mb-6">
{#if filterHostType || filterHostID || filterClientType || filterClientID}
Try adjusting your filter criteria or clear the filters to see all mappings.
{:else}
Test mappings connect tests between host systems (like HIS) and client systems. Add your first mapping to get started.
{/if}
</p>
{#if filterHostType || filterHostID || filterClientType || filterClientID}
<button class="btn btn-outline" onclick={clearFilters}>
Clear Filters
</button>
{:else}
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Mapping
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={filteredTestMaps}
{loading}
emptyMessage="No test mappings found"
hover={true}
bordered={false}
>
{#snippet cell({ column, row, value })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-1">
<button
class="btn btn-sm btn-ghost"
onclick={() => openEditModal(row)}
title="Edit mapping"
>
<Edit2 class="w-4 h-4" />
</button>
<button
class="btn btn-sm btn-ghost text-error"
onclick={() => confirmDelete(row)}
title="Delete mapping"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else}
{value || '-'}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
</div>
<!-- Test Mapping Modal -->
<TestMapModal
bind:open={modalOpen}
mode={modalMode}
initialData={modalData}
{containers}
onSave={handleModalSave}
/>
<!-- Delete Confirmation Modal -->
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete Mapping" size="sm">
<div class="py-2">
<p class="text-base-content/80">Are you sure you want to delete this test mapping?</p>
<div class="bg-base-200 rounded-lg p-3 mt-3 space-y-1">
<p class="text-sm">
<span class="text-gray-500">Host:</span>
<strong class="text-base-content">{deleteItem?.HostType} / {deleteItem?.HostID}</strong>
</p>
<p class="text-sm">
<span class="text-gray-500">Client:</span>
<strong class="text-base-content">{deleteItem?.ClientType} / {deleteItem?.ClientID}</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={() => (deleteConfirmOpen = false)} type="button" disabled={deleting}>
Cancel
</button>
<button class="btn btn-error" onclick={handleDelete} disabled={deleting} type="button">
{#if deleting}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{deleting ? 'Deleting...' : 'Delete'}
</button>
{/snippet}
</Modal>

View File

@ -0,0 +1,414 @@
<script>
import Modal from '$lib/components/Modal.svelte';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import { createTestMap, updateTestMap } from '$lib/api/testmap.js';
import {
Plus,
Trash2,
Server,
Monitor,
FlaskConical,
} from 'lucide-svelte';
let { open = $bindable(false), mode = 'create', initialData = null, containers = [], onSave } = $props();
let saving = $state(false);
// Modal context (shared for all rows)
let modalContext = $state({
HostType: '',
HostID: '',
ClientType: '',
ClientID: '',
});
// Editable rows in modal
let modalRows = $state([]);
// Form errors
let formErrors = $state({});
const hostTypes = ['HIS', 'SITE', 'WST', 'INST'];
const clientTypes = ['HIS', 'SITE', 'WST', 'INST'];
// Initialize modal when open changes
$effect(() => {
if (open) {
initializeModal();
}
});
function initializeModal() {
formErrors = {};
if (mode === 'edit' && initialData) {
modalContext = {
HostType: initialData.HostType || '',
HostID: initialData.HostID || '',
ClientType: initialData.ClientType || '',
ClientID: initialData.ClientID || '',
};
modalRows = [{
TestMapID: initialData.TestMapID,
HostTestCode: initialData.HostTestCode || '',
HostTestName: initialData.HostTestName || '',
ConDefID: initialData.ConDefID || null,
ClientTestCode: initialData.ClientTestCode || '',
ClientTestName: initialData.ClientTestName || '',
isNew: false,
}];
} else {
modalContext = {
HostType: '',
HostID: '',
ClientType: '',
ClientID: '',
};
modalRows = [createEmptyRow()];
}
}
function createEmptyRow() {
return {
TestMapID: null,
HostTestCode: '',
HostTestName: '',
ConDefID: null,
ClientTestCode: '',
ClientTestName: '',
isNew: true,
};
}
function addMappingRow() {
modalRows = [...modalRows, createEmptyRow()];
}
function removeMappingRow(index) {
modalRows = modalRows.filter((_, i) => i !== index);
}
function updateRowField(index, field, value) {
modalRows = modalRows.map((row, i) => {
if (i === index) {
return { ...row, [field]: value };
}
return row;
});
}
function validateModal() {
const errors = {};
if (!modalContext.HostType) {
errors.HostType = 'Host Type is required';
}
if (!modalContext.HostID) {
errors.HostID = 'Host ID is required';
}
if (!modalContext.ClientType) {
errors.ClientType = 'Client Type is required';
}
if (!modalContext.ClientID) {
errors.ClientID = 'Client ID is required';
}
// Validate each row
const rowErrors = [];
modalRows.forEach((row, index) => {
const rowError = {};
const hasAnyField = row.HostTestCode || row.HostTestName || row.ClientTestCode || row.ClientTestName;
if (!hasAnyField) {
rowError.empty = 'At least one field must be filled';
}
if (modalContext.ClientType === 'INST' && !row.ConDefID) {
rowError.ConDefID = 'Container is required for INST type';
}
if (Object.keys(rowError).length > 0) {
rowErrors[index] = rowError;
}
});
if (rowErrors.length > 0) {
errors.rows = rowErrors;
}
formErrors = errors;
return Object.keys(errors).length === 0;
}
async function handleSave() {
if (!validateModal()) {
return;
}
saving = true;
try {
const promises = modalRows.map(async (row) => {
const payload = {
HostType: modalContext.HostType,
HostID: modalContext.HostID,
HostTestCode: row.HostTestCode,
HostTestName: row.HostTestName,
ClientType: modalContext.ClientType,
ClientID: modalContext.ClientID,
ConDefID: modalContext.ClientType === 'INST' ? row.ConDefID : null,
ClientTestCode: row.ClientTestCode,
ClientTestName: row.ClientTestName,
};
if (mode === 'create' || row.isNew) {
return createTestMap(payload);
} else {
return updateTestMap({ ...payload, TestMapID: row.TestMapID });
}
});
await Promise.all(promises);
toastSuccess(`Test mapping${modalRows.length > 1 ? 's' : ''} saved successfully`);
open = false;
onSave?.();
} catch (err) {
toastError(err.message || 'Failed to save test mapping');
} finally {
saving = false;
}
}
function getContainerName(conDefId) {
if (!conDefId) return '-';
const container = containers.find((c) => c.ConDefID === conDefId);
return container ? container.ConName : '-';
}
</script>
<Modal
bind:open
title={mode === 'create' ? 'Add Test Mapping' : 'Edit Test Mapping'}
size="xl"
>
<div class="space-y-4 max-h-[70vh] overflow-y-auto">
<!-- 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">
<!-- Host Section -->
<div class="space-y-2">
<h3 class="text-xs font-medium text-gray-600 uppercase tracking-wide flex items-center gap-1">
<Server class="w-3 h-3" />
Host
</h3>
<div class="grid grid-cols-2 gap-2">
<div class="form-control">
<label class="label text-xs" for="hostType">
<span class="label-text">Type</span>
<span class="label-text-alt text-error">*</span>
</label>
<select
id="hostType"
class="select select-xs select-bordered"
bind:value={modalContext.HostType}
disabled={mode === 'edit'}
>
<option value="">Select...</option>
{#each hostTypes as type (type)}
<option value={type}>{type}</option>
{/each}
</select>
{#if formErrors.HostType}
<span class="text-xs text-error">{formErrors.HostType}</span>
{/if}
</div>
<div class="form-control">
<label class="label text-xs" for="hostID">
<span class="label-text">ID</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
id="hostID"
type="text"
class="input input-xs input-bordered"
bind:value={modalContext.HostID}
placeholder="Host ID"
disabled={mode === 'edit'}
/>
{#if formErrors.HostID}
<span class="text-xs text-error">{formErrors.HostID}</span>
{/if}
</div>
</div>
</div>
<!-- Client Section -->
<div class="space-y-2">
<h3 class="text-xs font-medium text-gray-600 uppercase tracking-wide flex items-center gap-1">
<Monitor class="w-3 h-3" />
Client
</h3>
<div class="grid grid-cols-2 gap-2">
<div class="form-control">
<label class="label text-xs" for="clientType">
<span class="label-text">Type</span>
<span class="label-text-alt text-error">*</span>
</label>
<select
id="clientType"
class="select select-xs select-bordered"
bind:value={modalContext.ClientType}
disabled={mode === 'edit'}
>
<option value="">Select...</option>
{#each clientTypes as type (type)}
<option value={type}>{type}</option>
{/each}
</select>
{#if formErrors.ClientType}
<span class="text-xs text-error">{formErrors.ClientType}</span>
{/if}
</div>
<div class="form-control">
<label class="label text-xs" for="clientID">
<span class="label-text">ID</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
id="clientID"
type="text"
class="input input-xs input-bordered"
bind:value={modalContext.ClientID}
placeholder="Client ID"
disabled={mode === 'edit'}
/>
{#if formErrors.ClientID}
<span class="text-xs text-error">{formErrors.ClientID}</span>
{/if}
</div>
</div>
</div>
</div>
<!-- Middle Section: Editable Table -->
<div class="space-y-2">
<h3 class="text-xs font-medium text-gray-600 uppercase tracking-wide flex items-center gap-1">
<FlaskConical class="w-3 h-3" />
Test Mappings
<span class="text-gray-400 font-normal">({modalRows.length} row{modalRows.length !== 1 ? 's' : ''})</span>
</h3>
<div class="overflow-x-auto border border-base-300 rounded-lg">
<table class="table table-compact w-full">
<thead class="bg-base-200">
<tr>
<th class="text-xs">Host Test Code</th>
<th class="text-xs">Host Test Name</th>
<th class="text-xs w-48">Container</th>
<th class="text-xs">Client Test Code</th>
<th class="text-xs">Client Test Name</th>
<th class="text-xs w-10"></th>
</tr>
</thead>
<tbody>
{#each modalRows as row, index (index)}
<tr class="hover:bg-base-100">
<td>
<input
type="text"
class="input input-xs input-bordered w-full"
bind:value={row.HostTestCode}
placeholder="Code"
/>
</td>
<td>
<input
type="text"
class="input input-xs input-bordered w-full"
bind:value={row.HostTestName}
placeholder="Name"
/>
</td>
<td>
{#if modalContext.ClientType === 'INST'}
<select
class="select select-xs select-bordered w-full"
bind:value={row.ConDefID}
>
<option value={null}>Select container...</option>
{#each containers as container (container.ConDefID)}
<option value={container.ConDefID}>
{container.ConName}
</option>
{/each}
</select>
{#if formErrors.rows?.[index]?.ConDefID}
<span class="text-xs text-error">{formErrors.rows[index].ConDefID}</span>
{/if}
{:else}
<select class="select select-xs select-bordered w-full" disabled>
<option>Only for INST</option>
</select>
{/if}
</td>
<td>
<input
type="text"
class="input input-xs input-bordered w-full"
bind:value={row.ClientTestCode}
placeholder="Code"
/>
</td>
<td>
<input
type="text"
class="input input-xs input-bordered w-full"
bind:value={row.ClientTestName}
placeholder="Name"
/>
</td>
<td>
<button
class="btn btn-ghost btn-xs text-error"
onclick={() => removeMappingRow(index)}
disabled={modalRows.length === 1}
title="Remove row"
>
<Trash2 class="w-3 h-3" />
</button>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{#if formErrors.rows}
{#each Object.entries(formErrors.rows) as [idx, error]}
{#if error.empty}
<p class="text-xs text-error">Row {parseInt(idx) + 1}: {error.empty}</p>
{/if}
{/each}
{/if}
</div>
<!-- Bottom Section: Add Button -->
<div class="flex justify-center pt-2 border-t border-base-300">
<button class="btn btn-sm btn-outline" onclick={addMappingRow}>
<Plus class="w-4 h-4 mr-1" />
Add Mapping
</button>
</div>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (open = false)} type="button" disabled={saving}>
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 ${modalRows.length} Mapping${modalRows.length !== 1 ? 's' : ''}`}
</button>
{/snippet}
</Modal>

View File

@ -34,27 +34,27 @@
try { try {
const params = searchQuery ? { key: searchQuery } : {}; const params = searchQuery ? { key: searchQuery } : {};
const response = await fetchValueSets(params); const response = await fetchValueSets(params);
let dataArray = []; let dataArray = [];
if (response.status === 'success' && response.data) { if (response.status === 'success' && response.data) {
if (typeof response.data === 'object' && !Array.isArray(response.data)) { if (Array.isArray(response.data)) {
// New API format: { value, label, count }
dataArray = response.data.map((item) => ({
ValueSetKey: item.value,
Name: item.label,
ItemCount: item.count,
}));
} else if (typeof response.data === 'object') {
// Fallback for old format: { key: count }
dataArray = Object.entries(response.data).map(([key, count]) => ({ dataArray = Object.entries(response.data).map(([key, count]) => ({
ValueSetKey: key, ValueSetKey: key,
Name: key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()), Name: key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()),
ItemCount: count, ItemCount: count,
})); }));
} else if (Array.isArray(response.data)) {
dataArray = response.data;
} }
} }
if (searchQuery) {
dataArray = dataArray.filter((vs) =>
vs.ValueSetKey?.toLowerCase().includes(searchQuery.toLowerCase())
);
}
valueSets = dataArray; valueSets = dataArray;
} catch (err) { } catch (err) {
console.error('Load error:', err); console.error('Load error:', err);