diff --git a/apps/backoffice/src/app/features/location/api/location-api.ts b/apps/backoffice/src/app/features/location/api/location-api.ts new file mode 100644 index 000000000..46c2af92d --- /dev/null +++ b/apps/backoffice/src/app/features/location/api/location-api.ts @@ -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, void>({ + query: () => '/location-types', + providesTags: ['Api'], + }), + createLocationType: builder.mutation({ + query: (body) => ({ url: '/location-types', method: 'POST', body }), + invalidatesTags: ['Api'], + }), + updateLocationType: builder.mutation({ + query: ({ id, ...body }) => ({ + url: `/location-types/${id}`, + method: 'PUT', + body, + }), + invalidatesTags: ['Api'], + }), + deleteLocationType: builder.mutation({ + query: (id) => ({ url: `/location-types/${id}`, method: 'DELETE' }), + invalidatesTags: ['Api'], + }), + + getLocations: builder.query, { parentId?: string; locationTypeId?: string; take?: number; skip?: number }>({ + query: (params) => ({ url: '/locations', params }), + providesTags: ['Api'], + }), + createLocation: builder.mutation({ + query: (body) => ({ url: '/locations', method: 'POST', body }), + invalidatesTags: ['Api'], + }), + updateLocation: builder.mutation({ + query: ({ id, ...body }) => ({ + url: `/locations/${id}`, + method: 'PUT', + body, + }), + invalidatesTags: ['Api'], + }), + deleteLocation: builder.mutation({ + query: (id) => ({ url: `/locations/${id}`, method: 'DELETE' }), + invalidatesTags: ['Api'], + }), + }), + overrideExisting: false, +}); + +export const { + useGetLocationTypesQuery, + useCreateLocationTypeMutation, + useUpdateLocationTypeMutation, + useDeleteLocationTypeMutation, + useGetLocationsQuery, + useCreateLocationMutation, + useUpdateLocationMutation, + useDeleteLocationMutation, +} = locationApi; diff --git a/apps/backoffice/src/app/features/location/components/LocationDetail.tsx b/apps/backoffice/src/app/features/location/components/LocationDetail.tsx new file mode 100644 index 000000000..f91292497 --- /dev/null +++ b/apps/backoffice/src/app/features/location/components/LocationDetail.tsx @@ -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 ( + + + {location.names.en} + + + + + + + + + + } + onClick={onEdit} + > + {t('location.edit')} + + } + color="red" + onClick={onDelete} + > + {t('location.delete')} + + + + + + + + + {location.code} + + {typeInfo && ( + + {typeInfo.names.en} + + )} + + + + + +
+ + {t('location.nameEn')} + + {location.names.en} +
+
+ + {t('location.nameAm')} + + {location.names.am} +
+
+ + {t('location.code')} + + {location.code} +
+ {typeInfo && ( +
+ + {t('location.type')} + + {typeInfo.names.en} (Level {typeInfo.level}) +
+ )} +
+
+ ); +} diff --git a/apps/backoffice/src/app/features/location/components/LocationForm.tsx b/apps/backoffice/src/app/features/location/components/LocationForm.tsx new file mode 100644 index 000000000..86ae22844 --- /dev/null +++ b/apps/backoffice/src/app/features/location/components/LocationForm.tsx @@ -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({ + 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 ( + + + {title} + + {parentLocation && !isEditing && ( + + )} +
+ +
+ + {t('location.type')} + + {type ? ( + + {type.names.en} + + ) : ( + + {t('location.noTypesAvailable')} + + )} +
+ {typesAvailable && ( + + {t('location.multipleTypesHint')} + + )} + + + + + + + +
+
+
+ ); +} diff --git a/apps/backoffice/src/app/features/location/components/LocationTree.tsx b/apps/backoffice/src/app/features/location/components/LocationTree.tsx new file mode 100644 index 000000000..e463b0f94 --- /dev/null +++ b/apps/backoffice/src/app/features/location/components/LocationTree.tsx @@ -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 ( + + { + if (!isSelected) + e.currentTarget.style.backgroundColor = + 'var(--mantine-color-gray-0)'; + }} + onMouseLeave={(e) => { + if (!isSelected) + e.currentTarget.style.backgroundColor = 'transparent'; + }} + > + {hasChildren && ( + { + 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 ? ( + + ) : ( + + )} + + )} + {!hasChildren && } + + + {location.names.en} + + + {hasChildren && ( + + + {location.children!.map((child) => ( + + ))} + + + )} + + ); +} + +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 ( +
+ +
+ ); + } + + return ( + <> + } + value={search} + onChange={(e) => setSearch(e.currentTarget.value)} + mb="sm" + size="sm" + /> + {tree.length === 0 && ( + + {t('location.noLocations')} + + )} + + {filteredTree.map((root) => ( + + ))} + + + ); +} diff --git a/apps/backoffice/src/app/features/location/components/LocationTypeModal.tsx b/apps/backoffice/src/app/features/location/components/LocationTypeModal.tsx new file mode 100644 index 000000000..4f040d4e6 --- /dev/null +++ b/apps/backoffice/src/app/features/location/components/LocationTypeModal.tsx @@ -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(null); + const [showForm, setShowForm] = useState(false); + + const form = useForm({ + 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 ( + + {!showForm && ( + + )} + + {showForm && ( +
+ + + + + + + + + + +
+ )} + + + + {isLoading && Loading...} + {!isLoading && sortedTypes.length === 0 && ( + + {t('location.noTypes')} + + )} + {sortedTypes.length > 0 && ( + + + + Level + Code + Name (EN) + Name (AM) + + + + + {sortedTypes.map((type) => ( + + + + {type.level} + + + + + {type.code} + + + {type.names.en} + {type.names.am} + + + handleEdit(type)} + > + + + handleDelete(type.id)} + > + + + + + + ))} + +
+ )} +
+ ); +} diff --git a/apps/backoffice/src/app/features/location/pages/LocationPage.tsx b/apps/backoffice/src/app/features/location/pages/LocationPage.tsx new file mode 100644 index 000000000..44c2e6736 --- /dev/null +++ b/apps/backoffice/src/app/features/location/pages/LocationPage.tsx @@ -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(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(null); + const [parentLocation, setParentLocation] = useState(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 ( +
+ +
+ ); + } + + return ( + + + {t('location.title')} + + {locationTypes.length > 0 && ( + + )} + + + + + + + + + {locationTypes.length === 0 && ( + } + title={t('location.setupRequired')} + color="blue" + > + + {t('location.setupHint')} + + + + )} + + {locationTypes.length > 0 && ( + + + + + {t('location.hierarchy')} + + + + + + {selectedLocation ? ( + + ) : ( + +
+ + + + {t('location.selectHint')} + + +
+
+ )} +
+
+ )} + + + { + closeFormModal(); + setEditingLocation(null); + setParentLocation(null); + }} + isSubmitting={isCreating || isUpdating} + /> + + + + + {t('location.deleteConfirmText', { + name: selectedLocation?.names.en ?? '', + })} + + + + + + + + +
+ ); +} diff --git a/apps/backoffice/src/app/features/location/types/location.ts b/apps/backoffice/src/app/features/location/types/location.ts new file mode 100644 index 000000000..e0e81bdb9 --- /dev/null +++ b/apps/backoffice/src/app/features/location/types/location.ts @@ -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 { + 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; +} diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 3b3b26867..d5ef15fb8 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -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: 'የመለያ ዝርዝሮችዎን እና ምርጫዎችዎን ያስተዳድሩ።', diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 8979d7a1a..092b40266 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -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.', diff --git a/apps/backoffice/src/app/layouts/BackofficeLayout.tsx b/apps/backoffice/src/app/layouts/BackofficeLayout.tsx index d8bf881e8..5ed3329c0 100644 --- a/apps/backoffice/src/app/layouts/BackofficeLayout.tsx +++ b/apps/backoffice/src/app/layouts/BackofficeLayout.tsx @@ -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 }, ]; diff --git a/apps/backoffice/src/app/router/index.tsx b/apps/backoffice/src/app/router/index.tsx index 91e344da6..a132b1f64 100644 --- a/apps/backoffice/src/app/router/index.tsx +++ b/apps/backoffice/src/app/router/index.tsx @@ -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: }, { path: 'dashboard', element: }, { path: 'profile', element: }, + { path: 'locations', element: }, ], }, ],