mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 18:48:12 +00:00
change location to dynamic fields
This commit is contained in:
19
apps/portal/src/app/features/location/api/location-api.ts
Normal file
19
apps/portal/src/app/features/location/api/location-api.ts
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
24
apps/portal/src/app/features/location/types/location.ts
Normal file
24
apps/portal/src/app/features/location/types/location.ts
Normal 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[];
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -138,6 +138,15 @@ export const am: Translations = {
|
||||
},
|
||||
},
|
||||
|
||||
location: {
|
||||
select: 'ይምረጡ...',
|
||||
noOptions: 'ምንም አማራጮች አልተገኙም',
|
||||
noLocationsAvailable: 'ምንም አካባቢዎች የሉም',
|
||||
chooseFirst: 'መጀመሪያ አካባቢ ይምረጡ',
|
||||
subLocation: 'ንዑስ አካባቢ',
|
||||
loading: 'አካባቢዎች በመጫን ላይ...',
|
||||
},
|
||||
|
||||
support: {
|
||||
title: 'እገዛና ድጋፍ',
|
||||
subtitle: 'መመሪያ፣ የመገናኛ መንገዶችና ለተደጋጋሚ ጥያቄዎች መልሶች።',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
Reference in New Issue
Block a user