2026-02-10 17:00:05 +07:00
< 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';
2026-02-15 17:58:42 +07:00
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';
2026-02-10 17:00:05 +07:00
let loading = $state(false);
2026-02-15 17:58:42 +07:00
let loadingMessage = $state('Loading...');
2026-02-10 17:00:05 +07:00
let activeTab = $state('provinces');
let areas = $state([]);
let provinces = $state([]);
let cities = $state([]);
let selectedProvince = $state('');
2026-02-15 17:58:42 +07:00
// 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,
};
2026-02-10 17:00:05 +07:00
const areaColumns = [
2026-02-15 17:58:42 +07:00
{ key : 'AreaGeoID' , label : 'ID' , class : 'font-medium w-20' } ,
{ key : 'AreaCode' , label : 'Code' , class : 'w-24' } ,
2026-02-10 17:00:05 +07:00
{ key : 'AreaName' , label : 'Name' } ,
2026-02-15 17:58:42 +07:00
{ key : 'Class' , label : 'Class' , class : 'w-32' } ,
{ key : 'Parent' , label : 'Parent Area' } ,
2026-02-10 17:00:05 +07:00
];
const provinceColumns = [
2026-02-15 17:58:42 +07:00
{ key : 'value' , label : 'ID' , class : 'font-medium w-24' } ,
2026-02-10 17:00:05 +07:00
{ key : 'label' , label : 'Province Name' } ,
];
const cityColumns = [
2026-02-15 17:58:42 +07:00
{ key : 'value' , label : 'ID' , class : 'font-medium w-24' } ,
2026-02-10 17:00:05 +07:00
{ key : 'label' , label : 'City Name' } ,
];
onMount(async () => {
await loadProvinces();
});
async function loadAreas() {
loading = true;
2026-02-15 17:58:42 +07:00
loadingMessage = 'Loading geographical areas...';
2026-02-10 17:00:05 +07:00
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;
2026-02-15 17:58:42 +07:00
loadingMessage = 'Loading provinces...';
2026-02-10 17:00:05 +07:00
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;
2026-02-15 17:58:42 +07:00
loadingMessage = selectedProvince
? 'Loading cities for selected province...'
: 'Loading all cities...';
2026-02-10 17:00:05 +07:00
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;
2026-02-15 17:58:42 +07:00
// Reset search when changing tabs
provinceSearch = '';
citySearch = '';
areaSearch = '';
2026-02-10 17:00:05 +07:00
if (tab === 'areas' && areas.length === 0) {
loadAreas();
} else if (tab === 'cities' && cities.length === 0) {
loadCities();
}
}
2026-02-15 17:58:42 +07:00
function getAreaClassIcon(className) {
if (!className) return MapPin;
const IconComponent = areaClassIcons[className];
return IconComponent || MapPin;
}
2026-02-10 17:00:05 +07:00
const provinceOptions = $derived(
provinces.map((p) => ({ value : p.value , label : p.label } ))
);
2026-02-15 17:58:42 +07:00
// 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
);
2026-02-10 17:00:05 +07:00
// 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" >
2026-02-15 17:58:42 +07:00
< 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 >
2026-02-10 17:00:05 +07:00
< p class = "text-gray-600" > View geographical areas, provinces, and cities< / p >
< / div >
< / div >
2026-02-15 17:58:42 +07:00
<!-- 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 >
2026-02-10 17:00:05 +07:00
< div class = "tabs tabs-boxed mb-6" >
2026-02-15 17:58:42 +07:00
< button
class="tab gap-2"
2026-02-10 17:00:05 +07:00
class:tab-active={ activeTab === 'provinces' }
onclick={() => handleTabChange ( 'provinces' )}
>
< MapPin class = "w-4 h-4" / >
Provinces
2026-02-15 17:58:42 +07:00
{ #if provinces . length > 0 }
< span class = "badge badge-sm badge-primary" > { provinces . length } </ span >
{ /if }
2026-02-10 17:00:05 +07:00
< / button >
2026-02-15 17:58:42 +07:00
< button
class="tab gap-2"
2026-02-10 17:00:05 +07:00
class:tab-active={ activeTab === 'cities' }
onclick={() => handleTabChange ( 'cities' )}
>
< Building2 class = "w-4 h-4" / >
Cities
< / button >
2026-02-15 17:58:42 +07:00
< button
class="tab gap-2"
2026-02-10 17:00:05 +07:00
class:tab-active={ activeTab === 'areas' }
onclick={() => handleTabChange ( 'areas' )}
>
< Globe class = "w-4 h-4" / >
All Areas
< / button >
< / div >
{ #if activeTab === 'provinces' }
2026-02-15 17:58:42 +07:00
< 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 >
2026-02-10 17:00:05 +07:00
< / div >
2026-02-15 17:58:42 +07:00
2026-02-10 17:00:05 +07:00
{ :else if activeTab === 'cities' }
< div class = "space-y-4" >
2026-02-15 17:58:42 +07:00
<!-- 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 -->
2026-02-10 17:00:05 +07:00
< div class = "bg-base-200 p-4 rounded-lg" >
2026-02-15 17:58:42 +07:00
< 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 }
2026-02-10 17:00:05 +07:00
/>
< / div >
2026-02-15 17:58:42 +07:00
2026-02-10 17:00:05 +07:00
< div class = "bg-base-100 rounded-lg shadow border border-base-200" >
2026-02-15 17:58:42 +07:00
{ #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 }
2026-02-10 17:00:05 +07:00
< / div >
< / div >
2026-02-15 17:58:42 +07:00
2026-02-10 17:00:05 +07:00
{ :else if activeTab === 'areas' }
2026-02-15 17:58:42 +07:00
< 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 }
2026-02-10 17:00:05 +07:00
< / div >
{ /if }
< / div >