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

363 lines
12 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 { fetchSpecialties, createSpecialty, updateSpecialty, deleteSpecialty } from '$lib/api/specialties.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 SelectDropdown from '$lib/components/SelectDropdown.svelte';
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
import { Plus, Edit2, Trash2, ArrowLeft, Search, Stethoscope, FolderTree } from 'lucide-svelte';
let loading = $state(false);
let specialties = $state([]);
let modalOpen = $state(false);
let modalMode = $state('create');
let saving = $state(false);
let formData = $state({ SpecialtyID: null, SpecialtyText: '', Title: '', Parent: '' });
let deleteConfirmOpen = $state(false);
let deleteItem = $state(null);
let deleting = $state(false);
let searchQuery = $state('');
const columns = [
{ key: 'SpecialtyText', label: 'Specialty Name', class: 'font-medium' },
{ key: 'Title', label: 'Title' },
{ key: 'ParentLabel', label: 'Parent Specialty' },
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
];
onMount(async () => {
await loadSpecialties();
});
async function loadSpecialties() {
loading = true;
try {
const response = await fetchSpecialties();
specialties = Array.isArray(response.data) ? response.data : [];
} catch (err) {
toastError(err.message || 'Failed to load specialties');
specialties = [];
} finally {
loading = false;
}
}
// Filter specialties based on search query
const filteredSpecialties = $derived(
specialties.filter((s) => {
if (!searchQuery.trim()) return true;
const query = searchQuery.toLowerCase();
const nameMatch = (s.SpecialtyText || '').toLowerCase().includes(query);
const titleMatch = (s.Title || '').toLowerCase().includes(query);
const parentMatch = (s.ParentLabel || '').toLowerCase().includes(query);
return nameMatch || titleMatch || parentMatch;
})
);
function openCreateModal() {
modalMode = 'create';
formData = { SpecialtyID: null, SpecialtyText: '', Title: '', Parent: '' };
modalOpen = true;
}
function openEditModal(row) {
modalMode = 'edit';
formData = {
SpecialtyID: row.SpecialtyID,
SpecialtyText: row.SpecialtyText || '',
Title: row.Title || '',
Parent: row.Parent || '',
};
modalOpen = true;
}
// Check for duplicate specialty name
function isDuplicateName(name, excludeId = null) {
const normalizedName = name.trim().toLowerCase();
return specialties.some(
(s) =>
s.SpecialtyText.trim().toLowerCase() === normalizedName &&
s.SpecialtyID !== excludeId
);
}
async function handleSave() {
// Validate for duplicate names
if (isDuplicateName(formData.SpecialtyText, formData.SpecialtyID)) {
toastError(`A specialty with the name "${formData.SpecialtyText}" already exists`);
return;
}
saving = true;
try {
if (modalMode === 'create') {
await createSpecialty(formData);
toastSuccess('Specialty created successfully');
} else {
await updateSpecialty(formData);
toastSuccess('Specialty updated successfully');
}
modalOpen = false;
await loadSpecialties();
} catch (err) {
toastError(err.message || 'Failed to save specialty');
} finally {
saving = false;
}
}
function confirmDelete(row) {
deleteItem = row;
deleteConfirmOpen = true;
}
async function handleDelete() {
deleting = true;
try {
await deleteSpecialty(deleteItem.SpecialtyID);
toastSuccess('Specialty deleted successfully');
deleteConfirmOpen = false;
await loadSpecialties();
} catch (err) {
toastError(err.message || 'Failed to delete specialty');
} finally {
deleting = false;
}
}
const parentOptions = $derived(
specialties
.filter((s) => s.SpecialtyID !== formData.SpecialtyID)
.map((s) => ({ value: s.SpecialtyID, label: s.SpecialtyText }))
);
const specialtyMap = $derived(
Object.fromEntries(specialties.map((s) => [s.SpecialtyID, s.SpecialtyText]))
);
// Get parent specialty name
function getParentName(parentId) {
if (!parentId || parentId === '0') return null;
return specialtyMap[parentId] || null;
}
</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">Medical Specialties</h1>
<p class="text-sm text-gray-600">Manage medical specialty codes and their hierarchical relationships</p>
</div>
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add Specialty
</button>
</div>
<!-- Search Bar -->
<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-4 h-4 text-gray-400" />
<input
type="text"
class="grow"
placeholder="Search by specialty name, title, or parent..."
bind:value={searchQuery}
/>
{#if searchQuery}
<button
class="btn btn-xs btn-ghost btn-circle"
onclick={() => (searchQuery = '')}
aria-label="Clear search"
>
×
</button>
{/if}
</label>
</div>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if filteredSpecialties.length === 0 && !loading}
<!-- Empty State -->
<div class="flex flex-col items-center justify-center py-16 px-4">
<div class="bg-base-200 rounded-full p-6 mb-4">
<Stethoscope class="w-12 h-12 text-base-content/40" />
</div>
<h3 class="text-base font-semibold text-base-content mb-2">
{searchQuery ? 'No specialties match your search' : 'No specialties yet'}
</h3>
<p class="text-base-content/60 text-center max-w-sm mb-4">
{searchQuery
? 'Try adjusting your search terms or clear the filter to see all specialties.'
: 'Get started by adding your first medical specialty to the system.'}
</p>
{#if searchQuery}
<button class="btn btn-ghost btn-sm" onclick={() => (searchQuery = '')}>
Clear Search
</button>
{:else}
<button class="btn btn-primary" onclick={openCreateModal}>
<Plus class="w-4 h-4 mr-2" />
Add First Specialty
</button>
{/if}
</div>
{:else}
<DataTable
{columns}
data={filteredSpecialties.map((s) => ({
...s,
ParentLabel: s.Parent === '0' || !s.Parent ? '-' : specialtyMap[s.Parent] || '-',
hasParent: s.Parent && s.Parent !== '0',
}))}
{loading}
emptyMessage="No specialties found"
hover={true}
bordered={false}
>
{#snippet cell({ column, row, value })}
{#if column.key === 'actions'}
<div class="flex justify-center gap-2">
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)}>
<Edit2 class="w-4 h-4" />
</button>
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)}>
<Trash2 class="w-4 h-4" />
</button>
</div>
{:else if column.key === 'SpecialtyText'}
<div class="flex items-center gap-2">
{#if row.hasParent}
<span class="text-base-content/40 ml-4">└─</span>
{/if}
<span class={row.hasParent ? 'text-base-content/80' : ''}>{value}</span>
{#if row.hasParent}
<span class="badge badge-sm badge-ghost">
<FolderTree class="w-3 h-3 mr-1" />
Child
</span>
{/if}
</div>
{:else}
{value}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
</div>
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Specialty' : 'Edit Specialty'} size="md">
<form class="space-y-3" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label" for="specialtyText">
<span class="label-text text-sm font-medium">Specialty Name</span>
<span class="label-text-alt text-xs text-error">*</span>
</label>
<input
id="specialtyText"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.SpecialtyText}
placeholder="e.g., Cardiology, Internal Medicine"
required
/>
<label class="label" for="specialtyText">
<span class="label-text-alt text-xs text-base-content/50">Unique name for the specialty</span>
</label>
</div>
<div class="form-control">
<label class="label" for="title">
<span class="label-text text-sm font-medium">Professional Title</span>
</label>
<input
id="title"
type="text"
class="input input-sm input-bordered w-full"
bind:value={formData.Title}
placeholder="e.g., Sp. PD, Sp. A, Sp. And"
/>
<label class="label" for="title">
<span class="label-text-alt text-xs text-base-content/50">Official abbreviation or title</span>
</label>
</div>
</div>
<div class="form-control">
<div class="flex items-center gap-2 mb-1">
<label class="label py-0" for="parent">
<span class="label-text text-sm font-medium">Parent Specialty</span>
</label>
<HelpTooltip
text="Organize specialties hierarchically. For example, select 'Internal Medicine' as the parent for 'Cardiology' to show it as a subspecialty."
title="Parent Specialty Hierarchy"
position="right"
/>
</div>
<SelectDropdown
label=""
name="parent"
bind:value={formData.Parent}
options={parentOptions}
placeholder="None (Top-level specialty)"
/>
<label class="label" for="parent">
<span class="label-text-alt text-xs text-base-content/50">
Optional: Select a parent specialty to create a subspecialty
</span>
</label>
</div>
</form>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (modalOpen = false)} type="button">Cancel</button>
<button class="btn btn-primary" onclick={handleSave} disabled={saving} type="button">
{#if saving}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
{saving ? 'Saving...' : 'Save'}
</button>
{/snippet}
</Modal>
<Modal bind:open={deleteConfirmOpen} title="Confirm Deletion" size="md">
<div class="py-4 space-y-3">
<div class="flex items-start gap-3">
<div class="bg-error/10 rounded-full p-2">
<Trash2 class="w-6 h-6 text-error" />
</div>
<div>
<p class="text-base-content font-medium">
Are you sure you want to delete this specialty?
</p>
<p class="text-base-content/70 mt-1">
<strong class="text-base-content">{deleteItem?.SpecialtyText}</strong>
{#if deleteItem?.Title}
<span class="text-base-content/50">({deleteItem?.Title})</span>
{/if}
</p>
</div>
</div>
<div class="bg-warning/10 border border-warning/20 rounded-lg p-3">
<p class="text-sm text-warning">
<strong>Warning:</strong> This action cannot be undone. If this specialty has child specialties,
they may become orphaned and need to be reassigned.
</p>
</div>
</div>
{#snippet footer()}
<button class="btn btn-ghost" onclick={() => (deleteConfirmOpen = false)} type="button">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 Specialty'}
</button>
{/snippet}
</Modal>