feat: add test map management module
This commit is contained in:
parent
3b8a935b46
commit
beb3235470
178
src/lib/api/testmap.js
Normal file
178
src/lib/api/testmap.js
Normal 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
|
||||
};
|
||||
}
|
||||
@ -18,9 +18,9 @@ import {
|
||||
Globe,
|
||||
ChevronDown,
|
||||
TestTube,
|
||||
ShieldCheck,
|
||||
FileText,
|
||||
X
|
||||
X,
|
||||
Link
|
||||
} from 'lucide-svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { goto } from '$app/navigation';
|
||||
@ -30,9 +30,7 @@ import {
|
||||
|
||||
// Collapsible section states - default collapsed
|
||||
let laboratoryExpanded = $state(false);
|
||||
let qualityControlExpanded = $state(false);
|
||||
let masterDataExpanded = $state(false);
|
||||
let administrationExpanded = $state(false);
|
||||
|
||||
// Load states from localStorage on mount
|
||||
$effect(() => {
|
||||
@ -42,9 +40,7 @@ import {
|
||||
try {
|
||||
const parsed = JSON.parse(savedStates);
|
||||
laboratoryExpanded = parsed.laboratory ?? false;
|
||||
qualityControlExpanded = parsed.qualityControl ?? false;
|
||||
masterDataExpanded = parsed.masterData ?? false;
|
||||
administrationExpanded = parsed.administration ?? false;
|
||||
} catch (e) {
|
||||
// Keep defaults if parsing fails
|
||||
}
|
||||
@ -57,9 +53,7 @@ import {
|
||||
if (browser) {
|
||||
localStorage.setItem('sidebar_section_states', JSON.stringify({
|
||||
laboratory: laboratoryExpanded,
|
||||
qualityControl: qualityControlExpanded,
|
||||
masterData: masterDataExpanded,
|
||||
administration: administrationExpanded
|
||||
masterData: masterDataExpanded
|
||||
}));
|
||||
}
|
||||
});
|
||||
@ -68,9 +62,7 @@ import {
|
||||
$effect(() => {
|
||||
if (!isOpen) {
|
||||
laboratoryExpanded = false;
|
||||
qualityControlExpanded = false;
|
||||
masterDataExpanded = false;
|
||||
administrationExpanded = false;
|
||||
}
|
||||
});
|
||||
|
||||
@ -96,26 +88,12 @@ function toggleLaboratory() {
|
||||
laboratoryExpanded = !laboratoryExpanded;
|
||||
}
|
||||
|
||||
function toggleQualityControl() {
|
||||
if (!isOpen) {
|
||||
expandSidebar();
|
||||
}
|
||||
qualityControlExpanded = !qualityControlExpanded;
|
||||
}
|
||||
|
||||
function toggleMasterData() {
|
||||
if (!isOpen) {
|
||||
expandSidebar();
|
||||
}
|
||||
masterDataExpanded = !masterDataExpanded;
|
||||
}
|
||||
|
||||
function toggleAdministration() {
|
||||
if (!isOpen) {
|
||||
expandSidebar();
|
||||
}
|
||||
administrationExpanded = !administrationExpanded;
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Mobile Overlay Backdrop -->
|
||||
@ -195,28 +173,6 @@ function toggleLaboratory() {
|
||||
{/if}
|
||||
</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}
|
||||
<li class="menu-title uppercase font-bold text-xs text-secondary/70 mt-4">Analytics</li>
|
||||
{/if}
|
||||
@ -259,36 +215,16 @@ function toggleLaboratory() {
|
||||
<ul class="submenu">
|
||||
<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/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/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/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/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/geography" class="submenu-link"><Globe size={16} /> Geography</a></li>
|
||||
</ul>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
329
src/routes/(app)/master-data/testmap/+page.svelte
Normal file
329
src/routes/(app)/master-data/testmap/+page.svelte
Normal 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>
|
||||
414
src/routes/(app)/master-data/testmap/TestMapModal.svelte
Normal file
414
src/routes/(app)/master-data/testmap/TestMapModal.svelte
Normal 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>
|
||||
@ -34,27 +34,27 @@
|
||||
try {
|
||||
const params = searchQuery ? { key: searchQuery } : {};
|
||||
const response = await fetchValueSets(params);
|
||||
|
||||
|
||||
let dataArray = [];
|
||||
|
||||
|
||||
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]) => ({
|
||||
ValueSetKey: key,
|
||||
Name: key.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase()),
|
||||
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;
|
||||
} catch (err) {
|
||||
console.error('Load error:', err);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user