- 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
483 lines
16 KiB
Svelte
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>
|