mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
added location registeration
This commit is contained in:
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
240
apps/backoffice/src/app/features/location/pages/LocationPage.tsx
Normal file
240
apps/backoffice/src/app/features/location/pages/LocationPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
apps/backoffice/src/app/features/location/types/location.ts
Normal file
51
apps/backoffice/src/app/features/location/types/location.ts
Normal 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;
|
||||
}
|
||||
@@ -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: 'የመለያ ዝርዝሮችዎን እና ምርጫዎችዎን ያስተዳድሩ።',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user