added location registeration

This commit is contained in:
mengstabketemaw
2026-06-17 16:25:58 +03:00
parent 85e070db33
commit ccb2b6118c
11 changed files with 1237 additions and 0 deletions

View File

@@ -0,0 +1,68 @@
import { baseApi } from '@ema-platform/api';
import type {
Location,
LocationType,
ListResponse,
CreateLocationPayload,
UpdateLocationPayload,
CreateLocationTypePayload,
UpdateLocationTypePayload,
} from '../types/location';
const locationApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getLocationTypes: builder.query<ListResponse<LocationType>, void>({
query: () => '/location-types',
providesTags: ['Api'],
}),
createLocationType: builder.mutation<LocationType, CreateLocationTypePayload>({
query: (body) => ({ url: '/location-types', method: 'POST', body }),
invalidatesTags: ['Api'],
}),
updateLocationType: builder.mutation<LocationType, UpdateLocationTypePayload>({
query: ({ id, ...body }) => ({
url: `/location-types/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: ['Api'],
}),
deleteLocationType: builder.mutation<void, string>({
query: (id) => ({ url: `/location-types/${id}`, method: 'DELETE' }),
invalidatesTags: ['Api'],
}),
getLocations: builder.query<ListResponse<Location>, { parentId?: string; locationTypeId?: string; take?: number; skip?: number }>({
query: (params) => ({ url: '/locations', params }),
providesTags: ['Api'],
}),
createLocation: builder.mutation<Location, CreateLocationPayload>({
query: (body) => ({ url: '/locations', method: 'POST', body }),
invalidatesTags: ['Api'],
}),
updateLocation: builder.mutation<Location, UpdateLocationPayload>({
query: ({ id, ...body }) => ({
url: `/locations/${id}`,
method: 'PUT',
body,
}),
invalidatesTags: ['Api'],
}),
deleteLocation: builder.mutation<void, string>({
query: (id) => ({ url: `/locations/${id}`, method: 'DELETE' }),
invalidatesTags: ['Api'],
}),
}),
overrideExisting: false,
});
export const {
useGetLocationTypesQuery,
useCreateLocationTypeMutation,
useUpdateLocationTypeMutation,
useDeleteLocationTypeMutation,
useGetLocationsQuery,
useCreateLocationMutation,
useUpdateLocationMutation,
useDeleteLocationMutation,
} = locationApi;

View File

@@ -0,0 +1,129 @@
import {
Paper,
Title,
Text,
Group,
Button,
Stack,
Badge,
Divider,
ActionIcon,
Menu,
rem,
} from '@mantine/core';
import {
IconEdit,
IconTrash,
IconPlus,
IconDots,
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { Location, LocationType } from '../types/location';
interface LocationDetailProps {
location: Location;
locationTypes: LocationType[];
onAddChild: () => void;
onEdit: () => void;
onDelete: () => void;
}
export function LocationDetail({
location,
locationTypes,
onAddChild,
onEdit,
onDelete,
}: LocationDetailProps) {
const { t } = useTranslation();
const typeInfo = locationTypes.find(
(lt) => lt.id === location.locationTypeId,
);
const isLeaf =
!locationTypes.some(
(lt) => lt.level === (typeInfo?.level ?? 0) + 1,
);
return (
<Paper p="lg" radius="md" withBorder>
<Group justify="space-between" mb="xs">
<Title order={4}>{location.names.en}</Title>
<Group gap="xs">
<Button
variant="light"
size="sm"
leftSection={<IconPlus size={16} />}
onClick={onAddChild}
disabled={isLeaf}
>
{t('location.addSubLocation')}
</Button>
<Menu shadow="md" width={180}>
<Menu.Target>
<ActionIcon variant="subtle" color="gray" size="sm">
<IconDots size={16} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconEdit size={16} />}
onClick={onEdit}
>
{t('location.edit')}
</Menu.Item>
<Menu.Item
leftSection={<IconTrash size={16} />}
color="red"
onClick={onDelete}
>
{t('location.delete')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
<Group gap="xs" mb="md">
<Badge size="lg" variant="light" color="blue">
{location.code}
</Badge>
{typeInfo && (
<Badge size="lg" variant="light" color="teal">
{typeInfo.names.en}
</Badge>
)}
</Group>
<Divider mb="md" />
<Stack gap="sm">
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{t('location.nameEn')}
</Text>
<Text size="sm">{location.names.en}</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{t('location.nameAm')}
</Text>
<Text size="sm">{location.names.am}</Text>
</div>
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{t('location.code')}
</Text>
<Text size="sm">{location.code}</Text>
</div>
{typeInfo && (
<div>
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
{t('location.type')}
</Text>
<Text size="sm">{typeInfo.names.en} (Level {typeInfo.level})</Text>
</div>
)}
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,179 @@
import { useEffect, useMemo } from 'react';
import {
TextInput,
Button,
Group,
Stack,
Paper,
Title,
Text,
Badge,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { useTranslation } from 'react-i18next';
import { notify } from '@ema-platform/ui';
import type { Location, LocationType } from '../types/location';
interface LocationFormValues {
code: string;
namesEn: string;
namesAm: string;
}
interface LocationFormProps {
locationTypes: LocationType[];
parentLocation?: Location | null;
editingLocation?: Location | null;
onSubmit: (values: {
code: string;
names: { en: string; am: string };
locationTypeId: string;
parentId?: string | null;
}) => void;
onCancel: () => void;
isSubmitting?: boolean;
}
export function LocationForm({
locationTypes,
parentLocation,
editingLocation,
onSubmit,
onCancel,
isSubmitting,
}: LocationFormProps) {
const { t } = useTranslation();
const isEditing = !!editingLocation;
const { type, typesAvailable } = useMemo(() => {
if (isEditing) {
const lt = locationTypes.find((t) => t.id === editingLocation.locationTypeId);
return { type: lt ?? null, typesAvailable: false };
}
if (parentLocation) {
const parentLevel =
locationTypes.find((lt) => lt.id === parentLocation.locationTypeId)?.level ?? 0;
const nextLevel = locationTypes
.filter((lt) => lt.level === parentLevel + 1)
.sort((a, b) => a.level - b.level);
return { type: nextLevel[0] ?? null, typesAvailable: nextLevel.length > 1 };
}
const minLevel = Math.min(...locationTypes.map((lt) => lt.level));
const roots = locationTypes
.filter((lt) => lt.level === minLevel)
.sort((a, b) => a.level - b.level);
return { type: roots[0] ?? null, typesAvailable: roots.length > 1 };
}, [locationTypes, parentLocation, editingLocation, isEditing]);
const form = useForm<LocationFormValues>({
initialValues: {
code: '',
namesEn: '',
namesAm: '',
},
validate: {
code: (v) => (!v ? t('location.validation.codeRequired') : null),
namesEn: (v) => (!v ? t('location.validation.nameEnRequired') : null),
namesAm: (v) => (!v ? t('location.validation.nameAmRequired') : null),
},
});
useEffect(() => {
if (editingLocation) {
form.setValues({
code: editingLocation.code,
namesEn: editingLocation.names.en,
namesAm: editingLocation.names.am,
});
}
}, [editingLocation]);
const handleSubmit = form.onSubmit((values) => {
if (!type) {
notify.error(t('location.noTypesAvailable'));
return;
}
onSubmit({
code: values.code,
names: { en: values.namesEn, am: values.namesAm },
locationTypeId: type.id,
parentId: editingLocation ? editingLocation.parentId : parentLocation?.id ?? null,
});
});
const title = isEditing
? t('location.editTitle')
: t('location.addTitle');
return (
<Paper p="lg" radius="md" withBorder>
<Title order={5} mb="md">
{title}
</Title>
{parentLocation && !isEditing && (
<TextInput
label={t('location.parent')}
value={parentLocation.names.en}
disabled
mb="sm"
size="sm"
/>
)}
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<div>
<Text size="xs" c="dimmed" fw={500} mb={4}>
{t('location.type')}
</Text>
{type ? (
<Badge size="lg" variant="light" color="blue">
{type.names.en}
</Badge>
) : (
<Text size="sm" c="red">
{t('location.noTypesAvailable')}
</Text>
)}
</div>
{typesAvailable && (
<Text size="xs" c="dimmed">
{t('location.multipleTypesHint')}
</Text>
)}
<TextInput
label={t('location.code')}
placeholder="e.g., ETH, AA, KK"
{...form.getInputProps('code')}
size="sm"
required
/>
<TextInput
label={t('location.nameEn')}
placeholder="English name"
{...form.getInputProps('namesEn')}
size="sm"
required
/>
<TextInput
label={t('location.nameAm')}
placeholder="የአማርኛ ስም"
{...form.getInputProps('namesAm')}
size="sm"
required
/>
<Group justify="flex-end" mt="md">
<Button variant="default" onClick={onCancel} size="sm">
{t('location.cancel')}
</Button>
<Button type="submit" loading={isSubmitting} size="sm">
{isEditing ? t('location.update') : t('location.create')}
</Button>
</Group>
</Stack>
</form>
</Paper>
);
}

View File

@@ -0,0 +1,239 @@
import { useState, useCallback, useMemo } from 'react';
import {
Text,
Loader,
Group,
Badge,
TextInput,
UnstyledButton,
Collapse,
Stack,
Box,
rem,
Center,
} from '@mantine/core';
import {
IconChevronRight,
IconChevronDown,
IconSearch,
IconMapPin,
} from '@tabler/icons-react';
import { useGetLocationsQuery } from '../api/location-api';
import type { Location } from '../types/location';
import { useTranslation } from 'react-i18next';
interface LocationTreeProps {
selectedId: string | null;
onSelect: (location: Location) => void;
}
function flatListToTree(
items: Location[],
parentId: string | null,
): Location[] {
return items
.filter((item) => item.parentId === parentId)
.map((item) => ({
...item,
children: flatListToTree(items, item.id),
}));
}
function TreeNode({
location,
selectedId,
onSelect,
depth,
}: {
location: Location;
selectedId: string | null;
onSelect: (location: Location) => void;
depth: number;
}) {
const [opened, setOpened] = useState(depth < 1);
const isSelected = selectedId === location.id;
const hasChildren =
Array.isArray(location.children) && location.children.length > 0;
const toggle = useCallback(() => {
setOpened((prev) => !prev);
}, []);
const select = useCallback(() => {
onSelect(location);
}, [onSelect, location]);
return (
<Box>
<UnstyledButton
onClick={select}
style={{
display: 'flex',
alignItems: 'center',
gap: rem(6),
width: '100%',
padding: `${rem(6)} ${rem(8)}`,
paddingLeft: rem(12 + depth * 20),
borderRadius: rem(8),
backgroundColor: isSelected
? 'var(--mantine-color-blue-light)'
: 'transparent',
color: isSelected
? 'var(--mantine-color-blue-filled)'
: 'var(--mantine-color-text)',
cursor: 'pointer',
border: 'none',
fontSize: rem(14),
transition: 'background-color 100ms ease',
}}
onMouseEnter={(e) => {
if (!isSelected)
e.currentTarget.style.backgroundColor =
'var(--mantine-color-gray-0)';
}}
onMouseLeave={(e) => {
if (!isSelected)
e.currentTarget.style.backgroundColor = 'transparent';
}}
>
{hasChildren && (
<Box
component="button"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
toggle();
}}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: rem(18),
height: rem(18),
border: 'none',
background: 'none',
cursor: 'pointer',
padding: 0,
flexShrink: 0,
}}
>
{opened ? (
<IconChevronDown size={14} stroke={1.5} />
) : (
<IconChevronRight size={14} stroke={1.5} />
)}
</Box>
)}
{!hasChildren && <Box w={rem(18)} flexShrink={0} />}
<IconMapPin
size={14}
stroke={1.5}
style={{ flexShrink: 0, opacity: 0.6 }}
/>
<Text size="sm" truncate style={{ flex: 1 }}>
{location.names.en}
</Text>
</UnstyledButton>
{hasChildren && (
<Collapse in={opened}>
<Stack gap={2} mt={2}>
{location.children!.map((child) => (
<TreeNode
key={child.id}
location={child}
selectedId={selectedId}
onSelect={onSelect}
depth={depth + 1}
/>
))}
</Stack>
</Collapse>
)}
</Box>
);
}
export function LocationTree({
selectedId,
onSelect,
}: LocationTreeProps) {
const { t } = useTranslation();
const [search, setSearch] = useState('');
const { data: allLocationsRes, isLoading } = useGetLocationsQuery({ take: 10000 });
const tree = useMemo(() => {
if (!allLocationsRes?.items) return [];
const roots = flatListToTree(allLocationsRes.items, null);
return roots;
}, [allLocationsRes]);
const filteredTree = useMemo(() => {
if (!search) return tree;
const matches = (loc: Location): boolean => {
const nameMatch = loc.names.en
.toLowerCase()
.includes(search.toLowerCase());
const childMatch =
Array.isArray(loc.children) &&
loc.children.some(matches);
return nameMatch || childMatch;
};
const filterNodes = (nodes: Location[]): Location[] =>
nodes
.filter(matches)
.map((node) => ({
...node,
children: Array.isArray(node.children)
? filterNodes(node.children)
: [],
}));
return filterNodes(tree);
}, [tree, search]);
const handleSelect = useCallback(
(loc: Location) => {
onSelect(loc);
},
[onSelect],
);
if (isLoading) {
return (
<Center py="xl">
<Loader />
</Center>
);
}
return (
<>
<TextInput
placeholder={t('location.search')}
leftSection={<IconSearch size={16} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
mb="sm"
size="sm"
/>
{tree.length === 0 && (
<Text c="dimmed" size="sm" ta="center" py="xl">
{t('location.noLocations')}
</Text>
)}
<Stack gap={2}>
{filteredTree.map((root) => (
<TreeNode
key={root.id}
location={root}
selectedId={selectedId}
onSelect={handleSelect}
depth={0}
/>
))}
</Stack>
</>
);
}

View File

@@ -0,0 +1,231 @@
import { useState, useEffect } from 'react';
import {
Modal,
TextInput,
Button,
Group,
Stack,
NumberInput,
Table,
ActionIcon,
Badge,
Text,
Tooltip,
Divider,
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { IconEdit, IconTrash, IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
useGetLocationTypesQuery,
useCreateLocationTypeMutation,
useUpdateLocationTypeMutation,
useDeleteLocationTypeMutation,
} from '../api/location-api';
import { notify } from '@ema-platform/ui';
interface LocationTypeFormValues {
code: string;
namesEn: string;
namesAm: string;
level: number;
}
export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
const { t } = useTranslation();
const { data: locationTypes, isLoading } = useGetLocationTypesQuery();
const [createType] = useCreateLocationTypeMutation();
const [updateType] = useUpdateLocationTypeMutation();
const [deleteType] = useDeleteLocationTypeMutation();
const [editingId, setEditingId] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const form = useForm<LocationTypeFormValues>({
initialValues: {
code: '',
namesEn: '',
namesAm: '',
level: 1,
},
validate: {
code: (v) => (!v ? 'Code is required' : null),
namesEn: (v) => (!v ? 'English name is required' : null),
namesAm: (v) => (!v ? 'Amharic name is required' : null),
level: (v) => (v < 1 ? 'Level must be at least 1' : null),
},
});
useEffect(() => {
if (!opened) {
resetForm();
}
}, [opened]);
const resetForm = () => {
form.reset();
setEditingId(null);
setShowForm(false);
};
const handleEdit = (type: { id: string; code: string; names: { en: string; am: string }; level: number }) => {
setEditingId(type.id);
form.setValues({
code: type.code,
namesEn: type.names.en,
namesAm: type.names.am,
level: type.level,
});
setShowForm(true);
};
const handleDelete = async (id: string) => {
try {
await deleteType(id).unwrap();
notify.success(t('location.typeDeleted'));
} catch {
notify.error(t('location.deleteError'));
}
};
const handleSubmit = form.onSubmit(async (values) => {
try {
if (editingId) {
await updateType({ id: editingId, ...values, names: { en: values.namesEn, am: values.namesAm } }).unwrap();
notify.success(t('location.typeUpdated'));
} else {
await createType({ code: values.code, names: { en: values.namesEn, am: values.namesAm }, level: values.level }).unwrap();
notify.success(t('location.typeCreated'));
}
resetForm();
} catch {
notify.error(t('location.typeError'));
}
});
const sortedTypes = locationTypes?.items
? [...locationTypes.items].sort((a, b) => a.level - b.level)
: [];
return (
<Modal
opened={opened}
onClose={onClose}
title={t('location.manageTypes')}
size="lg"
>
{!showForm && (
<Button
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => setShowForm(true)}
mb="md"
size="sm"
>
{t('location.addType')}
</Button>
)}
{showForm && (
<form onSubmit={handleSubmit}>
<Stack gap="sm" mb="md">
<TextInput
label="Code"
placeholder="e.g., COUNTRY, REGION, CITY"
{...form.getInputProps('code')}
size="sm"
/>
<TextInput
label="Name (English)"
placeholder="English name"
{...form.getInputProps('namesEn')}
size="sm"
/>
<TextInput
label="Name (Amharic)"
placeholder="የአማርኛ ስም"
{...form.getInputProps('namesAm')}
size="sm"
/>
<NumberInput
label="Level"
placeholder="1"
min={1}
max={10}
{...form.getInputProps('level')}
size="sm"
/>
<Group justify="flex-end">
<Button variant="default" onClick={resetForm} size="sm">
{t('location.cancel')}
</Button>
<Button type="submit" size="sm">
{editingId ? t('location.update') : t('location.create')}
</Button>
</Group>
</Stack>
</form>
)}
<Divider mb="md" />
{isLoading && <Text c="dimmed">Loading...</Text>}
{!isLoading && sortedTypes.length === 0 && (
<Text c="dimmed" ta="center" py="xl">
{t('location.noTypes')}
</Text>
)}
{sortedTypes.length > 0 && (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Level</Table.Th>
<Table.Th>Code</Table.Th>
<Table.Th>Name (EN)</Table.Th>
<Table.Th>Name (AM)</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{sortedTypes.map((type) => (
<Table.Tr key={type.id}>
<Table.Td>
<Badge size="sm" variant="light" color="gray">
{type.level}
</Badge>
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light" color="blue">
{type.code}
</Badge>
</Table.Td>
<Table.Td>{type.names.en}</Table.Td>
<Table.Td>{type.names.am}</Table.Td>
<Table.Td>
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
size="sm"
onClick={() => handleEdit(type)}
>
<IconEdit size={14} />
</ActionIcon>
<ActionIcon
variant="subtle"
color="red"
size="sm"
onClick={() => handleDelete(type.id)}
>
<IconTrash size={14} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
</Modal>
);
}

View File

@@ -0,0 +1,240 @@
import { useState, useCallback } from 'react';
import {
Stack,
Title,
Group,
Button,
Paper,
Text,
Grid,
Modal,
ActionIcon,
Tooltip,
Loader,
Center,
Alert,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { notify } from '@ema-platform/ui';
import { LocationTree } from '../components/LocationTree';
import { LocationDetail } from '../components/LocationDetail';
import { LocationForm } from '../components/LocationForm';
import { LocationTypeModal } from '../components/LocationTypeModal';
import {
useGetLocationTypesQuery,
useCreateLocationMutation,
useUpdateLocationMutation,
useDeleteLocationMutation,
} from '../api/location-api';
import type { Location } from '../types/location';
export function LocationPage() {
const { t } = useTranslation();
const { data: locationTypesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
const [createLocation, { isLoading: isCreating }] = useCreateLocationMutation();
const [updateLocation, { isLoading: isUpdating }] = useUpdateLocationMutation();
const [deleteLocation] = useDeleteLocationMutation();
const locationTypes = locationTypesRes?.items ?? [];
const [selectedLocation, setSelectedLocation] = useState<Location | null>(null);
const [typeModalOpened, { open: openTypeModal, close: closeTypeModal }] = useDisclosure(false);
const [formModalOpened, { open: openFormModal, close: closeFormModal }] = useDisclosure(false);
const [deleteModalOpened, { open: openDeleteModal, close: closeDeleteModal }] = useDisclosure(false);
const [editingLocation, setEditingLocation] = useState<Location | null>(null);
const [parentLocation, setParentLocation] = useState<Location | null>(null);
const handleSelect = useCallback((loc: Location) => {
setSelectedLocation(loc);
}, []);
const handleAddChild = useCallback(() => {
setEditingLocation(null);
setParentLocation(selectedLocation);
openFormModal();
}, [selectedLocation, openFormModal]);
const handleEdit = useCallback(() => {
if (!selectedLocation) return;
setEditingLocation(selectedLocation);
setParentLocation(null);
openFormModal();
}, [selectedLocation, openFormModal]);
const handleFormSubmit = useCallback(
async (values: {
code: string;
names: { en: string; am: string };
locationTypeId: string;
parentId?: string | null;
}) => {
try {
if (editingLocation) {
await updateLocation({ id: editingLocation.id, ...values }).unwrap();
notify.success(t('location.updated'));
} else {
await createLocation(values).unwrap();
notify.success(t('location.created'));
}
closeFormModal();
setEditingLocation(null);
setParentLocation(null);
} catch {
notify.error(t('location.error'));
}
},
[editingLocation, createLocation, updateLocation, closeFormModal, t],
);
const handleDeleteConfirm = useCallback(async () => {
if (!selectedLocation) return;
try {
await deleteLocation(selectedLocation.id).unwrap();
notify.success(t('location.deleted'));
setSelectedLocation(null);
closeDeleteModal();
} catch {
notify.error(t('location.deleteError'));
}
}, [selectedLocation, deleteLocation, closeDeleteModal, t]);
if (typesLoading) {
return (
<Center py="xl">
<Loader />
</Center>
);
}
return (
<Stack gap="lg">
<Group justify="space-between">
<Title order={2}>{t('location.title')}</Title>
<Group gap="sm">
{locationTypes.length > 0 && (
<Button
variant="light"
leftSection={<IconPlus size={16} />}
onClick={() => {
setEditingLocation(null);
setParentLocation(null);
openFormModal();
}}
size="sm"
>
{t('location.addRoot')}
</Button>
)}
<Tooltip label={t('location.manageTypes')}>
<ActionIcon
variant="subtle"
color="gray"
size="lg"
onClick={openTypeModal}
>
<IconSettings size={20} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
{locationTypes.length === 0 && (
<Alert
icon={<IconInfoCircle size={16} />}
title={t('location.setupRequired')}
color="blue"
>
<Text size="sm" mb="sm">
{t('location.setupHint')}
</Text>
<Button variant="light" size="sm" onClick={openTypeModal}>
{t('location.configureTypes')}
</Button>
</Alert>
)}
{locationTypes.length > 0 && (
<Grid gutter="md">
<Grid.Col span={{ base: 12, md: 5 }}>
<Paper p="md" radius="md" withBorder style={{ maxHeight: 'calc(100vh - 260px)', overflow: 'auto' }}>
<Text fw={600} size="sm" mb="sm">
{t('location.hierarchy')}
</Text>
<LocationTree
selectedId={selectedLocation?.id ?? null}
onSelect={handleSelect}
/>
</Paper>
</Grid.Col>
<Grid.Col span={{ base: 12, md: 7 }}>
{selectedLocation ? (
<LocationDetail
location={selectedLocation}
locationTypes={locationTypes}
onAddChild={handleAddChild}
onEdit={handleEdit}
onDelete={openDeleteModal}
/>
) : (
<Paper p="xl" radius="md" withBorder>
<Center py="xl">
<Stack align="center" gap="sm">
<IconMap size={48} stroke={1} color="var(--mantine-color-gray-4)" />
<Text c="dimmed" size="sm">
{t('location.selectHint')}
</Text>
</Stack>
</Center>
</Paper>
)}
</Grid.Col>
</Grid>
)}
<Modal
opened={formModalOpened}
onClose={closeFormModal}
title={editingLocation ? t('location.editTitle') : t('location.addTitle')}
size="md"
>
<LocationForm
locationTypes={locationTypes}
parentLocation={parentLocation}
editingLocation={editingLocation}
onSubmit={handleFormSubmit}
onCancel={() => {
closeFormModal();
setEditingLocation(null);
setParentLocation(null);
}}
isSubmitting={isCreating || isUpdating}
/>
</Modal>
<Modal
opened={deleteModalOpened}
onClose={closeDeleteModal}
title={t('location.confirmDelete')}
size="sm"
>
<Text mb="md">
{t('location.deleteConfirmText', {
name: selectedLocation?.names.en ?? '',
})}
</Text>
<Group justify="flex-end">
<Button variant="default" onClick={closeDeleteModal} size="sm">
{t('location.cancel')}
</Button>
<Button color="red" onClick={handleDeleteConfirm} size="sm">
{t('location.delete')}
</Button>
</Group>
</Modal>
<LocationTypeModal opened={typeModalOpened} onClose={closeTypeModal} />
</Stack>
);
}

View File

@@ -0,0 +1,51 @@
export interface NamePair {
en: string;
am: string;
}
export interface LocationType {
id: string;
code: string;
names: NamePair;
level: number;
createdAt: string;
updatedAt: string;
}
export interface Location {
id: string;
code: string;
names: NamePair;
locationTypeId: string;
parentId: string | null;
locationType?: LocationType;
children?: Location[];
createdAt: string;
updatedAt: string;
}
export interface ListResponse<T> {
count: number;
items: T[];
}
export interface CreateLocationTypePayload {
code: string;
names: NamePair;
level: number;
}
export interface UpdateLocationTypePayload extends CreateLocationTypePayload {
id: string;
}
export interface CreateLocationPayload {
code: string;
names: NamePair;
locationTypeId: string;
parentId?: string | null;
}
export interface UpdateLocationPayload extends CreateLocationPayload {
id: string;
}

View File

@@ -86,6 +86,54 @@ export const am: Translations = {
},
},
location: {
title: 'አካባቢዎች',
hierarchy: 'የአካባቢ ተዋረድ',
search: 'አካባቢዎችን ይፈልጉ...',
noLocations: 'ምንም አካባቢዎች አልተገኙም። ለመጀመር የስር አካባቢ ያክሉ።',
selectHint: 'ዝርዝሮችን ለማየት ከዛፉ ውስጥ አካባቢ ይምረጡ',
addRoot: 'የስር አካባቢ ያክሉ',
addSubLocation: 'ንዑስ አካባቢ ያክሉ',
addTitle: 'አካባቢ ያክሉ',
editTitle: 'አካባቢ ያስተካክሉ',
code: 'ኮድ',
nameEn: 'ስም (እንግሊዝኛ)',
nameAm: 'ስም (አማርኛ)',
type: 'አይነት',
parent: 'ወላጅ',
selectType: 'አይነት ይምረጡ',
cancel: 'ሰርዝ',
create: 'ፍጠር',
update: 'አዘምን',
edit: 'አስተካክል',
delete: 'ሰርዝ',
created: 'አካባቢ በተሳካ ሁኔታ ተፈጥሯል',
updated: 'አካባቢ በተሳካ ሁኔታ ዘምኗል',
deleted: 'አካባቢ በተሳካ ሁኔታ ተሰርዟል',
error: 'አንድ ስህተት ተፈጥሯል',
deleteError: 'አካባቢውን መሰረዝ አልተቻለም',
confirmDelete: 'መሰረዝን ያረጋግጡ',
deleteConfirmText: 'እርግጠኛ ነዎት {{name}}ን መሰረዝ ይፈልጋሉ?',
manageTypes: 'የአካባቢ አይነቶችን ያስተዳድሩ',
addType: 'የአካባቢ አይነት ያክሉ',
noTypes: 'ገና ምንም የአካባቢ አይነቶች አልተገለጹም',
typeCreated: 'የአካባቢ አይነት ተፈጥሯል',
typeUpdated: 'የአካባቢ አይነት ዘምኗል',
typeDeleted: 'የአካባቢ አይነት ተሰርዟል',
typeError: 'የአካባቢ አይነቱን ማስቀመጥ አልተቻለም',
setupRequired: 'ማዋቀር ያስፈልጋል',
setupHint: 'አካባቢዎችን ከመጨመርዎ በፊት የአካባቢ አይነቶችን ማዋቀር ያስፈልግዎታል።',
configureTypes: 'የአካባቢ አይነቶችን ያዋቅሩ',
noTypesAvailable: 'ለዚህ ደረጃ ምንም የአካባቢ አይነቶች የሉም',
multipleTypesHint: 'በርካታ አይነቶች ተገኝተዋል — አይነቱ ከዝቅተኛው ደረጃ በራስ-ሰር ተመርጧል።',
validation: {
codeRequired: 'ኮድ ያስፈልጋል',
nameEnRequired: 'የእንግሊዝኛ ስም ያስፈልጋል',
nameAmRequired: 'የአማርኛ ስም ያስፈልጋል',
typeRequired: 'የአካባቢ አይነት ያስፈልጋል',
},
},
profile: {
title: 'የእኔ መገለጫ',
subtitle: 'የመለያ ዝርዝሮችዎን እና ምርጫዎችዎን ያስተዳድሩ።',

View File

@@ -84,6 +84,54 @@ export const en = {
},
},
location: {
title: 'Locations',
hierarchy: 'Location Hierarchy',
search: 'Search locations...',
noLocations: 'No locations found. Add a root location to get started.',
selectHint: 'Select a location from the tree to view details',
addRoot: 'Add Root Location',
addSubLocation: 'Add Sub-Location',
addTitle: 'Add Location',
editTitle: 'Edit Location',
code: 'Code',
nameEn: 'Name (English)',
nameAm: 'Name (Amharic)',
type: 'Type',
parent: 'Parent',
selectType: 'Select type',
cancel: 'Cancel',
create: 'Create',
update: 'Update',
edit: 'Edit',
delete: 'Delete',
created: 'Location created successfully',
updated: 'Location updated successfully',
deleted: 'Location deleted successfully',
error: 'Something went wrong',
deleteError: 'Failed to delete location',
confirmDelete: 'Confirm Delete',
deleteConfirmText: 'Are you sure you want to delete {{name}}?',
manageTypes: 'Manage Location Types',
addType: 'Add Location Type',
noTypes: 'No location types defined yet',
typeCreated: 'Location type created',
typeUpdated: 'Location type updated',
typeDeleted: 'Location type deleted',
typeError: 'Failed to save location type',
setupRequired: 'Setup Required',
setupHint: 'You need to configure location types (e.g., Country, Region, City) before adding locations.',
configureTypes: 'Configure Location Types',
noTypesAvailable: 'No location types available for this level',
multipleTypesHint: 'Multiple types found — type is auto-selected from the lowest available level.',
validation: {
codeRequired: 'Code is required',
nameEnRequired: 'English name is required',
nameAmRequired: 'Amharic name is required',
typeRequired: 'Location type is required',
},
},
profile: {
title: 'My Profile',
subtitle: 'Manage your account details and preferences.',

View File

@@ -11,6 +11,7 @@ import {
IconLayoutDashboard,
IconUsers,
IconUser,
IconMap,
} from '@tabler/icons-react';
import { notify } from '@ema-platform/ui';
import { SUPPORTED_LANGUAGES } from '../i18n/config';
@@ -18,6 +19,7 @@ import { SUPPORTED_LANGUAGES } from '../i18n/config';
const NAV_ITEMS: NavItem[] = [
{ to: '/dashboard', label: 'Dashboard', icon: IconLayoutDashboard },
{ to: '/um/user-management/dashboard', label: 'User Management', icon: IconUsers },
{ to: '/locations', label: 'Locations', icon: IconMap },
{ to: '/profile', label: 'Profile', icon: IconUser },
];

View File

@@ -14,6 +14,7 @@ import { ProtectedRoute } from './ProtectedRoute';
import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
import UserManagementHostPage from '../features/user-management-host/UserManagementHostPage';
import { ProfilePage } from '../features/profile/pages/ProfilePage';
import { LocationPage } from '../features/location/pages/LocationPage';
const router = createBrowserRouter([
{
@@ -34,6 +35,7 @@ const router = createBrowserRouter([
{ index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> },
{ path: 'profile', element: <ProfilePage /> },
{ path: 'locations', element: <LocationPage /> },
],
},
],