mahdahar ae806911be feat(equipment,organization): add equipment API client and complete organization module structure
- Add equipment.js API client with full CRUD operations
- Add organization sub-routes: account, department, discipline, instrument, site, workstation
- Create EquipmentModal and DeleteConfirmModal components
- Update master-data navigation and sidebar
- Update tests, containers, counters, geography, locations, occupations, specialties, testmap, and valuesets pages
- Add COMPONENT_ORGANIZATION.md documentation
2026-02-24 16:53:04 +07:00

274 lines
9.4 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script>
import { onMount } from 'svelte';
import { fetchTests, fetchTest, createTest, updateTest, deleteTest } from '$lib/api/tests.js';
import { fetchDisciplines, fetchDepartments } from '$lib/api/organization.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import DataTable from '$lib/components/DataTable.svelte';
import Modal from '$lib/components/Modal.svelte';
import TestFormModal from './test-modal/TestFormModal.svelte';
import TestTypePickerModal from './test-modal/modals/TestTypePickerModal.svelte';
import { Plus, Edit2, Trash2, ArrowLeft, Search, Microscope, Variable, Calculator, Box, Layers, Loader2, Users } from 'lucide-svelte';
let loading = $state(false);
let tests = $state([]);
let disciplines = $state([]);
let departments = $state([]);
let searchQuery = $state('');
let modalOpen = $state(false);
let modalMode = $state('create');
let selectedTestId = $state(null);
let selectedTestType = $state('TEST');
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
let deleting = $state(false);
let testTypePickerOpen = $state(false);
const testTypeConfig = {
TEST: { label: 'Test', badgeClass: 'badge-primary', icon: Microscope, color: '#0066CC', bgColor: '#E6F2FF' },
PARAM: { label: 'Parameter', badgeClass: 'badge-secondary', icon: Variable, color: '#3399FF', bgColor: '#F0F8FF' },
CALC: { label: 'Calculated', badgeClass: 'badge-accent', icon: Calculator, color: '#9933CC', bgColor: '#F5E6FF' },
GROUP: { label: 'Panel', badgeClass: 'badge-info', icon: Box, color: '#00AA44', bgColor: '#E6F9EE' },
TITLE: { label: 'Header', badgeClass: 'badge-ghost', icon: Layers, color: '#666666', bgColor: '#F5F5F5' }
};
const columns = [
{ key: 'TestSiteCode', label: 'Code', class: 'font-medium w-24' },
{ key: 'TestSiteName', label: 'Name', class: 'min-w-[200px]' },
{ key: 'TestType', label: 'Type', class: 'w-28' },
{ key: 'DisciplineName', label: 'Discipline', class: 'w-32' },
{ key: 'DepartmentName', label: 'Department', class: 'w-32' },
{ key: 'Visible', label: 'Visible', class: 'w-24 text-center' },
{ key: 'actions', label: 'Actions', class: 'w-24 text-center' }
];
const filteredTests = $derived.by(() => {
if (!searchQuery.trim()) return tests;
const query = searchQuery.toLowerCase().trim();
return tests.filter(test => {
const code = (test.TestSiteCode || '').toLowerCase();
const name = (test.TestSiteName || '').toLowerCase();
return code.includes(query) || name.includes(query);
});
});
onMount(async () => {
await Promise.all([loadTests(), loadDisciplines(), loadDepartments()]);
});
async function loadTests() {
loading = true;
try {
const response = await fetchTests();
tests = Array.isArray(response.data) ? response.data.filter(t => t.IsActive !== '0' && t.IsActive !== 0) : [];
} catch (err) {
toastError(err.message || 'Failed to load tests');
tests = [];
} finally {
loading = false;
}
}
async function loadDisciplines() {
try {
const response = await fetchDisciplines();
disciplines = Array.isArray(response.data) ? response.data : [];
} catch (err) {
console.error('Failed to load disciplines:', err);
disciplines = [];
}
}
async function loadDepartments() {
try {
const response = await fetchDepartments();
departments = Array.isArray(response.data) ? response.data : [];
} catch (err) {
console.error('Failed to load departments:', err);
departments = [];
}
}
function openCreateModal() {
testTypePickerOpen = true;
}
function handleTestTypeSelect(type) {
selectedTestType = type;
modalMode = 'create';
selectedTestId = null;
modalOpen = true;
}
async function openEditModal(row) {
modalMode = 'edit';
selectedTestId = row.TestSiteID;
modalOpen = true;
}
function getTestTypeConfig(type) {
return testTypeConfig[type] || testTypeConfig.TEST;
}
function confirmDelete(row) {
deleteItem = row;
deleteConfirmOpen = true;
}
async function handleDelete() {
deleting = true;
try {
await deleteTest(deleteItem.TestSiteID);
toastSuccess('Test deleted successfully');
deleteConfirmOpen = false;
await loadTests();
} catch (err) {
toastError(err.message || 'Failed to delete test');
} finally {
deleting = false;
}
}
</script>
<div class="p-4">
<div class="flex items-center gap-4 mb-6">
<a href="/master-data" class="btn btn-ghost btn-circle">
<ArrowLeft class="w-5 h-5" />
</a>
<div class="flex-1">
<h1 class="text-xl font-bold text-gray-800">Test Definitions</h1>
<p class="text-sm text-gray-600">Manage laboratory tests, panels, and calculated values</p>
</div>
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Test
</button>
</div>
<div class="mb-4">
<div class="max-w-md">
<label class="input input-sm input-bordered w-full flex items-center gap-2">
<Search class="w-5 h-5 text-gray-400" />
<input
type="text"
class="grow"
placeholder="Search by code or name..."
bind:value={searchQuery}
/>
{#if searchQuery}
<button
class="btn btn-ghost btn-xs btn-circle"
onclick={() => searchQuery = ''}
>
×
</button>
{/if}
</label>
</div>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if loading}
<div class="flex items-center justify-center py-16">
<Loader2 class="w-8 h-8 animate-spin text-primary mr-3" />
<span class="text-gray-600">Loading tests...</span>
</div>
{:else if filteredTests.length === 0}
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="bg-base-200 rounded-full p-6 mb-4">
<Microscope class="w-12 h-12 text-gray-400" />
</div>
<h3 class="text-base font-semibold text-gray-700 mb-1">
{searchQuery ? 'No tests found' : 'No tests yet'}
</h3>
<p class="text-xs text-gray-500 text-center max-w-sm mb-4">
{searchQuery
? `No tests matching "${searchQuery}". Try a different search term.`
: 'Get started by adding your first laboratory test.'}
</p>
{#if !searchQuery}
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add First Test
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={filteredTests}
loading={false}
emptyMessage="No tests found"
hover={true}
bordered={false}
>
{#snippet cell({ column, row, value })}
{@const typeConfig = getTestTypeConfig(row.TestType)}
{@const IconComponent = typeConfig.icon}
{#if column.key === 'TestType'}
<span class="badge gap-1" style="background-color: {typeConfig.bgColor}; color: {typeConfig.color}; border-color: {typeConfig.color};">
<IconComponent class="w-3 h-3" />
{typeConfig.label}
</span>
{:else if column.key === 'Visible'}
<div class="flex justify-center gap-2">
<span class="badge {row.VisibleScr === '1' || row.VisibleScr === 1 ? 'badge-success' : 'badge-ghost'} badge-sm">S</span>
<span class="badge {row.VisibleRpt === '1' || row.VisibleRpt === 1 ? 'badge-success' : 'badge-ghost'} badge-sm">R</span>
</div>
{:else if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)} title="Edit test">
<Edit2 class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)} title="Delete test">
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else}
{value || '-'}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
</div>
<TestTypePickerModal
bind:open={testTypePickerOpen}
onselect={handleTestTypeSelect}
/>
<TestFormModal
bind:open={modalOpen}
mode={modalMode}
testId={selectedTestId}
initialTestType={selectedTestType}
{disciplines}
{departments}
{tests}
onsave={async () => {
modalOpen = false;
await loadTests();
}}
/>
<Modal bind:open={deleteConfirmOpen} title="Confirm Delete" size="sm">
<div class="py-2">
<p class="text-base-content/80">
Are you sure you want to delete <strong class="text-base-content">{deleteItem?.TestSiteName}</strong>?
</p>
{#if deleteItem?.TestSiteCode}
<p class="text-sm text-gray-500 mt-1">Code: {deleteItem.TestSiteCode}</p>
{/if}
<p class="text-sm text-error mt-3">This action cannot be undone.</p>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} type="button" disabled={deleting}>Cancel</button>
<button class="btn btn-error" onclick={handleDelete} disabled={deleting} type="button">
{#if deleting}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{deleting ? 'Deleting...' : 'Delete'}
</button>
{/snippet}
</Modal>