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'; import { get, post, patch, del } from './client.js';
/** /**
* @typedef {Object} TestMap * @typedef {Object} TestMapHeader
* @property {number} TestMapID - Mapping ID * @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 {number} TestSiteID - Test Site ID
* @property {string} TestSiteCode - Test Code * @property {string} TestSiteCode - Test Code
* @property {string} TestSiteName - Test Name * @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} HostTestCode - Host Test Code
* @property {string} HostTestName - Host Test Name * @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 {number} ConDefID - Container Definition ID
* @property {string} ClientTestCode - Client Test Code * @property {string} ClientTestCode - Client Test Code
* @property {string} ClientTestName - Client Test Name * @property {string} ClientTestName - Client Test Name
@ -19,25 +28,42 @@ import { get, post, patch, del } from './client.js';
*/ */
/** /**
* @typedef {Object} TestMapFilterOptions * @typedef {Object} TestMapWithDetails
* @property {string} [hostType] - Filter by Host Type * @property {number} TestMapID - Mapping ID
* @property {string} [hostID] - Filter by Host ID * @property {number} TestSiteID - Test Site ID
* @property {string} [clientType] - Filter by Client Type * @property {string} TestSiteCode - Test Code
* @property {string} [clientID] - Filter by Client ID * @property {string} TestSiteName - Test Name
* @property {string} [search] - Search term * @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 * @typedef {Object} TestMapListResponse
* @property {boolean} success * @property {boolean} success
* @property {TestMap[]} data * @property {TestMapHeader[]} data
* @property {string} [message] * @property {string} [message]
*/ */
/** /**
* @typedef {Object} TestMapResponse * @typedef {Object} TestMapResponse
* @property {boolean} success * @property {boolean} success
* @property {TestMap} data * @property {TestMapWithDetails} data
* @property {string} [message]
*/
/**
* @typedef {Object} TestMapDetailResponse
* @property {boolean} success
* @property {TestMapDetail} data
* @property {string} [message] * @property {string} [message]
*/ */
@ -46,49 +72,63 @@ import { get, post, patch, del } from './client.js';
* @property {number} TestSiteID - Test Site ID * @property {number} TestSiteID - Test Site ID
* @property {string} HostType - Host Type * @property {string} HostType - Host Type
* @property {string} HostID - Host ID * @property {string} HostID - Host ID
* @property {string} HostTestCode - Host Test Code
* @property {string} HostTestName - Host Test Name
* @property {string} ClientType - Client Type * @property {string} ClientType - Client Type
* @property {string} ClientID - Client ID * @property {string} ClientID - Client ID
* @property {number} ConDefID - Container Definition ID * @property {Object[]} [details] - Optional detail records to create
* @property {string} ClientTestCode - Client Test Code
* @property {string} ClientTestName - Client Test Name
*/ */
/** /**
* @typedef {Object} UpdateTestMapPayload * @typedef {Object} UpdateTestMapPayload
* @property {number} TestMapID - Mapping ID * @property {number} TestMapID - Mapping ID
* @property {number} TestSiteID - Test Site ID * @property {number} [TestSiteID] - Test Site ID
* @property {string} HostType - Host Type * @property {string} [HostType] - Host Type
* @property {string} HostID - Host ID * @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} HostTestCode - Host Test Code
* @property {string} HostTestName - Host Test Name * @property {string} HostTestName - Host Test Name
* @property {string} ClientType - Client Type
* @property {string} ClientID - Client ID
* @property {number} ConDefID - Container Definition ID * @property {number} ConDefID - Container Definition ID
* @property {string} ClientTestCode - Client Test Code * @property {string} ClientTestCode - Client Test Code
* @property {string} ClientTestName - Client Test Name * @property {string} ClientTestName - Client Test Name
*/ */
/** /**
* Fetch all test mappings * @typedef {Object} UpdateTestMapDetailPayload
* @returns {Promise<TestMapListResponse>} API response with mappings list * @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() { export async function fetchTestMaps() {
return get('/api/test/testmap'); 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 * @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) { export async function fetchTestMap(id) {
return get(`/api/test/testmap/${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 * @param {number} testSiteID - Test Site ID
* @returns {Promise<TestMapListResponse>} API response with mappings list * @returns {Promise<TestMapListResponse>} API response with mappings list
*/ */
@ -97,52 +137,123 @@ export async function fetchTestMapsByTestSite(testSiteID) {
} }
/** /**
* Fetch test mappings by host * Create a new test mapping header
* @param {string} hostType - Host Type * @param {CreateTestMapPayload} data - Header data
* @param {string} hostID - Host ID * @returns {Promise<{success: boolean, data: number, message?: string}>} API response with created TestMapID
* @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) { export async function createTestMap(data) {
return post('/api/test/testmap', data); return post('/api/test/testmap', data);
} }
/** /**
* Update an existing test mapping * Update an existing test mapping header
* @param {UpdateTestMapPayload} data - Mapping data * @param {UpdateTestMapPayload} data - Header data
* @returns {Promise<TestMapResponse>} API response * @returns {Promise<{success: boolean, data: number, message?: string}>} API response
*/ */
export async function updateTestMap(data) { export async function updateTestMap(data) {
return patch('/api/test/testmap', 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 * @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) { export async function deleteTestMap(id) {
return del('/api/test/testmap', { body: JSON.stringify({ TestMapID: 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 * Validate mapping form data
* @param {Object} data - Form data to validate * @param {Object} data - Form data to validate
@ -176,3 +287,26 @@ export function validateTestMap(data) {
errors 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 { onMount } from 'svelte';
import { import {
fetchTestMaps, fetchTestMaps,
fetchTestMap,
deleteTestMap, deleteTestMap,
} from '$lib/api/testmap.js'; } from '$lib/api/testmap.js';
import { fetchContainers } from '$lib/api/containers.js'; import { fetchContainers } from '$lib/api/containers.js';
@ -17,12 +18,10 @@
Link, Link,
Server, Server,
Monitor, Monitor,
Filter,
X,
} from 'lucide-svelte'; } from 'lucide-svelte';
let loading = $state(false); let loading = $state(false);
let testMaps = $state([]); let testMapHeaders = $state([]);
let containers = $state([]); let containers = $state([]);
let modalOpen = $state(false); let modalOpen = $state(false);
let modalMode = $state('create'); let modalMode = $state('create');
@ -32,68 +31,14 @@
let deleteConfirmOpen = $state(false); let deleteConfirmOpen = $state(false);
let deleteItem = $state(null); let deleteItem = $state(null);
let deleteGroupMode = $state(false); let deleteGroupMode = $state(false);
let modalLoading = $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());
const columns = [ const columns = [
{ key: 'HostInfo', label: 'Host System', class: 'w-48' }, { key: 'HostInfo', label: 'Host System', class: 'w-48' },
{ key: 'ClientInfo', label: 'Client 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' }, { 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 () => { onMount(async () => {
await Promise.all([loadTestMaps(), loadContainers()]); await Promise.all([loadTestMaps(), loadContainers()]);
}); });
@ -102,10 +47,10 @@
loading = true; loading = true;
try { try {
const response = await fetchTestMaps(); const response = await fetchTestMaps();
testMaps = Array.isArray(response.data) ? response.data : []; testMapHeaders = Array.isArray(response.data) ? response.data : [];
} catch (err) { } catch (err) {
toastError(err.message || 'Failed to load test mappings'); toastError(err.message || 'Failed to load test mappings');
testMaps = []; testMapHeaders = [];
} finally { } finally {
loading = false; loading = false;
} }
@ -128,12 +73,27 @@
modalOpen = true; modalOpen = true;
} }
function openEditGroupModal(group) { async function openEditGroupModal(group) {
modalLoading = true;
modalMode = 'edit'; modalMode = 'edit';
modalGroupData = group;
// Pass the first mapping as initial data, modal will handle the rest try {
modalData = group.mappings[0] || null; // 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; modalOpen = true;
} catch (err) {
toastError(err.message || 'Failed to load test mapping details');
modalGroupData = group;
modalData = null;
modalOpen = true;
} finally {
modalLoading = false;
}
} }
function handleModalSave() { function handleModalSave() {
@ -150,34 +110,26 @@
deleting = true; deleting = true;
try { try {
if (deleteGroupMode && deleteItem) { if (deleteGroupMode && deleteItem) {
// Delete all mappings in the group // For delete, we need the TestMapID
const deletePromises = deleteItem.mappings.map((mapping) => // Since the list doesn't return TestMapID, we need to fetch it first
deleteTestMap(mapping.TestMapID) // 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.');
await Promise.all(deletePromises); // TODO: Implement once API returns TestMapID or provides delete by host/client endpoint
toastSuccess(`Deleted ${deleteItem.mappings.length} test mapping(s) successfully`);
} else if (deleteItem?.TestMapID) { } else if (deleteItem?.TestMapID) {
// Delete single mapping (fallback) // Delete single mapping (fallback)
await deleteTestMap(deleteItem.TestMapID); await deleteTestMap(deleteItem.TestMapID);
toastSuccess('Test mapping deleted successfully'); toastSuccess('Test mapping deleted successfully');
}
deleteConfirmOpen = false; deleteConfirmOpen = false;
deleteItem = null; deleteItem = null;
deleteGroupMode = false; deleteGroupMode = false;
await loadTestMaps(); await loadTestMaps();
}
} catch (err) { } catch (err) {
toastError(err.message || 'Failed to delete test mapping(s)'); toastError(err.message || 'Failed to delete test mapping(s)');
} finally { } finally {
deleting = false; deleting = false;
} }
} }
function clearFilters() {
filterHostType = '';
filterHostID = '';
filterClientType = '';
filterClientID = '';
}
</script> </script>
<div class="p-4"> <div class="p-4">
@ -197,113 +149,29 @@
</button> </button>
</div> </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 --> <!-- Data Table -->
<div class="bg-base-100 rounded-lg shadow border border-base-200"> <div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if !loading && filteredGroupedTestMaps.length === 0} {#if !loading && testMapHeaders.length === 0}
<!-- Empty State --> <!-- Empty State -->
<div class="flex flex-col items-center justify-center py-16 px-4"> <div class="flex flex-col items-center justify-center py-16 px-4">
<div class="bg-base-200 rounded-full p-6 mb-4"> <div class="bg-base-200 rounded-full p-6 mb-4">
<Link class="w-12 h-12 text-gray-400" /> <Link class="w-12 h-12 text-gray-400" />
</div> </div>
<h3 class="text-base font-semibold text-gray-700 mb-2"> <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 No test mappings found
{/if}
</h3> </h3>
<p class="text-xs text-gray-500 text-center max-w-md mb-6"> <p class="text-sm 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. Test mappings connect tests between host systems (like HIS) and client systems. Add your first mapping to get started.
{/if}
</p> </p>
{#if filterHostType || filterHostID || filterClientType || filterClientID}
<button class="btn btn-outline" onclick={clearFilters}>
Clear Filters
</button>
{:else}
<button class="btn btn-primary" onclick={openCreateModal}> <button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" /> <Plus class="w-4 h-4 mr-2" />
Add Mapping Add Mapping
</button> </button>
{/if}
</div> </div>
{:else} {:else}
<DataTable <DataTable
{columns} {columns}
data={filteredGroupedTestMaps} data={testMapHeaders}
{loading} {loading}
emptyMessage="No test mappings found" emptyMessage="No test mappings found"
hover={true} hover={true}
@ -314,35 +182,29 @@
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Server class="w-4 h-4 text-primary flex-shrink-0" /> <Server class="w-4 h-4 text-primary flex-shrink-0" />
<div class="font-medium text-sm"> <div class="font-medium text-sm">
{row.HostType || '-'} - {row.HostID || '-'} {row.HostName || row.HostID || '-'}
</div> </div>
</div> </div>
{:else if column.key === 'ClientInfo'} {:else if column.key === 'ClientInfo'}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Monitor class="w-4 h-4 text-secondary flex-shrink-0" /> <Monitor class="w-4 h-4 text-secondary flex-shrink-0" />
<div class="font-medium text-sm"> <div class="font-medium text-sm">
{row.ClientType || '-'} - {row.ClientID || '-'} {row.ClientName || row.ClientID || '-'}
</div> </div>
</div> </div>
{:else if column.key === 'TestCount'}
<div class="flex justify-center">
<span class="badge badge-primary badge-sm">
{row.mappings.length}
</span>
</div>
{:else if column.key === 'actions'} {:else if column.key === 'actions'}
<div class="flex justify-center gap-1"> <div class="flex justify-center gap-1">
<button <button
class="btn btn-sm btn-ghost" class="btn btn-sm btn-ghost"
onclick={() => openEditGroupModal(row)} onclick={() => openEditGroupModal(row)}
title="Edit all {row.mappings.length} test mapping(s)" title="Edit test mapping group"
> >
<Edit2 class="w-4 h-4" /> <Edit2 class="w-4 h-4" />
</button> </button>
<button <button
class="btn btn-sm btn-ghost text-error" class="btn btn-sm btn-ghost text-error"
onclick={() => confirmDeleteGroup(row)} onclick={() => confirmDeleteGroup(row)}
title="Delete all {row.mappings.length} test mapping(s)" title="Delete test mapping group"
> >
<Trash2 class="w-4 h-4" /> <Trash2 class="w-4 h-4" />
</button> </button>
@ -363,6 +225,7 @@
initialData={modalData} initialData={modalData}
groupData={modalGroupData} groupData={modalGroupData}
{containers} {containers}
loading={modalLoading}
onSave={handleModalSave} onSave={handleModalSave}
/> />
@ -371,7 +234,7 @@
<div class="py-2"> <div class="py-2">
<p class="text-base-content/80"> <p class="text-base-content/80">
{#if deleteGroupMode} {#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} {:else}
Are you sure you want to delete this test mapping? Are you sure you want to delete this test mapping?
{/if} {/if}
@ -379,11 +242,11 @@
<div class="bg-base-200 rounded-lg p-3 mt-3 space-y-1"> <div class="bg-base-200 rounded-lg p-3 mt-3 space-y-1">
<p class="text-sm"> <p class="text-sm">
<span class="text-gray-500">Host:</span> <span class="text-gray-500">Host:</span>
<strong class="text-base-content">{deleteItem?.HostType} / {deleteItem?.HostID}</strong> <strong class="text-base-content">{deleteItem?.HostName || deleteItem?.HostID}</strong>
</p> </p>
<p class="text-sm"> <p class="text-sm">
<span class="text-gray-500">Client:</span> <span class="text-gray-500">Client:</span>
<strong class="text-base-content">{deleteItem?.ClientType} / {deleteItem?.ClientID}</strong> <strong class="text-base-content">{deleteItem?.ClientName || deleteItem?.ClientID}</strong>
</p> </p>
</div> </div>

View File

@ -1,7 +1,15 @@
<script> <script>
import { untrack } from 'svelte';
import Modal from '$lib/components/Modal.svelte'; import Modal from '$lib/components/Modal.svelte';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js'; 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 { import {
Plus, Plus,
Trash2, Trash2,
@ -11,12 +19,14 @@
AlertCircle, AlertCircle,
} from 'lucide-svelte'; } 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 saving = $state(false);
let localLoading = $state(false);
// Modal context (shared for all rows) // Modal context (shared for all rows)
let modalContext = $state({ let modalContext = $state({
TestMapID: null,
HostType: '', HostType: '',
HostID: '', HostID: '',
ClientType: '', ClientType: '',
@ -26,51 +36,71 @@
// Editable rows in modal // Editable rows in modal
let modalRows = $state([]); let modalRows = $state([]);
// Original rows for comparison in edit mode
let originalRows = $state([]);
// Form errors // Form errors
let formErrors = $state({}); 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 hostTypes = ['HIS', 'SITE', 'WST', 'INST'];
const clientTypes = ['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(() => { $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(); initializeModal();
} }
}); });
function initializeModal() { function initializeModal() {
formErrors = {}; formErrors = {};
originalRows = [];
if (mode === 'edit' && groupData) { if (mode === 'edit' && groupData) {
// Edit mode with group data - load all mappings in the group // Edit mode with group data - load all mappings in the group
modalContext = { modalContext = {
TestMapID: groupData.TestMapID || null,
HostType: groupData.HostType || '', HostType: groupData.HostType || '',
HostID: groupData.HostID || '', HostID: groupData.HostID || '',
ClientType: groupData.ClientType || '', ClientType: groupData.ClientType || '',
ClientID: groupData.ClientID || '', ClientID: groupData.ClientID || '',
}; };
// Load all mappings from the group // Fetch existing details from API
modalRows = groupData.mappings.map((mapping) => ({ fetchDetailsForGroup();
TestMapID: mapping.TestMapID,
HostTestCode: mapping.HostTestCode || '',
HostTestName: mapping.HostTestName || '',
ConDefID: mapping.ConDefID || null,
ClientTestCode: mapping.ClientTestCode || '',
ClientTestName: mapping.ClientTestName || '',
isNew: false,
}));
} else if (mode === 'edit' && initialData) { } else if (mode === 'edit' && initialData) {
// Legacy edit mode (single mapping) // Legacy edit mode (single mapping)
modalContext = { modalContext = {
TestMapID: initialData.TestMapID || null,
HostType: initialData.HostType || '', HostType: initialData.HostType || '',
HostID: initialData.HostID || '', HostID: initialData.HostID || '',
ClientType: initialData.ClientType || '', ClientType: initialData.ClientType || '',
ClientID: initialData.ClientID || '', ClientID: initialData.ClientID || '',
}; };
modalRows = [{ modalRows = [{
TestMapDetailID: null,
TestMapID: initialData.TestMapID, TestMapID: initialData.TestMapID,
TestSiteID: null,
HostTestCode: initialData.HostTestCode || '', HostTestCode: initialData.HostTestCode || '',
HostTestName: initialData.HostTestName || '', HostTestName: initialData.HostTestName || '',
ConDefID: initialData.ConDefID || null, ConDefID: initialData.ConDefID || null,
@ -78,9 +108,11 @@
ClientTestName: initialData.ClientTestName || '', ClientTestName: initialData.ClientTestName || '',
isNew: false, isNew: false,
}]; }];
originalRows = JSON.parse(JSON.stringify(modalRows));
} else { } else {
// Create mode // Create mode
modalContext = { modalContext = {
TestMapID: null,
HostType: '', HostType: '',
HostID: '', HostID: '',
ClientType: '', 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() { function createEmptyRow() {
return { return {
TestMapDetailID: null,
TestMapID: null, TestMapID: null,
TestSiteID: null,
HostTestCode: '', HostTestCode: '',
HostTestName: '', HostTestName: '',
ConDefID: null, ConDefID: null,
@ -164,34 +229,111 @@
return; return;
} }
if (mode === 'create') {
await handleCreate();
} else {
await handleUpdate();
}
}
async function handleCreate() {
saving = true; saving = true;
try { try {
const promises = modalRows.map(async (row) => { // Create header with details array
const payload = { const payload = {
HostType: modalContext.HostType, HostType: modalContext.HostType,
HostID: modalContext.HostID, HostID: modalContext.HostID,
HostTestCode: row.HostTestCode,
HostTestName: row.HostTestName,
ClientType: modalContext.ClientType, ClientType: modalContext.ClientType,
ClientID: modalContext.ClientID, ClientID: modalContext.ClientID,
details: modalRows.map((row) => ({
HostTestCode: row.HostTestCode,
HostTestName: row.HostTestName,
ConDefID: modalContext.ClientType === 'INST' ? row.ConDefID : null, ConDefID: modalContext.ClientType === 'INST' ? row.ConDefID : null,
ClientTestCode: row.ClientTestCode, ClientTestCode: row.ClientTestCode,
ClientTestName: row.ClientTestName, ClientTestName: row.ClientTestName,
})),
}; };
if (mode === 'create' || row.isNew) { await createTestMap(payload);
return createTestMap(payload); toastSuccess(`Test mapping${modalRows.length > 1 ? 's' : ''} created successfully`);
} else {
return updateTestMap({ ...payload, TestMapID: row.TestMapID });
}
});
await Promise.all(promises);
toastSuccess(`Test mapping${modalRows.length > 1 ? 's' : ''} saved successfully`);
open = false; open = false;
onSave?.(); onSave?.();
} catch (err) { } 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 { } finally {
saving = false; saving = false;
} }
@ -209,7 +351,16 @@
title={mode === 'create' ? 'Add Test Mapping' : `Edit Test Mapping${groupData ? 's' : ''} (${modalRows.length})`} title={mode === 'create' ? 'Add Test Mapping' : `Edit Test Mapping${groupData ? 's' : ''} (${modalRows.length})`}
size="xl" 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 --> <!-- Sticky Top Section: Info banner + Host and Client -->
<div class="flex-shrink-0 bg-base-100 z-10"> <div class="flex-shrink-0 bg-base-100 z-10">
<!-- Info banner for group editing --> <!-- Info banner for group editing -->
@ -420,7 +571,7 @@
</div> </div>
{#if formErrors.rows} {#if formErrors.rows}
{#each Object.entries(formErrors.rows) as [idx, error]} {#each Object.entries(formErrors.rows) as [idx, error] (idx)}
{#if error.empty} {#if error.empty}
<p class="text-xs text-error">Row {parseInt(idx) + 1}: {error.empty}</p> <p class="text-xs text-error">Row {parseInt(idx) + 1}: {error.empty}</p>
{/if} {/if}