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>