feat: implement location management with interactive maps

- Introduced a new `LocationMap` component for handling latitude and longitude inputs in forms and detail views.
- Updated form and detail components to utilize the `LocationMap`, enhancing user experience by allowing map-based location selection.
- Added internationalization support for map-related labels in English and Indonesian.
- Developed unit tests for the `LocationMap` to ensure functionality and reliability.

This commit enhances the application by providing a user-friendly interface for managing geographical locations, improving data accuracy and user interaction.
This commit is contained in:
shancheas
2026-08-26 07:56:04 +07:00
parent 5287da918c
commit 27dfc167d2
15 changed files with 578 additions and 62 deletions
@@ -0,0 +1,140 @@
import { useCallback, useEffect, useMemo, useRef, type RefObject } from 'react';
import { CircleMarker, MapContainer, TileLayer, useMap, useMapEvents } from 'react-leaflet';
import { Box, Text } from '@mantine/core';
import { OSM_ATTRIBUTION, OSM_TILE_URL } from './osm';
import {
DEFAULT_MAP_CENTER,
DEFAULT_MAP_ZOOM,
locationFromLatLng,
SELECTED_MAP_ZOOM,
toLocationLatLng,
} from './location-point';
import 'leaflet/dist/leaflet.css';
export interface LocationMapProps {
latitude?: unknown;
longitude?: unknown;
onChange?: (point: { latitude: number; longitude: number }) => void;
height?: number;
emptyLabel?: string;
helperLabel?: string;
defaultCenter?: [number, number];
defaultZoom?: number;
tileUrl?: string;
attribution?: string;
}
function SyncMapView({
latitude,
longitude,
lastPickedRef,
}: {
latitude: unknown;
longitude: unknown;
lastPickedRef: RefObject<{ latitude: number; longitude: number } | null>;
}) {
const map = useMap();
const point = toLocationLatLng(latitude, longitude);
const lat = point?.[0] ?? null;
const lng = point?.[1] ?? null;
const hasCentered = useRef(false);
useEffect(() => {
const id = window.setTimeout(() => map.invalidateSize(), 0);
return () => window.clearTimeout(id);
}, [map]);
useEffect(() => {
if (lat === null || lng === null) return;
const picked = lastPickedRef.current;
if (picked && picked.latitude === lat && picked.longitude === lng) {
return;
}
if (!hasCentered.current) {
hasCentered.current = true;
map.setView([lat, lng], SELECTED_MAP_ZOOM);
return;
}
map.panTo([lat, lng]);
}, [map, lat, lng, lastPickedRef]);
return null;
}
function MapClickPicker({ onPick }: { onPick: (lat: number, lng: number) => void }) {
const onPickRef = useRef(onPick);
onPickRef.current = onPick;
const handlers = useMemo(
() => ({
click(event: { latlng: { lat: number; lng: number } }) {
onPickRef.current(event.latlng.lat, event.latlng.lng);
},
}),
[],
);
useMapEvents(handlers);
return null;
}
export function LocationMap({
latitude,
longitude,
onChange,
height = 280,
emptyLabel = 'No location',
helperLabel,
defaultCenter = DEFAULT_MAP_CENTER,
defaultZoom = DEFAULT_MAP_ZOOM,
tileUrl = OSM_TILE_URL,
attribution = OSM_ATTRIBUTION,
}: LocationMapProps) {
const interactive = Boolean(onChange);
const position = toLocationLatLng(latitude, longitude);
const lastPickedRef = useRef<{ latitude: number; longitude: number } | null>(null);
const center = position ?? defaultCenter;
const zoom = position ? SELECTED_MAP_ZOOM : defaultZoom;
const handlePick = useCallback(
(lat: number, lng: number) => {
const point = locationFromLatLng(lat, lng);
lastPickedRef.current = point;
onChange?.(point);
},
[onChange],
);
if (!interactive && !position) {
return (
<Box h={height} bdrs="md" bd="1px solid var(--mantine-color-default-border)" p="md">
<Text size="sm" c="dimmed">
{emptyLabel}
</Text>
</Box>
);
}
return (
<Box>
<Box
h={height}
bdrs="md"
bd="1px solid var(--mantine-color-default-border)"
style={{ overflow: 'hidden', cursor: interactive ? 'crosshair' : undefined }}
>
<MapContainer center={center} zoom={zoom} style={{ height: '100%', width: '100%' }} scrollWheelZoom={interactive}>
<TileLayer attribution={attribution} url={tileUrl} />
{position ? (
<CircleMarker center={position} radius={10} pathOptions={{ color: 'var(--mantine-color-blue-8)', fillOpacity: 0.9 }} />
) : null}
<SyncMapView latitude={latitude} longitude={longitude} lastPickedRef={lastPickedRef} />
{onChange ? <MapClickPicker onPick={handlePick} /> : null}
</MapContainer>
</Box>
{interactive && helperLabel ? (
<Text size="xs" c="dimmed" mt="xs">
{helperLabel}
</Text>
) : null}
</Box>
);
}