diff --git a/apps/portal/src/app/features/location/api/location-api.ts b/apps/portal/src/app/features/location/api/location-api.ts new file mode 100644 index 000000000..502820896 --- /dev/null +++ b/apps/portal/src/app/features/location/api/location-api.ts @@ -0,0 +1,19 @@ +import { baseApi } from '@ema-platform/api'; +import type { Location, LocationType, ListResponse } from '../types/location'; + +const locationApi = baseApi.injectEndpoints({ + endpoints: (builder) => ({ + getLocationTypes: builder.query, void>({ + query: () => '/location-types', + }), + getLocations: builder.query< + ListResponse, + { take?: number } + >({ + query: (params) => ({ url: '/locations', params }), + }), + }), + overrideExisting: false, +}); + +export const { useGetLocationTypesQuery, useGetLocationsQuery } = locationApi; diff --git a/apps/portal/src/app/features/location/components/LocationPicker.tsx b/apps/portal/src/app/features/location/components/LocationPicker.tsx new file mode 100644 index 000000000..2b1205f67 --- /dev/null +++ b/apps/portal/src/app/features/location/components/LocationPicker.tsx @@ -0,0 +1,225 @@ +import { useState, useMemo, useEffect, useCallback } from 'react'; +import { Stack, Select, Group, Text, Loader, Center, Badge } from '@mantine/core'; +import { useGetLocationTypesQuery, useGetLocationsQuery } from '../api/location-api'; +import type { Location, LocationType } from '../types/location'; +import { useTranslation } from 'react-i18next'; + +interface LocationPickerProps { + value?: string; + onChange: (locationId: string | null) => void; + required?: boolean; +} + +export function LocationPicker({ value, onChange, required }: LocationPickerProps) { + const { t } = useTranslation(); + const { data: typesRes, isLoading: typesLoading } = useGetLocationTypesQuery(); + const { data: locsRes, isLoading: locsLoading } = useGetLocationsQuery({ take: 10000 }); + + const locationTypes = typesRes?.items ?? []; + const allLocations = locsRes?.items ?? []; + + const locMap = useMemo(() => { + const map = new Map(); + allLocations.forEach((loc) => map.set(loc.id, loc)); + return map; + }, [allLocations]); + + const typeMap = useMemo(() => { + const map = new Map(); + locationTypes.forEach((t) => map.set(t.id, t)); + return map; + }, [locationTypes]); + + const childrenByParentId = useMemo(() => { + const map = new Map(); + allLocations.forEach((loc) => { + const key = loc.parentId ?? '__root__'; + if (!map.has(key)) map.set(key, []); + map.get(key)!.push(loc); + }); + return map; + }, [allLocations]); + + const [selectedChain, setSelectedChain] = useState([]); + + useEffect(() => { + if (!value || locMap.size === 0) return; + const loc = locMap.get(value); + if (!loc) return; + + const chain: Location[] = []; + let current: Location | undefined = loc; + while (current) { + chain.unshift(current); + current = current.parentId ? locMap.get(current.parentId) : undefined; + } + setSelectedChain(chain); + }, [value, locMap]); + + const currentLevelChildren = useMemo(() => { + const parentId = + selectedChain.length === 0 + ? null + : selectedChain[selectedChain.length - 1].id; + const key = parentId ?? '__root__'; + return childrenByParentId.get(key) ?? []; + }, [selectedChain, childrenByParentId]); + + const levelLabel = useMemo(() => { + if (currentLevelChildren.length === 0) return ''; + const typeIds = [...new Set(currentLevelChildren.map((c) => c.locationTypeId))]; + const names = typeIds + .map((id) => typeMap.get(id)?.names.en) + .filter(Boolean) as string[]; + return names.join(' / '); + }, [currentLevelChildren, typeMap]); + + const depth = selectedChain.length; + + const allLevelsComplete = useMemo(() => { + return selectedChain.every((loc, i) => { + const key = loc.id; + const children = childrenByParentId.get(key); + return !children || children.length === 0; + }); + }, [selectedChain, childrenByParentId]); + + const handleSelect = useCallback( + (id: string | null) => { + if (!id) { + const newChain = selectedChain.slice(0, -1); + setSelectedChain(newChain); + onChange(newChain.length > 0 ? newChain[newChain.length - 1].id : null); + return; + } + + const loc = locMap.get(id); + if (!loc) return; + + const newChain = selectedChain.slice(0, depth); + newChain.push(loc); + setSelectedChain(newChain); + + onChange(id); + }, + [selectedChain, locMap, onChange, depth], + ); + + const buildOptions = (levelIdx: number) => { + if (levelIdx === 0) { + const roots = childrenByParentId.get('__root__') ?? []; + return roots + .map((loc) => ({ value: loc.id, label: loc.names.en })) + .sort((a, b) => a.label.localeCompare(b.label)); + } + + const parent = selectedChain[levelIdx - 1]; + if (!parent) return []; + const children = childrenByParentId.get(parent.id) ?? []; + return children + .map((loc) => ({ value: loc.id, label: loc.names.en })) + .sort((a, b) => a.label.localeCompare(b.label)); + }; + + const getLevelLabel = (levelIdx: number) => { + if (levelIdx === 0) { + const roots = childrenByParentId.get('__root__') ?? []; + if (roots.length === 0) return ''; + const typeIds = [...new Set(roots.map((r) => r.locationTypeId))]; + const names = typeIds + .map((id) => typeMap.get(id)?.names.en) + .filter(Boolean) as string[]; + return names.join(' / '); + } + + const parent = selectedChain[levelIdx - 1]; + if (!parent) return t('location.chooseFirst'); + const children = childrenByParentId.get(parent.id) ?? []; + const typeIds = [...new Set(children.map((c) => c.locationTypeId))]; + const names = typeIds + .map((id) => typeMap.get(id)?.names.en) + .filter(Boolean) as string[]; + return names.join(' / ') || t('location.subLocation'); + }; + + const selectedPath = useMemo(() => { + return selectedChain + .map((loc) => loc.names.en) + .join(' → '); + }, [selectedChain]); + + if (typesLoading || locsLoading) { + return ( +
+ +
+ ); + } + + const roots = childrenByParentId.get('__root__') ?? []; + + if (roots.length === 0) { + return ( + + {t('location.noLocationsAvailable')} + + ); + } + + const totalRenderedLevels = Math.max( + 1, + selectedChain.length + (currentLevelChildren.length > 0 ? 1 : 0), + ); + + const levels = Array.from({ length: totalRenderedLevels }, (_, i) => i); + + return ( + + + {levels.map((levelIdx) => { + const options = buildOptions(levelIdx); + const currentValue = selectedChain[levelIdx]?.id ?? null; + const isDisabled = levelIdx > 0 && !selectedChain[levelIdx - 1]; + + return ( + - setCity(e.currentTarget.value)} /> + + + Location {locationId ? : *} + + +