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>