refactor(testmap): reorganize testmap docs and update components

This commit is contained in:
mahdahar 2026-02-26 16:48:27 +07:00
parent ecc4822a38
commit 9eef675a52
5 changed files with 931 additions and 5780 deletions

File diff suppressed because it is too large Load Diff

503
docs/testmap.yaml Normal file
View File

@ -0,0 +1,503 @@
/api/test/testmap:
get:
tags: [Tests]
summary: List all test mappings (unique groupings)
security:
- bearerAuth: []
responses:
'200':
description: List of unique test mapping groupings
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
data:
type: array
items:
type: object
properties:
HostType:
type: string
HostID:
type: string
HostName:
type: string
ClientType:
type: string
ClientID:
type: string
ClientName:
type: string
post:
tags: [Tests]
summary: Create test mapping (header only)
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
TestSiteID:
type: integer
description: Test Site ID (required)
HostType:
type: string
description: Host type code
HostID:
type: string
description: Host identifier
ClientType:
type: string
description: Client type code
ClientID:
type: string
description: Client identifier
details:
type: array
description: Optional detail records to create
items:
type: object
properties:
HostTestCode:
type: string
HostTestName:
type: string
ConDefID:
type: integer
ClientTestCode:
type: string
ClientTestName:
type: string
required:
- TestSiteID
responses:
'201':
description: Test mapping created
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
data:
type: integer
description: Created TestMapID
patch:
tags: [Tests]
summary: Update test mapping
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
TestMapID:
type: integer
description: Test Map ID (required)
TestSiteID:
type: integer
HostType:
type: string
HostID:
type: string
ClientType:
type: string
ClientID:
type: string
required:
- TestMapID
responses:
'200':
description: Test mapping updated
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
data:
type: integer
description: Updated TestMapID
delete:
tags: [Tests]
summary: Soft delete test mapping (cascades to details)
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
TestMapID:
type: integer
description: Test Map ID to delete (required)
required:
- TestMapID
responses:
'200':
description: Test mapping deleted successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
data:
type: integer
description: Deleted TestMapID
'404':
description: Test mapping not found or already deleted
/api/test/testmap/{id}:
get:
tags: [Tests]
summary: Get test mapping by ID with details
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Test Map ID
responses:
'200':
description: Test mapping details with nested detail records
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/tests.yaml#/TestMap'
'404':
description: Test mapping not found
/api/test/testmap/by-testsite/{testSiteID}:
get:
tags: [Tests]
summary: Get test mappings by test site with details
security:
- bearerAuth: []
parameters:
- name: testSiteID
in: path
required: true
schema:
type: integer
description: Test Site ID
responses:
'200':
description: List of test mappings with details for the test site
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: array
items:
$ref: '../components/schemas/tests.yaml#/TestMap'
/api/test/testmap/detail:
get:
tags: [Tests]
summary: List test mapping details
security:
- bearerAuth: []
parameters:
- name: TestMapID
in: query
schema:
type: integer
description: Filter by TestMapID
responses:
'200':
description: List of test mapping details
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: array
items:
$ref: '../components/schemas/tests.yaml#/TestMapDetail'
post:
tags: [Tests]
summary: Create test mapping detail
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
TestMapID:
type: integer
description: Test Map ID (required)
HostTestCode:
type: string
HostTestName:
type: string
ConDefID:
type: integer
ClientTestCode:
type: string
ClientTestName:
type: string
required:
- TestMapID
responses:
'201':
description: Test mapping detail created
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: integer
description: Created TestMapDetailID
patch:
tags: [Tests]
summary: Update test mapping detail
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
TestMapDetailID:
type: integer
description: Test Map Detail ID (required)
TestMapID:
type: integer
HostTestCode:
type: string
HostTestName:
type: string
ConDefID:
type: integer
ClientTestCode:
type: string
ClientTestName:
type: string
required:
- TestMapDetailID
responses:
'200':
description: Test mapping detail updated
delete:
tags: [Tests]
summary: Soft delete test mapping detail
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
TestMapDetailID:
type: integer
description: Test Map Detail ID to delete (required)
required:
- TestMapDetailID
responses:
'200':
description: Test mapping detail deleted
/api/test/testmap/detail/{id}:
get:
tags: [Tests]
summary: Get test mapping detail by ID
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Test Map Detail ID
responses:
'200':
description: Test mapping detail
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/tests.yaml#/TestMapDetail'
/api/test/testmap/detail/by-testmap/{testMapID}:
get:
tags: [Tests]
summary: Get test mapping details by test map ID
security:
- bearerAuth: []
parameters:
- name: testMapID
in: path
required: true
schema:
type: integer
description: Test Map ID
responses:
'200':
description: List of test mapping details
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: array
items:
$ref: '../components/schemas/tests.yaml#/TestMapDetail'
/api/test/testmap/detail/batch:
post:
tags: [Tests]
summary: Batch create test mapping details
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: array
items:
type: object
properties:
TestMapID:
type: integer
HostTestCode:
type: string
HostTestName:
type: string
ConDefID:
type: integer
ClientTestCode:
type: string
ClientTestName:
type: string
responses:
'200':
description: Batch create results
patch:
tags: [Tests]
summary: Batch update test mapping details
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: array
items:
type: object
properties:
TestMapDetailID:
type: integer
TestMapID:
type: integer
HostTestCode:
type: string
HostTestName:
type: string
ConDefID:
type: integer
ClientTestCode:
type: string
ClientTestName:
type: string
responses:
'200':
description: Batch update results
delete:
tags: [Tests]
summary: Batch delete test mapping details
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: array
items:
type: integer
description: TestMapDetailIDs to delete
responses:
'200':
description: Batch delete results

View File

@ -1,17 +1,26 @@
import { get, post, patch, del } from './client.js';
/**
* @typedef {Object} TestMap
* @typedef {Object} TestMapHeader
* @property {number} TestMapID - Mapping ID
* @property {string} HostType - Host Type (HIS, SITE, WST, INST)
* @property {string} HostID - Host ID
* @property {string} HostName - Host Name
* @property {string} ClientType - Client Type (HIS, SITE, WST, INST)
* @property {string} ClientID - Client ID
* @property {string} ClientName - Client Name
* @property {string} IsActive - Active status ('1' or '0')
*/
/**
* @typedef {Object} TestMapDetail
* @property {number} TestMapDetailID - Detail ID
* @property {number} TestMapID - Test Map ID (header)
* @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
@ -19,25 +28,42 @@ import { get, post, patch, del } from './client.js';
*/
/**
* @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} TestMapWithDetails
* @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
* @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
* @property {string} IsActive - Active status ('1' or '0')
* @property {TestMapDetail[]} details - Array of detail records
*/
/**
* @typedef {Object} TestMapListResponse
* @property {boolean} success
* @property {TestMap[]} data
* @property {TestMapHeader[]} data
* @property {string} [message]
*/
/**
* @typedef {Object} TestMapResponse
* @property {boolean} success
* @property {TestMap} data
* @property {TestMapWithDetails} data
* @property {string} [message]
*/
/**
* @typedef {Object} TestMapDetailResponse
* @property {boolean} success
* @property {TestMapDetail} data
* @property {string} [message]
*/
@ -46,49 +72,63 @@ import { get, post, patch, del } from './client.js';
* @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
* @property {Object[]} [details] - Optional detail records to create
*/
/**
* @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 {number} [TestSiteID] - Test Site ID
* @property {string} [HostType] - Host Type
* @property {string} [HostID] - Host ID
* @property {string} [ClientType] - Client Type
* @property {string} [ClientID] - Client ID
*/
/**
* @typedef {Object} CreateTestMapDetailPayload
* @property {number} TestMapID - Test Map 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
* @typedef {Object} UpdateTestMapDetailPayload
* @property {number} TestMapDetailID - Detail ID
* @property {number} [TestMapID] - Test Map ID
* @property {string} [HostTestCode] - Host Test Code
* @property {string} [HostTestName] - Host Test Name
* @property {number} [ConDefID] - Container Definition ID
* @property {string} [ClientTestCode] - Client Test Code
* @property {string} [ClientTestName] - Client Test Name
*/
// ==================== HEADER ENDPOINTS ====================
/**
* Fetch all test mapping headers (grouped by host/client)
* @returns {Promise<TestMapListResponse>} API response with headers list
*/
export async function fetchTestMaps() {
return get('/api/test/testmap');
}
/**
* Fetch a single test mapping by ID
* Fetch a single test mapping header by ID with details
* @param {number} id - Test Map ID
* @returns {Promise<TestMapResponse>} API response with mapping detail
* @returns {Promise<TestMapResponse>} API response with header and details
*/
export async function fetchTestMap(id) {
return get(`/api/test/testmap/${id}`);
}
/**
* Fetch test mappings by test site ID
* Fetch test mappings by test site ID with details
* @param {number} testSiteID - Test Site ID
* @returns {Promise<TestMapListResponse>} API response with mappings list
*/
@ -97,52 +137,123 @@ export async function fetchTestMapsByTestSite(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
* Create a new test mapping header
* @param {CreateTestMapPayload} data - Header data
* @returns {Promise<{success: boolean, data: number, message?: string}>} API response with created TestMapID
*/
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
* Update an existing test mapping header
* @param {UpdateTestMapPayload} data - Header data
* @returns {Promise<{success: boolean, data: number, message?: string}>} API response
*/
export async function updateTestMap(data) {
return patch('/api/test/testmap', data);
}
/**
* Soft delete a test mapping (set IsActive to '0')
* Soft delete a test mapping header (cascades to details)
* @param {number} id - Test Map ID
* @returns {Promise<{success: boolean, message?: string}>} API response
* @returns {Promise<{success: boolean, data: number, message?: string}>} API response
*/
export async function deleteTestMap(id) {
return del('/api/test/testmap', { body: JSON.stringify({ TestMapID: id }) });
}
// ==================== DETAIL ENDPOINTS ====================
/**
* Fetch all test mapping details (optionally filtered by TestMapID)
* @param {Object} params - Query parameters
* @param {number} [params.TestMapID] - Filter by TestMapID
* @returns {Promise<{success: boolean, data: TestMapDetail[], message?: string}>} API response
*/
export async function fetchTestMapDetails(params = {}) {
const query = new URLSearchParams();
if (params.TestMapID) query.append('TestMapID', params.TestMapID.toString());
const queryString = query.toString();
return get(queryString ? `/api/test/testmap/detail?${queryString}` : '/api/test/testmap/detail');
}
/**
* Fetch a single test mapping detail by ID
* @param {number} id - Test Map Detail ID
* @returns {Promise<TestMapDetailResponse>} API response with detail
*/
export async function fetchTestMapDetail(id) {
return get(`/api/test/testmap/detail/${id}`);
}
/**
* Fetch test mapping details by test map ID
* @param {number} testMapID - Test Map ID
* @returns {Promise<{success: boolean, data: TestMapDetail[], message?: string}>} API response
*/
export async function fetchTestMapDetailsByTestMap(testMapID) {
return get(`/api/test/testmap/detail/by-testmap/${testMapID}`);
}
/**
* Create a new test mapping detail
* @param {CreateTestMapDetailPayload} data - Detail data
* @returns {Promise<{success: boolean, data: number, message?: string}>} API response with created TestMapDetailID
*/
export async function createTestMapDetail(data) {
return post('/api/test/testmap/detail', data);
}
/**
* Update an existing test mapping detail
* @param {UpdateTestMapDetailPayload} data - Detail data
* @returns {Promise<{success: boolean, message?: string}>} API response
*/
export async function updateTestMapDetail(data) {
return patch('/api/test/testmap/detail', data);
}
/**
* Soft delete a test mapping detail
* @param {number} id - Test Map Detail ID
* @returns {Promise<{success: boolean, message?: string}>} API response
*/
export async function deleteTestMapDetail(id) {
return del('/api/test/testmap/detail', { body: JSON.stringify({ TestMapDetailID: id }) });
}
// ==================== BATCH DETAIL ENDPOINTS ====================
/**
* Batch create test mapping details
* @param {CreateTestMapDetailPayload[]} details - Array of detail records to create
* @returns {Promise<{success: boolean, message?: string}>} API response
*/
export async function batchCreateTestMapDetails(details) {
return post('/api/test/testmap/detail/batch', details);
}
/**
* Batch update test mapping details
* @param {UpdateTestMapDetailPayload[]} details - Array of detail records to update
* @returns {Promise<{success: boolean, message?: string}>} API response
*/
export async function batchUpdateTestMapDetails(details) {
return patch('/api/test/testmap/detail/batch', details);
}
/**
* Batch delete test mapping details
* @param {number[]} detailIDs - Array of TestMapDetailID to delete
* @returns {Promise<{success: boolean, message?: string}>} API response
*/
export async function batchDeleteTestMapDetails(detailIDs) {
return del('/api/test/testmap/detail/batch', { body: JSON.stringify(detailIDs) });
}
// ==================== VALIDATION ====================
/**
* Validate mapping form data
* @param {Object} data - Form data to validate
@ -176,3 +287,26 @@ export function validateTestMap(data) {
errors
};
}
/**
* Validate detail form data
* @param {Object} data - Form data to validate
* @returns {{valid: boolean, errors: Object}}
*/
export function validateTestMapDetail(data) {
const errors = {};
if (!data.TestMapID) {
errors.TestMapID = 'Test Map ID is required';
}
const hasAnyField = data.HostTestCode || data.HostTestName || data.ClientTestCode || data.ClientTestName;
if (!hasAnyField) {
errors.empty = 'At least one field must be filled';
}
return {
valid: Object.keys(errors).length === 0,
errors
};
}

View File

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import {
fetchTestMaps,
fetchTestMap,
deleteTestMap,
} from '$lib/api/testmap.js';
import { fetchContainers } from '$lib/api/containers.js';
@ -17,12 +18,10 @@
Link,
Server,
Monitor,
Filter,
X,
} from 'lucide-svelte';
let loading = $state(false);
let testMaps = $state([]);
let testMapHeaders = $state([]);
let containers = $state([]);
let modalOpen = $state(false);
let modalMode = $state('create');
@ -32,68 +31,14 @@
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
let deleteGroupMode = $state(false);
// Filter states
let filterHostType = $state('');
let filterHostID = $state('');
let filterClientType = $state('');
let filterClientID = $state('');
// System types for dropdowns
const SYSTEM_TYPES = ['HIS', 'SITE', 'WST', 'INST'];
// Derived unique values for ID dropdowns
let uniqueHostIDs = $derived([...new Set(testMaps.map(m => m.HostID).filter(Boolean))].sort());
let uniqueClientIDs = $derived([...new Set(testMaps.map(m => m.ClientID).filter(Boolean))].sort());
let modalLoading = $state(false);
const columns = [
{ key: 'HostInfo', label: 'Host System', class: 'w-48' },
{ key: 'ClientInfo', label: 'Client System', class: 'w-48' },
{ key: 'TestCount', label: 'Tests', class: 'w-24 text-center' },
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
];
// Group test mappings by HostType/HostID/ClientType/ClientID
let groupedTestMaps = $derived(() => {
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: [],
});
}
const group = groups.get(key);
group.mappings.push(mapping);
});
return Array.from(groups.values());
});
// Derived filtered grouped test maps
let filteredGroupedTestMaps = $derived(
groupedTestMaps().filter((group) => {
const matchesHostType =
!filterHostType || group.HostType === filterHostType;
const matchesHostID =
!filterHostID || group.HostID === filterHostID;
const matchesClientType =
!filterClientType || group.ClientType === filterClientType;
const matchesClientID =
!filterClientID || group.ClientID === filterClientID;
return matchesHostType && matchesHostID && matchesClientType && matchesClientID;
})
);
onMount(async () => {
await Promise.all([loadTestMaps(), loadContainers()]);
});
@ -102,10 +47,10 @@
loading = true;
try {
const response = await fetchTestMaps();
testMaps = Array.isArray(response.data) ? response.data : [];
testMapHeaders = Array.isArray(response.data) ? response.data : [];
} catch (err) {
toastError(err.message || 'Failed to load test mappings');
testMaps = [];
testMapHeaders = [];
} finally {
loading = false;
}
@ -128,12 +73,27 @@
modalOpen = true;
}
function openEditGroupModal(group) {
async function openEditGroupModal(group) {
modalLoading = true;
modalMode = 'edit';
modalGroupData = group;
// Pass the first mapping as initial data, modal will handle the rest
modalData = group.mappings[0] || null;
try {
// Use TestMapID directly from the grouped data (now returned by API)
modalGroupData = {
...group,
TestMapID: parseInt(group.TestMapID) || null,
mappings: [], // Will be populated by modal
};
modalData = null;
modalOpen = true;
} catch (err) {
toastError(err.message || 'Failed to load test mapping details');
modalGroupData = group;
modalData = null;
modalOpen = true;
} finally {
modalLoading = false;
}
}
function handleModalSave() {
@ -150,34 +110,26 @@
deleting = true;
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`);
// For delete, we need the TestMapID
// Since the list doesn't return TestMapID, we need to fetch it first
// This is a limitation of the current API - it should return TestMapID in the list
toastError('Delete functionality requires TestMapID which is not available in the list. Please contact administrator.');
// TODO: Implement once API returns TestMapID or provides delete by host/client endpoint
} else if (deleteItem?.TestMapID) {
// Delete single mapping (fallback)
await deleteTestMap(deleteItem.TestMapID);
toastSuccess('Test mapping deleted successfully');
}
deleteConfirmOpen = false;
deleteItem = null;
deleteGroupMode = false;
await loadTestMaps();
}
} catch (err) {
toastError(err.message || 'Failed to delete test mapping(s)');
} finally {
deleting = false;
}
}
function clearFilters() {
filterHostType = '';
filterHostID = '';
filterClientType = '';
filterClientID = '';
}
</script>
<div class="p-4">
@ -197,113 +149,29 @@
</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">
<select
class="select select-sm select-bordered w-full"
bind:value={filterHostType}
>
<option value="">All Types</option>
{#each SYSTEM_TYPES as type}
<option value={type}>{type}</option>
{/each}
</select>
<select
class="select select-sm select-bordered w-full"
bind:value={filterHostID}
>
<option value="">All IDs</option>
{#each uniqueHostIDs as id}
<option value={id}>{id}</option>
{/each}
</select>
</div>
</div>
<!-- Client Filters -->
<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">
<select
class="select select-sm select-bordered w-full"
bind:value={filterClientType}
>
<option value="">All Types</option>
{#each SYSTEM_TYPES as type}
<option value={type}>{type}</option>
{/each}
</select>
<select
class="select select-sm select-bordered w-full"
bind:value={filterClientID}
>
<option value="">All IDs</option>
{#each uniqueClientIDs as id}
<option value={id}>{id}</option>
{/each}
</select>
</div>
</div>
</div>
</div>
<!-- Data Table -->
<div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if !loading && filteredGroupedTestMaps.length === 0}
{#if !loading && testMapHeaders.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}
<p class="text-sm text-gray-500 text-center max-w-md mb-6">
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={filteredGroupedTestMaps}
data={testMapHeaders}
{loading}
emptyMessage="No test mappings found"
hover={true}
@ -314,35 +182,29 @@
<div class="flex items-center gap-2">
<Server class="w-4 h-4 text-primary flex-shrink-0" />
<div class="font-medium text-sm">
{row.HostType || '-'} - {row.HostID || '-'}
{row.HostName || row.HostID || '-'}
</div>
</div>
{:else if column.key === 'ClientInfo'}
<div class="flex items-center gap-2">
<Monitor class="w-4 h-4 text-secondary flex-shrink-0" />
<div class="font-medium text-sm">
{row.ClientType || '-'} - {row.ClientID || '-'}
{row.ClientName || row.ClientID || '-'}
</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 === 'actions'}
<div class="flex justify-center gap-1">
<button
class="btn btn-sm btn-ghost"
onclick={() => openEditGroupModal(row)}
title="Edit all {row.mappings.length} test mapping(s)"
title="Edit test mapping group"
>
<Edit2 class="w-4 h-4" />
</button>
<button
class="btn btn-sm btn-ghost text-error"
onclick={() => confirmDeleteGroup(row)}
title="Delete all {row.mappings.length} test mapping(s)"
title="Delete test mapping group"
>
<Trash2 class="w-4 h-4" />
</button>
@ -363,6 +225,7 @@
initialData={modalData}
groupData={modalGroupData}
{containers}
loading={modalLoading}
onSave={handleModalSave}
/>
@ -371,7 +234,7 @@
<div class="py-2">
<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?
Are you sure you want to delete this test mapping group?
{:else}
Are you sure you want to delete this test mapping?
{/if}
@ -379,11 +242,11 @@
<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>
<strong class="text-base-content">{deleteItem?.HostName || 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>
<strong class="text-base-content">{deleteItem?.ClientName || deleteItem?.ClientID}</strong>
</p>
</div>

View File

@ -1,7 +1,15 @@
<script>
import { untrack } from 'svelte';
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 {
createTestMap,
updateTestMap,
fetchTestMapDetailsByTestMap,
batchCreateTestMapDetails,
batchUpdateTestMapDetails,
batchDeleteTestMapDetails,
} from '$lib/api/testmap.js';
import {
Plus,
Trash2,
@ -11,12 +19,14 @@
AlertCircle,
} from 'lucide-svelte';
let { open = $bindable(false), mode = 'create', initialData = null, groupData = null, containers = [], onSave } = $props();
let { open = $bindable(false), mode = 'create', initialData = null, groupData = null, containers = [], loading = false, onSave } = $props();
let saving = $state(false);
let localLoading = $state(false);
// Modal context (shared for all rows)
let modalContext = $state({
TestMapID: null,
HostType: '',
HostID: '',
ClientType: '',
@ -26,51 +36,71 @@
// Editable rows in modal
let modalRows = $state([]);
// Original rows for comparison in edit mode
let originalRows = $state([]);
// Form errors
let formErrors = $state({});
// Track previous mode and groupData to detect actual changes
let previousMode = $state(mode);
let previousGroupData = $state(null);
const hostTypes = ['HIS', 'SITE', 'WST', 'INST'];
const clientTypes = ['HIS', 'SITE', 'WST', 'INST'];
// Initialize modal when open changes
// Initialize modal when open changes to true, or when mode/groupData actually change
$effect(() => {
if (open) {
const isOpen = open;
const currentMode = mode;
const currentGroupData = groupData;
if (!isOpen) return;
// Only initialize if:
// 1. Just opened (open changed from false to true)
// 2. Mode actually changed
// 3. GroupData actually changed (different reference)
const shouldInitialize =
currentMode !== untrack(() => previousMode) ||
currentGroupData !== untrack(() => previousGroupData);
if (shouldInitialize) {
previousMode = currentMode;
previousGroupData = currentGroupData;
initializeModal();
}
});
function initializeModal() {
formErrors = {};
originalRows = [];
if (mode === 'edit' && groupData) {
// Edit mode with group data - load all mappings in the group
modalContext = {
TestMapID: groupData.TestMapID || null,
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,
}));
// Fetch existing details from API
fetchDetailsForGroup();
} else if (mode === 'edit' && initialData) {
// Legacy edit mode (single mapping)
modalContext = {
TestMapID: initialData.TestMapID || null,
HostType: initialData.HostType || '',
HostID: initialData.HostID || '',
ClientType: initialData.ClientType || '',
ClientID: initialData.ClientID || '',
};
modalRows = [{
TestMapDetailID: null,
TestMapID: initialData.TestMapID,
TestSiteID: null,
HostTestCode: initialData.HostTestCode || '',
HostTestName: initialData.HostTestName || '',
ConDefID: initialData.ConDefID || null,
@ -78,9 +108,11 @@
ClientTestName: initialData.ClientTestName || '',
isNew: false,
}];
originalRows = JSON.parse(JSON.stringify(modalRows));
} else {
// Create mode
modalContext = {
TestMapID: null,
HostType: '',
HostID: '',
ClientType: '',
@ -90,9 +122,42 @@
}
}
async function fetchDetailsForGroup() {
if (!modalContext.TestMapID) return;
localLoading = true;
try {
const response = await fetchTestMapDetailsByTestMap(modalContext.TestMapID);
const details = response.data || [];
modalRows = details.map((detail) => ({
TestMapDetailID: parseInt(detail.TestMapDetailID) || null,
TestMapID: parseInt(detail.TestMapID) || null,
TestSiteID: detail.TestSiteID ? parseInt(detail.TestSiteID) : null,
HostTestCode: detail.HostTestCode || '',
HostTestName: detail.HostTestName || '',
ConDefID: detail.ConDefID ? parseInt(detail.ConDefID) : null,
ClientTestCode: detail.ClientTestCode || '',
ClientTestName: detail.ClientTestName || '',
isNew: false,
}));
// Store a copy for comparison
originalRows = JSON.parse(JSON.stringify(modalRows));
} catch (err) {
toastError(err.message || 'Failed to fetch test map details');
modalRows = [];
originalRows = [];
} finally {
localLoading = false;
}
}
function createEmptyRow() {
return {
TestMapDetailID: null,
TestMapID: null,
TestSiteID: null,
HostTestCode: '',
HostTestName: '',
ConDefID: null,
@ -164,34 +229,111 @@
return;
}
if (mode === 'create') {
await handleCreate();
} else {
await handleUpdate();
}
}
async function handleCreate() {
saving = true;
try {
const promises = modalRows.map(async (row) => {
// Create header with details array
const payload = {
HostType: modalContext.HostType,
HostID: modalContext.HostID,
HostTestCode: row.HostTestCode,
HostTestName: row.HostTestName,
ClientType: modalContext.ClientType,
ClientID: modalContext.ClientID,
details: modalRows.map((row) => ({
HostTestCode: row.HostTestCode,
HostTestName: row.HostTestName,
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`);
await createTestMap(payload);
toastSuccess(`Test mapping${modalRows.length > 1 ? 's' : ''} created successfully`);
open = false;
onSave?.();
} catch (err) {
toastError(err.message || 'Failed to save test mapping');
toastError(err.message || 'Failed to create test mapping');
} finally {
saving = false;
}
}
async function handleUpdate() {
saving = true;
try {
// Compare current rows with original rows
const currentIds = new Set(modalRows.map((r) => r.TestMapDetailID).filter((id) => id !== null));
const originalIds = new Set(originalRows.map((r) => r.TestMapDetailID).filter((id) => id !== null));
// Find deleted IDs (in original but not in current)
const deletedIds = [...originalIds].filter((id) => !currentIds.has(id));
// Find new rows (no TestMapDetailID)
const newRows = modalRows.filter((row) => row.TestMapDetailID === null);
// Find updated rows (has TestMapDetailID and exists in original)
const updatedRows = modalRows.filter((row) => {
if (row.TestMapDetailID === null) return false;
const original = originalRows.find((r) => r.TestMapDetailID === row.TestMapDetailID);
if (!original) return false;
// Check if any field changed
return (
row.HostTestCode !== original.HostTestCode ||
row.HostTestName !== original.HostTestName ||
row.ConDefID !== original.ConDefID ||
row.ClientTestCode !== original.ClientTestCode ||
row.ClientTestName !== original.ClientTestName
);
});
// Execute batch operations
const promises = [];
if (deletedIds.length > 0) {
promises.push(batchDeleteTestMapDetails(deletedIds));
}
if (newRows.length > 0) {
const newDetails = newRows.map((row) => ({
TestMapID: modalContext.TestMapID,
HostTestCode: row.HostTestCode,
HostTestName: row.HostTestName,
ConDefID: modalContext.ClientType === 'INST' ? row.ConDefID : null,
ClientTestCode: row.ClientTestCode,
ClientTestName: row.ClientTestName,
}));
promises.push(batchCreateTestMapDetails(newDetails));
}
if (updatedRows.length > 0) {
const updatedDetails = updatedRows.map((row) => ({
TestMapDetailID: row.TestMapDetailID,
TestMapID: modalContext.TestMapID,
HostTestCode: row.HostTestCode,
HostTestName: row.HostTestName,
ConDefID: modalContext.ClientType === 'INST' ? row.ConDefID : null,
ClientTestCode: row.ClientTestCode,
ClientTestName: row.ClientTestName,
}));
promises.push(batchUpdateTestMapDetails(updatedDetails));
}
if (promises.length > 0) {
await Promise.all(promises);
}
toastSuccess(`Test mapping${modalRows.length > 1 ? 's' : ''} updated successfully`);
open = false;
onSave?.();
} catch (err) {
toastError(err.message || 'Failed to update test mapping');
} finally {
saving = false;
}
@ -209,7 +351,16 @@
title={mode === 'create' ? 'Add Test Mapping' : `Edit Test Mapping${groupData ? 's' : ''} (${modalRows.length})`}
size="xl"
>
<div class="flex flex-col max-h-[70vh]">
<div class="flex flex-col max-h-[70vh] relative">
<!-- Loading Overlay -->
{#if loading || localLoading}
<div class="absolute inset-0 bg-base-100/80 z-50 flex items-center justify-center">
<div class="flex flex-col items-center gap-2">
<span class="loading loading-spinner loading-lg text-primary"></span>
<span class="text-sm text-gray-500">Loading test mappings...</span>
</div>
</div>
{/if}
<!-- Sticky Top Section: Info banner + Host and Client -->
<div class="flex-shrink-0 bg-base-100 z-10">
<!-- Info banner for group editing -->
@ -420,7 +571,7 @@
</div>
{#if formErrors.rows}
{#each Object.entries(formErrors.rows) as [idx, error]}
{#each Object.entries(formErrors.rows) as [idx, error] (idx)}
{#if error.empty}
<p class="text-xs text-error">Row {parseInt(idx) + 1}: {error.empty}</p>
{/if}