Files
emaui/apps/backoffice/src/app/features/location/components/LocationTree.tsx
estifanos 0fa5566432 UI fixes
2026-08-19 10:22:00 +00:00

243 lines
6.1 KiB
TypeScript

import { useState, useCallback, useMemo } from 'react';
import {
Text,
Loader,
Group,
Badge,
TextInput,
UnstyledButton,
Collapse,
Stack,
Box,
rem,
Center,
useComputedColorScheme,
} 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';
import { useLocalized } from '@ema-platform/api';
import { PageLoader } from '@ema-platform/ui';
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 localized = useLocalized();
const [opened, setOpened] = useState(depth < 1);
const isSelected = selectedId === location.id;
const hasChildren =
Array.isArray(location.children) && location.children.length > 0;
const colorScheme = useComputedColorScheme('light', { getInitialValueInEffect: true });
const hoverBg = colorScheme === 'dark'
? 'var(--mantine-color-dark-6)'
: 'var(--mantine-color-gray-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 = hoverBg;
}}
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)} style={{ flexShrink: 0 }} />}
<IconMapPin
size={14}
stroke={1.5}
style={{ flexShrink: 0, opacity: 0.6 }}
/>
<Text size="sm" truncate style={{ flex: 1 }}>
{localized(location.names)}
</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 <PageLoader label="Loading Locations…" height={300} />;
}
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>
</>
);
}