2026-02-13 16:07:59 +07:00
|
|
|
import { get, post, patch } from './client.js';
|
|
|
|
|
|
2026-02-20 13:51:54 +07:00
|
|
|
/**
|
|
|
|
|
* @typedef {import('$lib/types/test.types.js').TestSummary} TestSummary
|
|
|
|
|
* @typedef {import('$lib/types/test.types.js').TestDetail} TestDetail
|
|
|
|
|
* @typedef {import('$lib/types/test.types.js').CreateTestPayload} CreateTestPayload
|
|
|
|
|
* @typedef {import('$lib/types/test.types.js').UpdateTestPayload} UpdateTestPayload
|
|
|
|
|
* @typedef {import('$lib/types/test.types.js').TestListResponse} TestListResponse
|
|
|
|
|
* @typedef {import('$lib/types/test.types.js').TestDetailResponse} TestDetailResponse
|
|
|
|
|
* @typedef {import('$lib/types/test.types.js').CreateTestResponse} CreateTestResponse
|
|
|
|
|
* @typedef {import('$lib/types/test.types.js').UpdateTestResponse} UpdateTestResponse
|
|
|
|
|
* @typedef {import('$lib/types/test.types.js').DeleteTestResponse} DeleteTestResponse
|
|
|
|
|
* @typedef {import('$lib/types/test.types.js').TestFilterOptions} TestFilterOptions
|
|
|
|
|
*/
|
|
|
|
|
|
2026-02-16 15:58:06 +07:00
|
|
|
/**
|
|
|
|
|
* Fetch tests list with optional filters, pagination, and search
|
2026-02-20 13:51:54 +07:00
|
|
|
* @param {TestFilterOptions & { page?: number, perPage?: number }} [params] - Query parameters
|
|
|
|
|
* @returns {Promise<TestListResponse>} API response with test data and pagination
|
2026-02-16 15:58:06 +07:00
|
|
|
*/
|
2026-02-13 16:07:59 +07:00
|
|
|
export async function fetchTests(params = {}) {
|
|
|
|
|
const query = new URLSearchParams(params).toString();
|
2026-03-02 07:02:25 +07:00
|
|
|
return get(query ? `/api/test?${query}` : '/api/test');
|
2026-02-13 16:07:59 +07:00
|
|
|
}
|
|
|
|
|
|
2026-02-20 13:51:54 +07:00
|
|
|
/**
|
|
|
|
|
* Fetch a single test by ID
|
|
|
|
|
* @param {number} id - Test Site ID
|
|
|
|
|
* @returns {Promise<TestDetailResponse>} API response with test detail
|
|
|
|
|
*/
|
2026-02-13 16:07:59 +07:00
|
|
|
export async function fetchTest(id) {
|
2026-03-02 07:02:25 +07:00
|
|
|
return get(`/api/test/${id}`);
|
2026-02-13 16:07:59 +07:00
|
|
|
}
|
|
|
|
|
|
2026-02-20 13:51:54 +07:00
|
|
|
/**
|
|
|
|
|
* Build payload from form state for API submission
|
|
|
|
|
* Follows the specification: nested details object for technical fields
|
|
|
|
|
* @param {any} formData - The form state
|
|
|
|
|
* @param {boolean} isUpdate - Whether this is an update operation
|
|
|
|
|
* @returns {CreateTestPayload | UpdateTestPayload}
|
|
|
|
|
*/
|
|
|
|
|
function buildPayload(formData, isUpdate = false) {
|
|
|
|
|
/** @type {CreateTestPayload} */
|
2026-02-13 16:07:59 +07:00
|
|
|
const payload = {
|
2026-02-20 13:51:54 +07:00
|
|
|
SiteID: formData.SiteID || 1,
|
|
|
|
|
TestSiteCode: formData.TestSiteCode,
|
|
|
|
|
TestSiteName: formData.TestSiteName,
|
|
|
|
|
TestType: formData.TestType,
|
|
|
|
|
Description: formData.Description,
|
|
|
|
|
SeqScr: parseInt(formData.SeqScr) || 0,
|
|
|
|
|
SeqRpt: parseInt(formData.SeqRpt) || 0,
|
|
|
|
|
VisibleScr: formData.VisibleScr ? 1 : 0,
|
|
|
|
|
VisibleRpt: formData.VisibleRpt ? 1 : 0,
|
|
|
|
|
CountStat: formData.CountStat ? 1 : 0,
|
|
|
|
|
// StartDate is auto-set by backend (created_at)
|
|
|
|
|
details: {},
|
|
|
|
|
refnum: [],
|
|
|
|
|
reftxt: [],
|
|
|
|
|
testmap: []
|
2026-02-13 16:07:59 +07:00
|
|
|
};
|
2026-02-20 13:51:54 +07:00
|
|
|
|
|
|
|
|
// Add TestSiteID for updates
|
|
|
|
|
if (isUpdate && formData.TestSiteID) {
|
|
|
|
|
payload.TestSiteID = formData.TestSiteID;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build details object based on TestType
|
|
|
|
|
const type = formData.TestType;
|
|
|
|
|
|
|
|
|
|
// Common fields for TEST, PARAM, CALC
|
|
|
|
|
if (['TEST', 'PARAM', 'CALC'].includes(type)) {
|
|
|
|
|
payload.details = {
|
|
|
|
|
DisciplineID: formData.details?.DisciplineID || null,
|
|
|
|
|
DepartmentID: formData.details?.DepartmentID || null,
|
|
|
|
|
ResultType: formData.details?.ResultType || '',
|
|
|
|
|
RefType: formData.details?.RefType || '',
|
|
|
|
|
VSet: formData.details?.VSet || '',
|
|
|
|
|
Unit1: formData.details?.Unit1 || '',
|
|
|
|
|
Factor: formData.details?.Factor || null,
|
|
|
|
|
Unit2: formData.details?.Unit2 || '',
|
|
|
|
|
Decimal: formData.details?.Decimal || 2,
|
|
|
|
|
ReqQty: formData.details?.ReqQty || null,
|
|
|
|
|
ReqQtyUnit: formData.details?.ReqQtyUnit || '',
|
|
|
|
|
CollReq: formData.details?.CollReq || '',
|
|
|
|
|
Method: formData.details?.Method || '',
|
|
|
|
|
ExpectedTAT: formData.details?.ExpectedTAT || null
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Add formula fields for CALC type
|
|
|
|
|
if (type === 'CALC') {
|
|
|
|
|
payload.details.FormulaInput = formData.details?.FormulaInput || '';
|
|
|
|
|
payload.details.FormulaCode = formData.details?.FormulaCode || '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add members for GROUP type
|
|
|
|
|
if (type === 'GROUP' && formData.details?.members) {
|
|
|
|
|
payload.details = {
|
|
|
|
|
members: formData.details.members
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add reference ranges (TEST and PARAM only)
|
|
|
|
|
if (['TEST', 'PARAM'].includes(type)) {
|
|
|
|
|
if (formData.refnum && Array.isArray(formData.refnum)) {
|
|
|
|
|
payload.refnum = formData.refnum.map(r => ({
|
|
|
|
|
NumRefType: r.NumRefType,
|
|
|
|
|
RangeType: r.RangeType,
|
|
|
|
|
Sex: r.Sex,
|
|
|
|
|
AgeStart: parseInt(r.AgeStart) || 0,
|
2026-02-20 16:49:34 +07:00
|
|
|
AgeEnd: parseInt(r.AgeEnd) || 54750, // 150 years in days
|
2026-02-20 13:51:54 +07:00
|
|
|
LowSign: r.LowSign || null,
|
|
|
|
|
Low: r.Low !== null && r.Low !== undefined ? parseFloat(r.Low) : null,
|
|
|
|
|
HighSign: r.HighSign || null,
|
|
|
|
|
High: r.High !== null && r.High !== undefined ? parseFloat(r.High) : null,
|
|
|
|
|
Flag: r.Flag || null,
|
|
|
|
|
Interpretation: r.Interpretation || null
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (formData.reftxt && Array.isArray(formData.reftxt)) {
|
|
|
|
|
payload.reftxt = formData.reftxt.map(r => ({
|
|
|
|
|
TxtRefType: r.TxtRefType,
|
|
|
|
|
Sex: r.Sex,
|
|
|
|
|
AgeStart: parseInt(r.AgeStart) || 0,
|
2026-02-20 16:49:34 +07:00
|
|
|
AgeEnd: parseInt(r.AgeEnd) || 54750, // 150 years in days
|
2026-02-20 13:51:54 +07:00
|
|
|
RefTxt: r.RefTxt || '',
|
|
|
|
|
Flag: r.Flag || null
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add mappings (all types)
|
|
|
|
|
if (formData.testmap && Array.isArray(formData.testmap)) {
|
|
|
|
|
payload.testmap = formData.testmap.map(m => ({
|
|
|
|
|
HostType: m.HostType || null,
|
|
|
|
|
HostID: m.HostID || null,
|
|
|
|
|
HostDataSource: m.HostDataSource || null,
|
|
|
|
|
HostTestCode: m.HostTestCode || null,
|
|
|
|
|
HostTestName: m.HostTestName || null,
|
|
|
|
|
ClientType: m.ClientType || null,
|
|
|
|
|
ClientID: m.ClientID || null,
|
|
|
|
|
ClientDataSource: m.ClientDataSource || null,
|
|
|
|
|
ConDefID: m.ConDefID || null,
|
|
|
|
|
ClientTestCode: m.ClientTestCode || null,
|
|
|
|
|
ClientTestName: m.ClientTestName || null
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return payload;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create a new test
|
|
|
|
|
* @param {any} formData - The form state
|
|
|
|
|
* @returns {Promise<CreateTestResponse>} API response
|
|
|
|
|
*/
|
|
|
|
|
export async function createTest(formData) {
|
|
|
|
|
const payload = buildPayload(formData, false);
|
2026-03-02 07:02:25 +07:00
|
|
|
return post('/api/test', payload);
|
2026-02-13 16:07:59 +07:00
|
|
|
}
|
|
|
|
|
|
2026-02-20 13:51:54 +07:00
|
|
|
/**
|
|
|
|
|
* Update an existing test
|
|
|
|
|
* @param {any} formData - The form state
|
|
|
|
|
* @returns {Promise<UpdateTestResponse>} API response
|
|
|
|
|
*/
|
|
|
|
|
export async function updateTest(formData) {
|
|
|
|
|
const payload = buildPayload(formData, true);
|
2026-03-02 07:02:25 +07:00
|
|
|
return patch('/api/test', payload);
|
2026-02-13 16:07:59 +07:00
|
|
|
}
|
2026-02-15 17:58:42 +07:00
|
|
|
|
2026-02-20 13:51:54 +07:00
|
|
|
/**
|
|
|
|
|
* Soft delete a test (set IsActive to '0')
|
|
|
|
|
* @param {number} id - Test Site ID
|
|
|
|
|
* @returns {Promise<DeleteTestResponse>} API response
|
|
|
|
|
*/
|
2026-02-15 17:58:42 +07:00
|
|
|
export async function deleteTest(id) {
|
2026-03-02 07:02:25 +07:00
|
|
|
return patch('/api/test', {
|
2026-02-15 17:58:42 +07:00
|
|
|
TestSiteID: id,
|
|
|
|
|
IsActive: '0',
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-02-20 13:51:54 +07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate test type combination
|
|
|
|
|
* According to business rules: TestType + ResultType + RefType must be valid
|
|
|
|
|
* @param {string} testType - The test type
|
|
|
|
|
* @param {string} resultType - The result type
|
|
|
|
|
* @param {string} refType - The reference type
|
|
|
|
|
* @returns {{ valid: boolean, error?: string }}
|
|
|
|
|
*/
|
|
|
|
|
export function validateTypeCombination(testType, resultType, refType) {
|
|
|
|
|
// Valid combinations based on specification
|
|
|
|
|
const validCombinations = {
|
|
|
|
|
TEST: {
|
|
|
|
|
NMRIC: ['RANGE', 'THOLD'],
|
|
|
|
|
RANGE: ['RANGE', 'THOLD'],
|
|
|
|
|
TEXT: ['TEXT'],
|
|
|
|
|
VSET: ['VSET']
|
|
|
|
|
},
|
|
|
|
|
PARAM: {
|
|
|
|
|
NMRIC: ['RANGE', 'THOLD'],
|
|
|
|
|
RANGE: ['RANGE', 'THOLD'],
|
|
|
|
|
TEXT: ['TEXT'],
|
|
|
|
|
VSET: ['VSET']
|
|
|
|
|
},
|
|
|
|
|
CALC: {
|
|
|
|
|
NMRIC: ['RANGE', 'THOLD']
|
|
|
|
|
},
|
|
|
|
|
GROUP: {
|
|
|
|
|
NORES: ['NOREF']
|
|
|
|
|
},
|
|
|
|
|
TITLE: {
|
|
|
|
|
NORES: ['NOREF']
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const validResultTypes = validCombinations[testType];
|
|
|
|
|
if (!validResultTypes) {
|
|
|
|
|
return { valid: false, error: 'Invalid test type' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!resultType) {
|
|
|
|
|
return { valid: true }; // ResultType might be optional
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const validRefTypes = validResultTypes[resultType];
|
|
|
|
|
if (!validRefTypes) {
|
|
|
|
|
return { valid: false, error: `Result type '${resultType}' is not valid for '${testType}'` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (refType && !validRefTypes.includes(refType)) {
|
|
|
|
|
return { valid: false, error: `Reference type '${refType}' is not valid for '${testType}' + '${resultType}'` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { valid: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get valid result types for a test type
|
|
|
|
|
* @param {string} testType - The test type
|
|
|
|
|
* @returns {Array<{value: string, label: string}>}
|
|
|
|
|
*/
|
|
|
|
|
export function getResultTypeOptions(testType) {
|
|
|
|
|
switch (testType) {
|
|
|
|
|
case 'CALC':
|
|
|
|
|
return [{ value: 'NMRIC', label: 'Numeric' }];
|
|
|
|
|
case 'GROUP':
|
|
|
|
|
case 'TITLE':
|
|
|
|
|
return [{ value: 'NORES', label: 'No Result' }];
|
|
|
|
|
default:
|
|
|
|
|
return [
|
|
|
|
|
{ value: 'NMRIC', label: 'Numeric' },
|
|
|
|
|
{ value: 'RANGE', label: 'Range' },
|
|
|
|
|
{ value: 'TEXT', label: 'Text' },
|
|
|
|
|
{ value: 'VSET', label: 'Value Set' }
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get valid reference types for a result type
|
|
|
|
|
* @param {string} resultType - The result type
|
|
|
|
|
* @returns {Array<{value: string, label: string}>}
|
|
|
|
|
*/
|
|
|
|
|
export function getRefTypeOptions(resultType) {
|
|
|
|
|
switch (resultType) {
|
|
|
|
|
case 'NMRIC':
|
|
|
|
|
case 'RANGE':
|
|
|
|
|
return [
|
|
|
|
|
{ value: 'RANGE', label: 'Range' },
|
|
|
|
|
{ value: 'THOLD', label: 'Threshold' }
|
|
|
|
|
];
|
|
|
|
|
case 'VSET':
|
|
|
|
|
return [{ value: 'VSET', label: 'Value Set' }];
|
|
|
|
|
case 'TEXT':
|
|
|
|
|
return [{ value: 'TEXT', label: 'Text' }];
|
|
|
|
|
case 'NORES':
|
|
|
|
|
return [{ value: 'NOREF', label: 'No Reference' }];
|
|
|
|
|
default:
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate test code according to specification
|
|
|
|
|
* - Required
|
|
|
|
|
* - 3-6 characters
|
|
|
|
|
* - Uppercase alphanumeric only
|
|
|
|
|
* - Regex: ^[A-Z0-9]{3,6}$
|
|
|
|
|
* @param {string} code - The test code
|
|
|
|
|
* @returns {{ valid: boolean, error?: string }}
|
|
|
|
|
*/
|
|
|
|
|
export function validateTestCode(code) {
|
|
|
|
|
if (!code || code.trim() === '') {
|
|
|
|
|
return { valid: false, error: 'Test code is required' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const trimmed = code.trim();
|
|
|
|
|
|
|
|
|
|
const regex = /^[A-Z0-9]+$/;
|
|
|
|
|
if (!regex.test(trimmed)) {
|
|
|
|
|
return { valid: false, error: 'Test code must be uppercase alphanumeric' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { valid: true };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Validate test name according to specification
|
|
|
|
|
* - Required
|
|
|
|
|
* - 3-255 characters
|
|
|
|
|
* - Alphanumeric with hyphen, space, and parentheses allowed
|
|
|
|
|
* - Regex: ^[a-zA-Z0-9\s\-\(\)]{3,255}$
|
|
|
|
|
* @param {string} name - The test name
|
|
|
|
|
* @returns {{ valid: boolean, error?: string }}
|
|
|
|
|
*/
|
|
|
|
|
export function validateTestName(name) {
|
|
|
|
|
if (!name || name.trim() === '') {
|
|
|
|
|
return { valid: false, error: 'Test name is required' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const trimmed = name.trim();
|
|
|
|
|
if (trimmed.length < 3) {
|
|
|
|
|
return { valid: false, error: 'Test name must be at least 3 characters' };
|
|
|
|
|
}
|
|
|
|
|
if (trimmed.length > 255) {
|
|
|
|
|
return { valid: false, error: 'Test name must be at most 255 characters' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const regex = /^[a-zA-Z0-9\s\-\(\)]{3,255}$/;
|
|
|
|
|
if (!regex.test(trimmed)) {
|
|
|
|
|
return { valid: false, error: 'Test name can only contain letters, numbers, spaces, hyphens, and parentheses' };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { valid: true };
|
|
|
|
|
}
|