change location to dynamic fields

This commit is contained in:
mengstabketemaw
2026-06-17 17:06:04 +03:00
parent a675997946
commit 3a528c9515
6 changed files with 301 additions and 13 deletions

View File

@@ -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<ListResponse<LocationType>, void>({
query: () => '/location-types',
}),
getLocations: builder.query<
ListResponse<Location>,
{ take?: number }
>({
query: (params) => ({ url: '/locations', params }),
}),
}),
overrideExisting: false,
});
export const { useGetLocationTypesQuery, useGetLocationsQuery } = locationApi;

View File

@@ -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<string, Location>();
allLocations.forEach((loc) => map.set(loc.id, loc));
return map;
}, [allLocations]);
const typeMap = useMemo(() => {
const map = new Map<string, LocationType>();
locationTypes.forEach((t) => map.set(t.id, t));
return map;
}, [locationTypes]);
const childrenByParentId = useMemo(() => {
const map = new Map<string, Location[]>();
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<Location[]>([]);
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 (
<Center py="md">
<Loader size="sm" />
</Center>
);
}
const roots = childrenByParentId.get('__root__') ?? [];
if (roots.length === 0) {
return (
<Text size="sm" c="dimmed">
{t('location.noLocationsAvailable')}
</Text>
);
}
const totalRenderedLevels = Math.max(
1,
selectedChain.length + (currentLevelChildren.length > 0 ? 1 : 0),
);
const levels = Array.from({ length: totalRenderedLevels }, (_, i) => i);
return (
<Stack gap="sm">
<Group gap="xs" align="end" wrap="wrap">
{levels.map((levelIdx) => {
const options = buildOptions(levelIdx);
const currentValue = selectedChain[levelIdx]?.id ?? null;
const isDisabled = levelIdx > 0 && !selectedChain[levelIdx - 1];
return (
<Select
key={levelIdx}
label={getLevelLabel(levelIdx) || `Level ${levelIdx + 1}`}
placeholder={t('location.select')}
data={options}
value={currentValue}
onChange={(val) => handleSelect(val)}
disabled={isDisabled}
searchable
clearable
size="sm"
style={{ minWidth: 160, flex: 1 }}
nothingFoundMessage={t('location.noOptions')}
required={required && levelIdx === levels.length - 1}
/>
);
})}
</Group>
{selectedPath && (
<Group gap="xs">
{selectedChain.map((loc) => {
const typeInfo = typeMap.get(loc.locationTypeId);
return (
<Badge
key={loc.id}
size="sm"
variant="light"
color="blue"
style={{ textTransform: 'none' }}
>
{typeInfo ? `${typeInfo.names.en}: ` : ''}
{loc.names.en}
</Badge>
);
})}
</Group>
)}
</Stack>
);
}

View File

@@ -0,0 +1,24 @@
export interface NamePair {
en: string;
am: string;
}
export interface LocationType {
id: string;
code: string;
names: NamePair;
level: number;
}
export interface Location {
id: string;
code: string;
names: NamePair;
locationTypeId: string;
parentId: string | null;
}
export interface ListResponse<T> {
count: number;
items: T[];
}

View File

@@ -37,6 +37,7 @@ import { notify } from '@ema-platform/ui';
import { BilingualInput } from '../../../components/BilingualInput';
import type { BilingualValue } from '../../../components/BilingualInput';
import { AmharicDatePicker, toEthiopicDateLabel } from '../../../components/AmharicDatePicker';
import { LocationPicker } from '../../location/components/LocationPicker';
// ---------------------------------------------------------------------------
// Dummy API
@@ -55,11 +56,6 @@ const NATIONALITIES = [
];
const MARITAL_STATUSES = ['Single', 'Married', 'Divorced', 'Widowed'];
const GENDERS = ['Male', 'Female'];
const REGIONS = [
'Addis Ababa', 'Dire Dawa', 'Amhara', 'Oromia', 'Tigray',
'Afar', 'Somali', 'Sidama', 'South Ethiopia', 'Gambela',
'Benishangul-Gumuz', 'Harari',
];
const RELATIONSHIPS = ['Spouse', 'Parent', 'Sibling', 'Child', 'Friend', 'Other'];
const STEPS = [
@@ -279,8 +275,7 @@ export function SeafarerRegistrationPage() {
// Step 2 — Contact Details
const [mobile, setMobile] = useState('');
const [email, setEmail] = useState('');
const [region, setRegion] = useState<string | null>(null);
const [city, setCity] = useState('');
const [locationId, setLocationId] = useState<string | null>(null);
const [permanentAddress, setPermanentAddress] = useState('');
const [currentAddress, setCurrentAddress] = useState('');
const [emergencyName, setEmergencyName] = useState('');
@@ -297,7 +292,7 @@ export function SeafarerRegistrationPage() {
const canNext = () => {
if (active === 0) return !!firstName.en.trim() && !!lastName.en.trim() && !!gender && !!dob && !!placeOfBirth && !!nationality && !!nationalIdNumber.trim();
if (active === 1) return !!mobile.trim() && !!email.trim() && !!region && !!city.trim();
if (active === 1) return !!mobile.trim() && !!email.trim() && !!locationId;
if (active === 2) return !!files.nationalId && !!files.photo;
return true;
};
@@ -313,7 +308,7 @@ export function SeafarerRegistrationPage() {
try {
const result = await submitSeafarerRegistration({
personalInfo: { firstName, middleName, lastName, gender, dob: dob?.toISOString().split('T')[0] ?? '', placeOfBirth, nationality, maritalStatus, nationalIdNumber, passportNumber, passportExpiry },
contactDetails: { mobile, email, region, city, permanentAddress, currentAddress, emergency: { name: emergencyName, relationship: emergencyRel, phone: emergencyPhone } },
contactDetails: { mobile, email, locationId, permanentAddress, currentAddress, emergency: { name: emergencyName, relationship: emergencyRel, phone: emergencyPhone } },
documents: Object.fromEntries(Object.entries(files).map(([k, v]) => [k, v?.name ?? null])),
});
notify.success(`Registration submitted! Reference: ${result.referenceId}`);
@@ -386,8 +381,16 @@ export function SeafarerRegistrationPage() {
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<TextInput label="Mobile Number" placeholder="+251 9XX XXX XXX" required value={mobile} onChange={(e) => setMobile(e.currentTarget.value)} />
<TextInput label="Email Address" placeholder="email@example.com" type="email" required value={email} onChange={(e) => setEmail(e.currentTarget.value)} />
<Select label="Region" placeholder="Select region" required data={REGIONS} value={region} onChange={setRegion} searchable />
<TextInput label="City" placeholder="City" required value={city} onChange={(e) => setCity(e.currentTarget.value)} />
<Box style={{ gridColumn: '1 / -1' }}>
<Text fz="sm" fw={500} mb={4}>
Location {locationId ? <Text span c="green" size="sm"></Text> : <Text span c="red" size="sm">*</Text>}
</Text>
<LocationPicker
value={locationId ?? undefined}
onChange={setLocationId}
required
/>
</Box>
</SimpleGrid>
<Textarea label="Permanent Address" placeholder="Full permanent address" autosize minRows={2} value={permanentAddress} onChange={(e) => setPermanentAddress(e.currentTarget.value)} />
<Textarea
@@ -471,8 +474,7 @@ export function SeafarerRegistrationPage() {
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
<ReviewRow label="Mobile" value={mobile} />
<ReviewRow label="Email" value={email} />
<ReviewRow label="Region" value={region ?? ''} />
<ReviewRow label="City" value={city} />
<ReviewRow label="Location" value={locationId ?? ''} />
<ReviewRow label="Permanent Address" value={permanentAddress} />
<ReviewRow label="Current Address" value={currentAddress} />
</SimpleGrid>

View File

@@ -138,6 +138,15 @@ export const am: Translations = {
},
},
location: {
select: 'ይምረጡ...',
noOptions: 'ምንም አማራጮች አልተገኙም',
noLocationsAvailable: 'ምንም አካባቢዎች የሉም',
chooseFirst: 'መጀመሪያ አካባቢ ይምረጡ',
subLocation: 'ንዑስ አካባቢ',
loading: 'አካባቢዎች በመጫን ላይ...',
},
support: {
title: 'እገዛና ድጋፍ',
subtitle: 'መመሪያ፣ የመገናኛ መንገዶችና ለተደጋጋሚ ጥያቄዎች መልሶች።',

View File

@@ -136,6 +136,15 @@ export const en = {
},
},
location: {
select: 'Select...',
noOptions: 'No options found',
noLocationsAvailable: 'No locations available',
chooseFirst: 'Choose a location first',
subLocation: 'Sub-location',
loading: 'Loading locations...',
},
support: {
title: 'Help & Support',
subtitle: 'Guidance, contact channels and answers to common questions.',