427 lines
12 KiB
Svelte
427 lines
12 KiB
Svelte
<script>
|
|
import { onMount } from 'svelte';
|
|
import { Plus, Trash2, RefreshCw, Search, Beaker } from 'lucide-svelte';
|
|
import {
|
|
fetchSpecimens,
|
|
fetchSpecimen,
|
|
createSpecimen,
|
|
updateSpecimen,
|
|
deleteSpecimen
|
|
} from '$lib/api/specimens.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 SpecimenFormModal from './SpecimenFormModal.svelte';
|
|
import SpecimenDetailModal from './SpecimenDetailModal.svelte';
|
|
|
|
// Search state
|
|
let searchFilters = $state({
|
|
specimenId: '',
|
|
specimenType: '',
|
|
status: ''
|
|
});
|
|
|
|
// List state
|
|
let loading = $state(false);
|
|
let specimens = $state([]);
|
|
let currentPage = $state(1);
|
|
let perPage = $state(20);
|
|
let totalItems = $state(0);
|
|
let totalPages = $state(1);
|
|
|
|
// Modal states
|
|
let specimenForm = $state({
|
|
open: false,
|
|
specimen: null,
|
|
loading: false
|
|
});
|
|
|
|
let specimenDetail = $state({
|
|
open: false,
|
|
specimen: null,
|
|
loading: false
|
|
});
|
|
|
|
let deleteModal = $state({
|
|
open: false,
|
|
specimen: null
|
|
});
|
|
|
|
// Load specimens on mount
|
|
onMount(() => {
|
|
loadSpecimens();
|
|
});
|
|
|
|
async function loadSpecimens() {
|
|
loading = true;
|
|
|
|
try {
|
|
const params = {
|
|
page: currentPage,
|
|
perPage
|
|
};
|
|
|
|
// Add filters
|
|
if (searchFilters.specimenId.trim()) {
|
|
params.SpecimenID = searchFilters.specimenId.trim();
|
|
}
|
|
if (searchFilters.specimenType.trim()) {
|
|
params.SpecimenType = searchFilters.specimenType.trim();
|
|
}
|
|
if (searchFilters.status) {
|
|
params.Status = searchFilters.status;
|
|
}
|
|
|
|
const response = await fetchSpecimens(params);
|
|
specimens = Array.isArray(response.data) ? response.data : [];
|
|
|
|
if (response.pagination) {
|
|
totalItems = response.pagination.total || 0;
|
|
totalPages = Math.ceil(totalItems / perPage) || 1;
|
|
} else {
|
|
totalItems = specimens.length;
|
|
totalPages = 1;
|
|
}
|
|
} catch (err) {
|
|
toastError(err.message || 'Failed to load specimens');
|
|
specimens = [];
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
async function handleSearch() {
|
|
currentPage = 1;
|
|
await loadSpecimens();
|
|
}
|
|
|
|
function handleClear() {
|
|
searchFilters = {
|
|
specimenId: '',
|
|
specimenType: '',
|
|
status: ''
|
|
};
|
|
currentPage = 1;
|
|
loadSpecimens();
|
|
}
|
|
|
|
function handlePageChange(newPage) {
|
|
if (newPage >= 1 && newPage <= totalPages) {
|
|
currentPage = newPage;
|
|
loadSpecimens();
|
|
}
|
|
}
|
|
|
|
// CRUD Operations
|
|
function openCreateModal() {
|
|
specimenForm = { open: true, specimen: null, loading: false };
|
|
}
|
|
|
|
async function openEditModal(specimen) {
|
|
specimenForm = { open: true, specimen: null, loading: true };
|
|
try {
|
|
const response = await fetchSpecimen(specimen.SpecimenID);
|
|
specimenForm = {
|
|
...specimenForm,
|
|
specimen: response.data || specimen,
|
|
loading: false
|
|
};
|
|
} catch (err) {
|
|
toastError(err.message || 'Failed to load specimen details');
|
|
specimenForm = {
|
|
...specimenForm,
|
|
specimen: specimen,
|
|
loading: false
|
|
};
|
|
}
|
|
}
|
|
|
|
async function openViewModal(specimen) {
|
|
specimenDetail = { open: true, specimen: null, loading: true };
|
|
try {
|
|
const response = await fetchSpecimen(specimen.SpecimenID);
|
|
specimenDetail = {
|
|
...specimenDetail,
|
|
specimen: response.data || specimen,
|
|
loading: false
|
|
};
|
|
} catch (err) {
|
|
toastError(err.message || 'Failed to load specimen details');
|
|
specimenDetail = {
|
|
...specimenDetail,
|
|
specimen: specimen,
|
|
loading: false
|
|
};
|
|
}
|
|
}
|
|
|
|
async function handleSaveSpecimen(formData) {
|
|
specimenForm.loading = true;
|
|
|
|
try {
|
|
if (specimenForm.specimen) {
|
|
// Update existing specimen
|
|
await updateSpecimen(specimenForm.specimen.SpecimenID, formData);
|
|
toastSuccess('Specimen updated successfully');
|
|
} else {
|
|
// Create new specimen
|
|
await createSpecimen(formData);
|
|
toastSuccess('Specimen created successfully');
|
|
}
|
|
|
|
specimenForm = { open: false, specimen: null, loading: false };
|
|
await loadSpecimens();
|
|
} catch (err) {
|
|
toastError(err.message || 'Failed to save specimen');
|
|
specimenForm.loading = false;
|
|
}
|
|
}
|
|
|
|
function confirmDelete(specimen) {
|
|
deleteModal = { open: true, specimen };
|
|
}
|
|
|
|
async function handleDelete() {
|
|
if (!deleteModal.specimen?.SpecimenID) return;
|
|
|
|
try {
|
|
await deleteSpecimen(deleteModal.specimen.SpecimenID);
|
|
toastSuccess('Specimen deleted successfully');
|
|
deleteModal = { open: false, specimen: null };
|
|
await loadSpecimens();
|
|
} catch (err) {
|
|
toastError(err.message || 'Failed to delete specimen');
|
|
}
|
|
}
|
|
|
|
function handleRefresh() {
|
|
loadSpecimens();
|
|
}
|
|
|
|
const columns = [
|
|
{ key: 'SpecimenID', label: 'Specimen ID', class: 'w-32' },
|
|
{ key: 'SpecimenType', label: 'Type', class: 'w-40' },
|
|
{ key: 'Status', label: 'Status', class: 'w-24' },
|
|
{ key: 'CollectionDate', label: 'Collection Date', class: 'w-40' },
|
|
{ key: 'OrderID', label: 'Order ID', class: 'w-32' },
|
|
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' }
|
|
];
|
|
|
|
function formatDate(dateString) {
|
|
if (!dateString) return '-';
|
|
return new Date(dateString).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<div class="h-[calc(100vh-4rem)] flex flex-col p-4 gap-3">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<Beaker class="w-8 h-8 text-primary" />
|
|
<div>
|
|
<h1 class="text-2xl font-bold text-gray-800">Specimens</h1>
|
|
<p class="text-sm text-gray-600">Manage laboratory specimens</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button
|
|
class="btn btn-ghost btn-sm"
|
|
onclick={handleRefresh}
|
|
disabled={loading}
|
|
>
|
|
<RefreshCw class="w-4 h-4 mr-1" />
|
|
Refresh
|
|
</button>
|
|
<button class="btn btn-primary btn-sm" onclick={openCreateModal}>
|
|
<Plus class="w-4 h-4 mr-1" />
|
|
New Specimen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Search Bar -->
|
|
<div class="bg-base-100 p-4 rounded-lg shadow-sm border border-base-200">
|
|
<div class="flex flex-wrap gap-3 items-end">
|
|
<label class="form-control flex-1 min-w-[200px]">
|
|
<span class="label-text text-xs text-gray-500 mb-1">Specimen ID</span>
|
|
<label class="input input-sm input-bordered flex items-center gap-2 w-full">
|
|
<Search class="w-4 h-4 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
class="grow bg-transparent outline-none"
|
|
placeholder="Search by ID..."
|
|
bind:value={searchFilters.specimenId}
|
|
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
|
|
/>
|
|
</label>
|
|
</label>
|
|
|
|
<label class="form-control flex-1 min-w-[200px]">
|
|
<span class="label-text text-xs text-gray-500 mb-1">Specimen Type</span>
|
|
<label class="input input-sm input-bordered flex items-center gap-2 w-full">
|
|
<Beaker class="w-4 h-4 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
class="grow bg-transparent outline-none"
|
|
placeholder="Filter by type..."
|
|
bind:value={searchFilters.specimenType}
|
|
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
|
|
/>
|
|
</label>
|
|
</label>
|
|
|
|
<label class="form-control w-40">
|
|
<span class="label-text text-xs text-gray-500 mb-1">Status</span>
|
|
<select class="select select-sm select-bordered w-full" bind:value={searchFilters.status}>
|
|
<option value="">All Status</option>
|
|
<option value="COLLECTED">Collected</option>
|
|
<option value="RECEIVED">Received</option>
|
|
<option value="PROCESSING">Processing</option>
|
|
<option value="COMPLETED">Completed</option>
|
|
<option value="REJECTED">Rejected</option>
|
|
</select>
|
|
</label>
|
|
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-primary btn-sm" onclick={handleSearch}>
|
|
<Search class="w-4 h-4 mr-1" />
|
|
Search
|
|
</button>
|
|
<button class="btn btn-ghost btn-sm" onclick={handleClear}>
|
|
Clear
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Specimen List -->
|
|
<div class="flex-1 bg-base-100 rounded-lg shadow border border-base-200 overflow-hidden">
|
|
<DataTable
|
|
{columns}
|
|
data={specimens}
|
|
{loading}
|
|
emptyMessage="No specimens found"
|
|
hover={true}
|
|
bordered={false}
|
|
>
|
|
{#snippet cell({ column, row, value })}
|
|
{#if column.key === 'Status'}
|
|
{@const statusColors = {
|
|
'COLLECTED': 'badge-info',
|
|
'RECEIVED': 'badge-primary',
|
|
'PROCESSING': 'badge-warning',
|
|
'COMPLETED': 'badge-success',
|
|
'REJECTED': 'badge-error'
|
|
}}
|
|
<span class="badge badge-sm {statusColors[value] || 'badge-ghost'}">
|
|
{value || 'Unknown'}
|
|
</span>
|
|
{:else if column.key === 'CollectionDate'}
|
|
{formatDate(value)}
|
|
{:else if column.key === 'actions'}
|
|
<div class="flex justify-center gap-1">
|
|
<button
|
|
class="btn btn-xs btn-ghost"
|
|
onclick={() => openViewModal(row)}
|
|
title="View specimen details"
|
|
>
|
|
View
|
|
</button>
|
|
<button
|
|
class="btn btn-xs btn-ghost"
|
|
onclick={() => openEditModal(row)}
|
|
title="Edit specimen"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
class="btn btn-xs btn-ghost text-error"
|
|
onclick={() => confirmDelete(row)}
|
|
title="Delete specimen"
|
|
>
|
|
<Trash2 class="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
{:else}
|
|
{value || '-'}
|
|
{/if}
|
|
{/snippet}
|
|
</DataTable>
|
|
|
|
<!-- Pagination -->
|
|
{#if totalPages > 1}
|
|
<div class="border-t border-base-200 p-3 flex justify-between items-center">
|
|
<span class="text-sm text-gray-500">
|
|
Showing {(currentPage - 1) * perPage + 1} - {Math.min(currentPage * perPage, totalItems)} of {totalItems}
|
|
</span>
|
|
<div class="join">
|
|
<button
|
|
class="join-item btn btn-sm"
|
|
onclick={() => handlePageChange(currentPage - 1)}
|
|
disabled={currentPage === 1}
|
|
>
|
|
«
|
|
</button>
|
|
<button class="join-item btn btn-sm">Page {currentPage} of {totalPages}</button>
|
|
<button
|
|
class="join-item btn btn-sm"
|
|
onclick={() => handlePageChange(currentPage + 1)}
|
|
disabled={currentPage === totalPages}
|
|
>
|
|
»
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Specimen Form Modal -->
|
|
<SpecimenFormModal
|
|
bind:open={specimenForm.open}
|
|
specimen={specimenForm.specimen}
|
|
loading={specimenForm.loading}
|
|
onSave={handleSaveSpecimen}
|
|
onCancel={() => specimenForm = { open: false, specimen: null, loading: false }}
|
|
/>
|
|
|
|
<!-- Specimen Detail Modal -->
|
|
<SpecimenDetailModal
|
|
bind:open={specimenDetail.open}
|
|
specimen={specimenDetail.specimen}
|
|
loading={specimenDetail.loading}
|
|
onClose={() => specimenDetail = { open: false, specimen: null, loading: false }}
|
|
/>
|
|
|
|
<!-- Delete Confirmation Modal -->
|
|
<Modal bind:open={deleteModal.open} title="Confirm Delete" size="sm">
|
|
<div class="py-2">
|
|
<p>
|
|
Delete specimen
|
|
<strong>{deleteModal.specimen?.SpecimenID}</strong>?
|
|
</p>
|
|
<p class="text-sm text-error mt-2">This action cannot be undone.</p>
|
|
</div>
|
|
{#snippet footer()}
|
|
<button
|
|
class="btn btn-ghost btn-sm"
|
|
onclick={() => deleteModal.open = false}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
class="btn btn-error btn-sm"
|
|
onclick={handleDelete}
|
|
>
|
|
<Trash2 class="w-4 h-4 mr-1" />
|
|
Delete
|
|
</button>
|
|
{/snippet}
|
|
</Modal>
|