mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
243 lines
6.1 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|