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,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>
);
}