mahdahar f7a884577f feat: Add ADT history, specimens API, and enhance master data pages
- Add VisitADTHistoryModal for ADT tracking
- Create specimens API client
- Add HelpTooltip component
- Enhance all master data pages with improved UI
- Update patient pages and visit management
- Add implementation plans and API docs
2026-02-15 17:58:42 +07:00

483 lines
16 KiB
Svelte

<script>
import { onMount } from 'svelte';
import { fetchGeographicalAreas, fetchProvinces, fetchCities } from '$lib/api/geography.js';
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
import DataTable from '$lib/components/DataTable.svelte';
import SelectDropdown from '$lib/components/SelectDropdown.svelte';
import HelpTooltip from '$lib/components/HelpTooltip.svelte';
import {
ArrowLeft,
MapPin,
Globe,
Building2,
Search,
Info,
MapPinned,
TreePine,
Mountain,
Waves,
Home,
Building,
LandPlot,
Map,
AlertCircle,
Database
} from 'lucide-svelte';
let loading = $state(false);
let loadingMessage = $state('Loading...');
let activeTab = $state('provinces');
let areas = $state([]);
let provinces = $state([]);
let cities = $state([]);
let selectedProvince = $state('');
// Search states for each tab
let provinceSearch = $state('');
let citySearch = $state('');
let areaSearch = $state('');
// Area class to icon mapping
const areaClassIcons = {
'Province': MapPinned,
'Regency': Building,
'District': LandPlot,
'Village': Home,
'Country': Globe,
'State': Map,
'City': Building2,
'Island': TreePine,
'Mountain': Mountain,
'Coastal': Waves,
};
const areaColumns = [
{ key: 'AreaGeoID', label: 'ID', class: 'font-medium w-20' },
{ key: 'AreaCode', label: 'Code', class: 'w-24' },
{ key: 'AreaName', label: 'Name' },
{ key: 'Class', label: 'Class', class: 'w-32' },
{ key: 'Parent', label: 'Parent Area' },
];
const provinceColumns = [
{ key: 'value', label: 'ID', class: 'font-medium w-24' },
{ key: 'label', label: 'Province Name' },
];
const cityColumns = [
{ key: 'value', label: 'ID', class: 'font-medium w-24' },
{ key: 'label', label: 'City Name' },
];
onMount(async () => {
await loadProvinces();
});
async function loadAreas() {
loading = true;
loadingMessage = 'Loading geographical areas...';
try {
const response = await fetchGeographicalAreas();
areas = response.data || [];
} catch (err) {
toastError(err.message || 'Failed to load geographical areas');
areas = [];
} finally {
loading = false;
}
}
async function loadProvinces() {
loading = true;
loadingMessage = 'Loading provinces...';
try {
const response = await fetchProvinces();
provinces = Array.isArray(response) ? response : (Array.isArray(response.data) ? response.data : []);
} catch (err) {
toastError(err.message || 'Failed to load provinces');
provinces = [];
} finally {
loading = false;
}
}
async function loadCities() {
loading = true;
loadingMessage = selectedProvince
? 'Loading cities for selected province...'
: 'Loading all cities...';
try {
const provinceId = selectedProvince || null;
const response = await fetchCities(provinceId);
cities = Array.isArray(response) ? response : (Array.isArray(response.data) ? response.data : []);
} catch (err) {
toastError(err.message || 'Failed to load cities');
cities = [];
} finally {
loading = false;
}
}
function handleTabChange(tab) {
activeTab = tab;
// Reset search when changing tabs
provinceSearch = '';
citySearch = '';
areaSearch = '';
if (tab === 'areas' && areas.length === 0) {
loadAreas();
} else if (tab === 'cities' && cities.length === 0) {
loadCities();
}
}
function getAreaClassIcon(className) {
if (!className) return MapPin;
const IconComponent = areaClassIcons[className];
return IconComponent || MapPin;
}
const provinceOptions = $derived(
provinces.map((p) => ({ value: p.value, label: p.label }))
);
// Filtered data based on search
const filteredProvinces = $derived(
provinceSearch.trim()
? provinces.filter(p =>
p.label.toLowerCase().includes(provinceSearch.toLowerCase())
)
: provinces
);
const filteredCities = $derived(
citySearch.trim()
? cities.filter(c =>
c.label.toLowerCase().includes(citySearch.toLowerCase())
)
: cities
);
const filteredAreas = $derived(
areaSearch.trim()
? areas.filter(a =>
(a.AreaName && a.AreaName.toLowerCase().includes(areaSearch.toLowerCase())) ||
(a.AreaCode && a.AreaCode.toLowerCase().includes(areaSearch.toLowerCase())) ||
(a.Class && a.Class.toLowerCase().includes(areaSearch.toLowerCase()))
)
: areas
);
const selectedProvinceLabel = $derived(
selectedProvince
? provinces.find(p => String(p.value) === String(selectedProvince))?.label
: null
);
// Reload cities when province filter changes
$effect(() => {
if (activeTab === 'cities') {
loadCities();
}
});
</script>
<div class="p-6">
<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">
<div class="flex items-center gap-2">
<h1 class="text-3xl font-bold text-gray-800">Geography</h1>
<HelpTooltip
title="Geography Data"
text="Geography data is used for patient address management throughout the system. This includes province, city, and detailed area information."
position="right"
/>
</div>
<p class="text-gray-600">View geographical areas, provinces, and cities</p>
</div>
</div>
<!-- Info Banner -->
<div class="alert alert-info mb-6">
<Info class="w-5 h-5 shrink-0" />
<div>
<span class="font-medium">Reference Data</span>
<p class="text-sm opacity-80">
Geography data is read-only reference information used for patient address management.
This data is maintained by system administrators and synchronized with national standards.
</p>
</div>
</div>
<div class="tabs tabs-boxed mb-6">
<button
class="tab gap-2"
class:tab-active={activeTab === 'provinces'}
onclick={() => handleTabChange('provinces')}
>
<MapPin class="w-4 h-4" />
Provinces
{#if provinces.length > 0}
<span class="badge badge-sm badge-primary">{provinces.length}</span>
{/if}
</button>
<button
class="tab gap-2"
class:tab-active={activeTab === 'cities'}
onclick={() => handleTabChange('cities')}
>
<Building2 class="w-4 h-4" />
Cities
</button>
<button
class="tab gap-2"
class:tab-active={activeTab === 'areas'}
onclick={() => handleTabChange('areas')}
>
<Globe class="w-4 h-4" />
All Areas
</button>
</div>
{#if activeTab === 'provinces'}
<div class="space-y-4">
<!-- Tab Description -->
<div class="flex items-start gap-2 text-sm text-gray-600 bg-base-200 p-3 rounded-lg">
<MapPin class="w-4 h-4 mt-0.5 shrink-0" />
<div class="flex-1">
<span class="font-medium">Provinces</span> are the top-level administrative divisions.
Cities and areas are organized hierarchically under provinces.
<HelpTooltip
text="Provinces form the first level of the geographical hierarchy. Each city belongs to exactly one province."
position="right"
/>
</div>
</div>
<!-- Search -->
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Search provinces by name..."
class="input input-bordered w-full pl-10"
bind:value={provinceSearch}
/>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if loading}
<div class="text-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-gray-500 mt-4">{loadingMessage}</p>
</div>
{:else if filteredProvinces.length === 0}
<div class="text-center py-12">
{#if provinceSearch.trim()}
<Search class="w-12 h-12 mx-auto text-gray-300 mb-3" />
<p class="text-gray-500">No provinces match "{provinceSearch}"</p>
<button class="btn btn-sm btn-ghost mt-2" onclick={() => provinceSearch = ''}>
Clear search
</button>
{:else}
<MapPin class="w-12 h-12 mx-auto text-gray-300 mb-3" />
<p class="text-gray-500">No provinces found</p>
{/if}
</div>
{:else}
<DataTable
columns={provinceColumns}
data={filteredProvinces}
loading={false}
emptyMessage="No provinces found"
hover={true}
bordered={false}
/>
{/if}
</div>
</div>
{:else if activeTab === 'cities'}
<div class="space-y-4">
<!-- Tab Description -->
<div class="flex items-start gap-2 text-sm text-gray-600 bg-base-200 p-3 rounded-lg">
<Building2 class="w-4 h-4 mt-0.5 shrink-0" />
<div class="flex-1">
<span class="font-medium">Cities</span> are the second-level administrative divisions within provinces.
Use the filter below to view cities for a specific province.
<HelpTooltip
text="Cities are organized hierarchically under provinces. When you select a province, only cities within that province are displayed."
position="right"
/>
</div>
</div>
<!-- Province Filter -->
<div class="bg-base-200 p-4 rounded-lg">
<div class="flex items-start gap-4">
<div class="flex-1">
<SelectDropdown
label="Filter by Province"
name="province"
bind:value={selectedProvince}
options={provinceOptions}
placeholder="-- All Provinces --"
/>
</div>
{#if selectedProvince && selectedProvinceLabel}
<div class="pt-8">
<span class="badge badge-primary badge-lg">
{filteredCities.length} cities in {selectedProvinceLabel}
</span>
</div>
{/if}
</div>
<p class="text-xs text-gray-500 mt-2">
Select a province to filter cities, or leave empty to view all cities
</p>
</div>
<!-- Search -->
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder={selectedProvince
? `Search cities in ${selectedProvinceLabel}...`
: "Search cities by name..."
}
class="input input-bordered w-full pl-10"
bind:value={citySearch}
/>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if loading}
<div class="text-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-gray-500 mt-4">{loadingMessage}</p>
</div>
{:else if filteredCities.length === 0}
<div class="text-center py-12">
{#if citySearch.trim()}
<Search class="w-12 h-12 mx-auto text-gray-300 mb-3" />
<p class="text-gray-500">No cities match "{citySearch}"</p>
<button class="btn btn-sm btn-ghost mt-2" onclick={() => citySearch = ''}>
Clear search
</button>
{:else if selectedProvince}
<Building2 class="w-12 h-12 mx-auto text-gray-300 mb-3" />
<p class="text-gray-500">No cities found for {selectedProvinceLabel}</p>
<p class="text-sm text-gray-400 mt-1">Try selecting a different province</p>
{:else}
<Building2 class="w-12 h-12 mx-auto text-gray-300 mb-3" />
<p class="text-gray-500">No cities found</p>
{/if}
</div>
{:else}
<DataTable
columns={cityColumns}
data={filteredCities}
loading={false}
emptyMessage="No cities found"
hover={true}
bordered={false}
/>
{/if}
</div>
</div>
{:else if activeTab === 'areas'}
<div class="space-y-4">
<!-- Tab Description -->
<div class="flex items-start gap-2 text-sm text-gray-600 bg-base-200 p-3 rounded-lg">
<Globe class="w-4 h-4 mt-0.5 shrink-0" />
<div class="flex-1">
<span class="font-medium">All Areas</span> shows the complete geographical hierarchy including
provinces, cities, districts, and villages with their relationships.
<HelpTooltip
text="Areas represent the complete geographical hierarchy from provinces down to villages. Each area has a class indicating its level in the hierarchy and may have a parent area."
position="right"
/>
</div>
</div>
<!-- Search -->
<div class="relative">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="Search areas by name, code, or class..."
class="input input-bordered w-full pl-10"
bind:value={areaSearch}
/>
</div>
<div class="bg-base-100 rounded-lg shadow border border-base-200">
{#if loading}
<div class="text-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-gray-500 mt-4">{loadingMessage}</p>
</div>
{:else if filteredAreas.length === 0}
<div class="text-center py-12">
{#if areaSearch.trim()}
<Search class="w-12 h-12 mx-auto text-gray-300 mb-3" />
<p class="text-gray-500">No areas match "{areaSearch}"</p>
<button class="btn btn-sm btn-ghost mt-2" onclick={() => areaSearch = ''}>
Clear search
</button>
{:else}
<Globe class="w-12 h-12 mx-auto text-gray-300 mb-3" />
<p class="text-gray-500">No geographical areas found</p>
{/if}
</div>
{:else}
<DataTable
columns={areaColumns}
data={filteredAreas}
loading={false}
emptyMessage="No geographical areas found"
hover={true}
bordered={false}
>
{#snippet cell({ column, row, value })}
{#if column.key === 'Class'}
{@const IconComponent = getAreaClassIcon(value)}
<div class="flex items-center gap-2">
<IconComponent class="w-4 h-4 text-primary" />
<span>{value}</span>
</div>
{:else}
{value}
{/if}
{/snippet}
</DataTable>
{/if}
</div>
<!-- Area Class Legend -->
{#if filteredAreas.length > 0}
<div class="bg-base-200 p-4 rounded-lg">
<h4 class="text-sm font-medium mb-3 flex items-center gap-2">
<Map class="w-4 h-4" />
Area Class Types
</h4>
<div class="flex flex-wrap gap-2">
{#each Object.entries(areaClassIcons) as [className, IconComponent] (className)}
<div class="badge badge-outline gap-1">
<IconComponent class="w-3 h-3" />
{className}
</div>
{/each}
</div>
</div>
{/if}
</div>
{/if}
</div>