408 lines
13 KiB
Svelte
408 lines
13 KiB
Svelte
<script>
|
|
import { onMount } from 'svelte';
|
|
import { Info, Settings, Calculator, Users, Link, Hash, Type } from 'lucide-svelte';
|
|
import { fetchTest, createTest, updateTest, validateTestCode, validateTestName } from '$lib/api/tests.js';
|
|
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
|
import Modal from '$lib/components/Modal.svelte';
|
|
import BasicInfoTab from './tabs/BasicInfoTab.svelte';
|
|
import TechDetailsTab from './tabs/TechDetailsTab.svelte';
|
|
import CalcDetailsTab from './tabs/CalcDetailsTab.svelte';
|
|
import GroupMembersTab from './tabs/GroupMembersTab.svelte';
|
|
import MappingsTab from './tabs/MappingsTab.svelte';
|
|
import RefNumTab from './tabs/RefNumTab.svelte';
|
|
import RefTxtTab from './tabs/RefTxtTab.svelte';
|
|
|
|
let { open = $bindable(false), mode = 'create', testId = null, initialTestType = 'TEST', disciplines = [], departments = [], tests = [], onsave = null } = $props();
|
|
|
|
let currentTab = $state('basic');
|
|
let loading = $state(false);
|
|
let saving = $state(false);
|
|
let isDirty = $state(false);
|
|
let validationErrors = $state({});
|
|
let lastLoadedTestId = $state(null);
|
|
|
|
// Reference to BasicInfoTab for validation
|
|
let basicInfoTabRef = $state(null);
|
|
|
|
// Initialize form state with proper defaults
|
|
let formData = $state(getDefaultFormData());
|
|
|
|
const tabConfig = [
|
|
{ id: 'basic', label: 'Basic Info', component: Info },
|
|
{ id: 'tech', label: 'Tech Details', component: Settings },
|
|
{ id: 'calc', label: 'Calculations', component: Calculator },
|
|
{ id: 'group', label: 'Group Members', component: Users },
|
|
{ id: 'mappings', label: 'Mappings', component: Link },
|
|
{ id: 'refnum', label: 'Num Refs', component: Hash },
|
|
{ id: 'reftxt', label: 'Txt Refs', component: Type }
|
|
];
|
|
|
|
const visibleTabs = $derived.by(() => {
|
|
const type = formData?.TestType;
|
|
const resultType = formData?.details?.ResultType;
|
|
const refType = formData?.details?.RefType;
|
|
|
|
return tabConfig.filter(tab => {
|
|
if (tab.id === 'basic' || tab.id === 'mappings') return true;
|
|
if (tab.id === 'tech') return ['TEST', 'PARAM', 'CALC'].includes(type);
|
|
if (tab.id === 'calc') return type === 'CALC';
|
|
if (tab.id === 'group') return type === 'GROUP';
|
|
if (tab.id === 'refnum') {
|
|
// Show for TEST/PARAM with numeric result types and RANGE/THOLD ref types
|
|
return ['TEST', 'PARAM'].includes(type) && ['NMRIC', 'RANGE'].includes(resultType) && ['RANGE', 'THOLD'].includes(refType);
|
|
}
|
|
if (tab.id === 'reftxt') {
|
|
// Show for TEST/PARAM with TEXT result type
|
|
return ['TEST', 'PARAM'].includes(type) && resultType === 'TEXT' && refType === 'TEXT';
|
|
}
|
|
return false;
|
|
});
|
|
});
|
|
|
|
// Computed validation state
|
|
const canSave = $derived.by(() => {
|
|
const codeResult = validateTestCode(formData.TestSiteCode);
|
|
const nameResult = validateTestName(formData.TestSiteName);
|
|
return codeResult.valid && nameResult.valid && formData.TestType !== '';
|
|
});
|
|
|
|
const formErrors = $derived.by(() => {
|
|
const errors = [];
|
|
const codeResult = validateTestCode(formData.TestSiteCode);
|
|
if (!codeResult.valid) errors.push(codeResult.error);
|
|
const nameResult = validateTestName(formData.TestSiteName);
|
|
if (!nameResult.valid) errors.push(nameResult.error);
|
|
return errors;
|
|
});
|
|
|
|
// Watch for modal open changes and load data
|
|
$effect(() => {
|
|
if (open && mode === 'edit' && testId && testId !== lastLoadedTestId) {
|
|
loadTest();
|
|
} else if (open && mode === 'create' && lastLoadedTestId !== null) {
|
|
resetForm();
|
|
}
|
|
});
|
|
|
|
onMount(() => {
|
|
if (open) {
|
|
if (mode === 'edit' && testId) {
|
|
loadTest();
|
|
} else {
|
|
resetForm();
|
|
}
|
|
}
|
|
});
|
|
|
|
function getDefaultFormData() {
|
|
return {
|
|
TestSiteID: null,
|
|
TestSiteCode: '',
|
|
TestSiteName: '',
|
|
TestType: initialTestType,
|
|
Description: '',
|
|
SiteID: 1,
|
|
SeqScr: 0,
|
|
SeqRpt: 0,
|
|
VisibleScr: true,
|
|
VisibleRpt: true,
|
|
CountStat: true,
|
|
details: {
|
|
DisciplineID: null,
|
|
DepartmentID: null,
|
|
ResultType: '',
|
|
RefType: '',
|
|
VSet: '',
|
|
Unit1: '',
|
|
Factor: null,
|
|
Unit2: '',
|
|
Decimal: 2,
|
|
ReqQty: null,
|
|
ReqQtyUnit: '',
|
|
CollReq: '',
|
|
Method: '',
|
|
ExpectedTAT: null,
|
|
FormulaInput: '',
|
|
FormulaCode: '',
|
|
members: []
|
|
},
|
|
refnum: [],
|
|
reftxt: [],
|
|
testmap: []
|
|
};
|
|
}
|
|
|
|
function resetForm() {
|
|
formData = getDefaultFormData();
|
|
currentTab = 'basic';
|
|
isDirty = false;
|
|
validationErrors = {};
|
|
lastLoadedTestId = null;
|
|
setDefaults();
|
|
}
|
|
|
|
async function loadTest() {
|
|
if (!testId || testId === lastLoadedTestId) return;
|
|
|
|
loading = true;
|
|
try {
|
|
const response = await fetchTest(testId);
|
|
const test = response.data;
|
|
|
|
// Transform API data to form state
|
|
formData = {
|
|
TestSiteID: test.TestSiteID,
|
|
TestSiteCode: test.TestSiteCode || '',
|
|
TestSiteName: test.TestSiteName || '',
|
|
TestType: test.TestType || 'TEST',
|
|
Description: test.Description || '',
|
|
SiteID: test.SiteID || 1,
|
|
SeqScr: test.SeqScr || 0,
|
|
SeqRpt: test.SeqRpt || 0,
|
|
VisibleScr: test.VisibleScr === '1' || test.VisibleScr === 1 || test.VisibleScr === true,
|
|
VisibleRpt: test.VisibleRpt === '1' || test.VisibleRpt === 1 || test.VisibleRpt === true,
|
|
CountStat: test.CountStat === '1' || test.CountStat === 1 || test.CountStat === true,
|
|
details: {
|
|
DisciplineID: test.DisciplineID || null,
|
|
DepartmentID: test.DepartmentID || null,
|
|
ResultType: test.ResultType || '',
|
|
RefType: test.RefType || '',
|
|
VSet: test.VSet || '',
|
|
Unit1: test.Unit1 || '',
|
|
Factor: test.Factor || null,
|
|
Unit2: test.Unit2 || '',
|
|
Decimal: test.Decimal || 2,
|
|
ReqQty: test.ReqQty || null,
|
|
ReqQtyUnit: test.ReqQtyUnit || '',
|
|
CollReq: test.CollReq || '',
|
|
Method: test.Method || '',
|
|
ExpectedTAT: test.ExpectedTAT || null,
|
|
FormulaInput: test.FormulaInput || '',
|
|
FormulaCode: test.FormulaCode || '',
|
|
members: test.testdefgrp?.map(m => m.TestSiteID) || []
|
|
},
|
|
refnum: test.refnum || [],
|
|
reftxt: test.reftxt || [],
|
|
testmap: test.testmap || []
|
|
};
|
|
|
|
// Set defaults for CALC tests
|
|
if (formData.TestType === 'CALC') {
|
|
formData.details.ResultType = 'NMRIC';
|
|
formData.details.RefType = 'RANGE';
|
|
}
|
|
|
|
lastLoadedTestId = testId;
|
|
currentTab = 'basic';
|
|
isDirty = false;
|
|
} catch (err) {
|
|
toastError(err.message || 'Failed to load test');
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
function setDefaults() {
|
|
const type = formData.TestType;
|
|
if (type === 'CALC') {
|
|
formData.details.ResultType = 'NMRIC';
|
|
formData.details.RefType = 'RANGE';
|
|
} else if (type === 'GROUP' || type === 'TITLE') {
|
|
formData.details.ResultType = 'NORES';
|
|
formData.details.RefType = 'NOREF';
|
|
} else {
|
|
formData.details.ResultType = '';
|
|
formData.details.RefType = '';
|
|
}
|
|
}
|
|
|
|
function handleTabChange(tabId) {
|
|
currentTab = tabId;
|
|
}
|
|
|
|
function handleTypeChange(newType) {
|
|
if (isDirty && !confirm('Changing test type will reset some fields. Continue?')) {
|
|
return;
|
|
}
|
|
formData.TestType = newType;
|
|
setDefaults();
|
|
isDirty = false;
|
|
}
|
|
|
|
/**
|
|
* Validate the entire form
|
|
* @returns {boolean}
|
|
*/
|
|
function validateForm() {
|
|
const errors = {};
|
|
|
|
// Validate basic info
|
|
const codeResult = validateTestCode(formData.TestSiteCode);
|
|
if (!codeResult.valid) errors.TestSiteCode = codeResult.error;
|
|
|
|
const nameResult = validateTestName(formData.TestSiteName);
|
|
if (!nameResult.valid) errors.TestSiteName = nameResult.error;
|
|
|
|
if (!formData.TestType) {
|
|
errors.TestType = 'Test type is required';
|
|
}
|
|
|
|
validationErrors = errors;
|
|
return Object.keys(errors).length === 0;
|
|
}
|
|
|
|
async function handleSave() {
|
|
if (!validateForm()) {
|
|
toastError('Please fix validation errors');
|
|
currentTab = 'basic';
|
|
return;
|
|
}
|
|
|
|
saving = true;
|
|
try {
|
|
if (mode === 'create') {
|
|
await createTest(formData);
|
|
toastSuccess('Test created successfully');
|
|
} else {
|
|
await updateTest(formData);
|
|
toastSuccess('Test updated successfully');
|
|
}
|
|
|
|
if (onsave) onsave();
|
|
open = false;
|
|
resetForm();
|
|
} catch (err) {
|
|
toastError(err.message || 'Failed to save test');
|
|
} finally {
|
|
saving = false;
|
|
}
|
|
}
|
|
|
|
function handleClose() {
|
|
if (isDirty && !confirm('You have unsaved changes. Discard?')) {
|
|
return;
|
|
}
|
|
open = false;
|
|
resetForm();
|
|
}
|
|
</script>
|
|
|
|
<Modal bind:open size="wide" title={mode === 'create' ? 'New Test' : 'Edit Test'}>
|
|
{#if loading}
|
|
<div class="flex items-center justify-center py-16">
|
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
|
</div>
|
|
{:else}
|
|
{#if formErrors.length > 0}
|
|
<div class="alert alert-warning alert-sm mb-4">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-4 w-4" fill="none" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
</svg>
|
|
<div>
|
|
<span class="font-semibold">Please fix the following errors:</span>
|
|
<ul class="list-disc list-inside text-sm mt-1">
|
|
{#each formErrors as error}
|
|
<li>{error}</li>
|
|
{/each}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="flex flex-col" style="height: 75vh; max-height: 650px;">
|
|
<!-- Top Tabs -->
|
|
<div class="border-b border-base-200 bg-base-50">
|
|
<div class="flex overflow-x-auto">
|
|
{#each visibleTabs as tab (tab.id)}
|
|
{@const IconComponent = tab.component}
|
|
<button
|
|
class="flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-all duration-200"
|
|
class:border-primary={currentTab === tab.id}
|
|
class:text-primary={currentTab === tab.id}
|
|
class:border-transparent={currentTab !== tab.id}
|
|
class:text-gray-600={currentTab !== tab.id}
|
|
class:hover:text-gray-800={currentTab !== tab.id}
|
|
onclick={() => handleTabChange(tab.id)}
|
|
aria-selected={currentTab === tab.id}
|
|
role="tab"
|
|
>
|
|
<IconComponent class="w-4 h-4 flex-shrink-0" />
|
|
<span>{tab.label}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab Content -->
|
|
<div class="flex-1 overflow-y-auto p-6">
|
|
{#if currentTab === 'basic'}
|
|
<BasicInfoTab
|
|
bind:this={basicInfoTabRef}
|
|
bind:formData
|
|
bind:isDirty
|
|
onTypeChange={handleTypeChange}
|
|
{mode}
|
|
/>
|
|
{:else if currentTab === 'tech'}
|
|
<TechDetailsTab
|
|
bind:formData
|
|
{disciplines}
|
|
{departments}
|
|
bind:isDirty
|
|
onSwitchTab={handleTabChange}
|
|
/>
|
|
{:else if currentTab === 'calc'}
|
|
<CalcDetailsTab
|
|
bind:formData
|
|
bind:isDirty
|
|
/>
|
|
{:else if currentTab === 'group'}
|
|
<GroupMembersTab
|
|
bind:formData
|
|
{tests}
|
|
bind:isDirty
|
|
/>
|
|
{:else if currentTab === 'calc'}
|
|
<CalcDetailsTab
|
|
bind:formData
|
|
{disciplines}
|
|
{departments}
|
|
bind:isDirty
|
|
/>
|
|
{:else if currentTab === 'group'}
|
|
<GroupMembersTab
|
|
bind:formData
|
|
{tests}
|
|
bind:isDirty
|
|
/>
|
|
{:else if currentTab === 'mappings'}
|
|
<MappingsTab
|
|
bind:formData
|
|
bind:isDirty
|
|
/>
|
|
{:else if currentTab === 'refnum'}
|
|
<RefNumTab
|
|
bind:formData
|
|
bind:isDirty
|
|
/>
|
|
{:else if currentTab === 'reftxt'}
|
|
<RefTxtTab
|
|
bind:formData
|
|
bind:isDirty
|
|
/>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
{/if}
|
|
|
|
{#snippet footer()}
|
|
<button class="btn btn-ghost" onclick={handleClose} type="button" disabled={saving || loading}>Cancel</button>
|
|
<button class="btn btn-primary" onclick={handleSave} disabled={saving || loading || !canSave} type="button">
|
|
{#if saving}
|
|
<span class="loading loading-spinner loading-sm mr-2"></span>
|
|
{/if}
|
|
{saving ? 'Saving...' : 'Save'}
|
|
</button>
|
|
{/snippet}
|
|
</Modal> |