clqms-fe1/src/lib/api/tests.js

339 lines
10 KiB
JavaScript
Raw Normal View History

import { get, post, patch } from './client.js';
/**
* @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
*/
/**
* Fetch tests list with optional filters, pagination, and search
* @param {TestFilterOptions & { page?: number, perPage?: number }} [params] - Query parameters
* @returns {Promise<TestListResponse>} API response with test data and pagination
*/
export async function fetchTests(params = {}) {
const query = new URLSearchParams(params).toString();
return get(query ? `/api/tests?${query}` : '/api/tests');
}
/**
* Fetch a single test by ID
* @param {number} id - Test Site ID
* @returns {Promise<TestDetailResponse>} API response with test detail
*/
export async function fetchTest(id) {
return get(`/api/tests/${id}`);
}
/**
* 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} */
const payload = {
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: []
};
// 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,
AgeEnd: parseInt(r.AgeEnd) || 150,
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,
AgeEnd: parseInt(r.AgeEnd) || 150,
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);
return post('/api/tests', payload);
}
/**
* 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);
return patch('/api/tests', payload);
}
/**
* Soft delete a test (set IsActive to '0')
* @param {number} id - Test Site ID
* @returns {Promise<DeleteTestResponse>} API response
*/
export async function deleteTest(id) {
return patch('/api/tests', {
TestSiteID: id,
IsActive: '0',
});
}
/**
* 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 };
}