194 lines
7.8 KiB
TypeScript
194 lines
7.8 KiB
TypeScript
import { useState } from 'react';
|
||
import { Card, CardContent } from '@/components/ui/card';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Car, ChevronDown, ChevronRight, Search, User } from 'lucide-react';
|
||
import { cn } from '@/lib/utils';
|
||
import type { GpsAsset, GpsGroup } from '@/pages/gps/types';
|
||
|
||
/**
|
||
* Левая панель-дерево объектов GPS-модуля (общая для табов «Карта» и «Маршруты»).
|
||
* Группы — раскрывающиеся узлы с объектами внутри; объекты без группы — внизу
|
||
* панели. Поиск фильтрует объекты по имени (группы с совпадениями автораскрыты).
|
||
*/
|
||
|
||
interface AssetTreePanelProps {
|
||
assets: GpsAsset[];
|
||
groups: GpsGroup[];
|
||
/** Фильтр по группе (null — все). Без onSelectGroup выбор группы недоступен */
|
||
selectedGroupId?: number | null;
|
||
/** Выбранный объект (подсветка строки) */
|
||
selectedAssetId?: number | null;
|
||
onSelectGroup?: (groupId: number | null) => void;
|
||
onSelectAsset?: (asset: GpsAsset) => void;
|
||
showSearch?: boolean;
|
||
title?: string;
|
||
className?: string;
|
||
/** Дополнительный контент под деревом (например, контролы периода в «Маршрутах») */
|
||
children?: React.ReactNode;
|
||
}
|
||
|
||
export function AssetTreePanel({
|
||
assets,
|
||
groups,
|
||
selectedGroupId = null,
|
||
selectedAssetId = null,
|
||
onSelectGroup,
|
||
onSelectAsset,
|
||
showSearch = true,
|
||
title = 'Объекты',
|
||
className,
|
||
children,
|
||
}: AssetTreePanelProps) {
|
||
const [search, setSearch] = useState('');
|
||
const [collapsed, setCollapsed] = useState<Set<number>>(new Set());
|
||
const query = search.trim().toLowerCase();
|
||
|
||
const matches = (a: GpsAsset) => !query || a.name.toLowerCase().includes(query);
|
||
const assetsById = new Map(assets.map((a) => [a.id, a]));
|
||
|
||
// При выбранной группе дерево показывает только её (сброс — кнопка «Все»)
|
||
const visibleGroups = selectedGroupId ? groups.filter((g) => g.id === selectedGroupId) : groups;
|
||
|
||
const groupedAssetIds = new Set(groups.flatMap((g) => g.assetIds));
|
||
const ungroupedAssets = selectedGroupId ? [] : assets.filter((a) => !groupedAssetIds.has(a.id) && matches(a));
|
||
|
||
const isExpanded = (groupId: number) => (query ? true : !collapsed.has(groupId));
|
||
|
||
const toggleGroup = (groupId: number) => {
|
||
setCollapsed((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(groupId)) next.delete(groupId);
|
||
else next.add(groupId);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const renderAsset = (asset: GpsAsset) => {
|
||
const TypeIcon = asset.type === 'person' ? User : Car;
|
||
return (
|
||
<button
|
||
key={asset.id}
|
||
type="button"
|
||
onClick={() => onSelectAsset?.(asset)}
|
||
className={cn(
|
||
'w-full flex items-center gap-1.5 px-2 py-1 rounded text-xs text-left transition-colors',
|
||
selectedAssetId === asset.id
|
||
? 'bg-muted text-foreground font-medium'
|
||
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
|
||
)}
|
||
>
|
||
<span
|
||
className="w-2.5 h-2.5 rounded-full shrink-0 border border-border"
|
||
style={{ backgroundColor: asset.color || '#3b82f6' }}
|
||
/>
|
||
<TypeIcon className="w-3.5 h-3.5 shrink-0" />
|
||
<span className="truncate flex-1">{asset.name}</span>
|
||
<span
|
||
className="w-1.5 h-1.5 rounded-full shrink-0"
|
||
style={{ backgroundColor: asset.isOnline ? '#22c55e' : '#9ca3af' }}
|
||
title={asset.isOnline ? 'Онлайн' : 'Офлайн'}
|
||
/>
|
||
</button>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<Card className={className}>
|
||
<CardContent className="p-3 space-y-2">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<h3 className="text-sm font-semibold">{title}</h3>
|
||
{onSelectGroup && (
|
||
<Button
|
||
size="sm"
|
||
variant={selectedGroupId === null ? 'default' : 'outline'}
|
||
className="h-6 text-[11px] px-2"
|
||
onClick={() => onSelectGroup(null)}
|
||
>
|
||
Все
|
||
</Button>
|
||
)}
|
||
</div>
|
||
|
||
{showSearch && (
|
||
<div className="relative">
|
||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||
<Input
|
||
className="h-8 pl-7 text-xs"
|
||
placeholder="Поиск объекта…"
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{assets.length === 0 ? (
|
||
<p className="text-xs text-muted-foreground py-4 text-center">
|
||
Объектов нет. Добавьте их на вкладке «Объекты».
|
||
</p>
|
||
) : (
|
||
<div className="space-y-1 max-h-[50vh] overflow-y-auto">
|
||
{visibleGroups.map((group) => {
|
||
const groupAssets = group.assetIds
|
||
.map((id) => assetsById.get(id))
|
||
.filter((a): a is GpsAsset => !!a && matches(a));
|
||
if (groupAssets.length === 0 && query) return null;
|
||
const expanded = isExpanded(group.id);
|
||
return (
|
||
<div key={group.id}>
|
||
<div
|
||
className={cn(
|
||
'flex items-center rounded transition-colors',
|
||
selectedGroupId === group.id ? 'bg-muted' : 'hover:bg-muted/50'
|
||
)}
|
||
>
|
||
<button
|
||
type="button"
|
||
className="p-1.5 shrink-0 text-muted-foreground"
|
||
onClick={() => toggleGroup(group.id)}
|
||
title={expanded ? 'Свернуть' : 'Развернуть'}
|
||
>
|
||
{expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
'flex-1 text-left py-1.5 pr-2 text-xs truncate',
|
||
selectedGroupId === group.id ? 'font-medium text-foreground' : 'text-muted-foreground'
|
||
)}
|
||
onClick={() => (onSelectGroup ? onSelectGroup(group.id) : toggleGroup(group.id))}
|
||
title={group.name}
|
||
>
|
||
{group.name}
|
||
<span className="ml-1 text-[10px] text-muted-foreground">({groupAssets.length})</span>
|
||
</button>
|
||
</div>
|
||
{expanded && <div className="ml-4 space-y-0.5">{groupAssets.map(renderAsset)}</div>}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Объекты без группы — в самом низу панели */}
|
||
{ungroupedAssets.length > 0 && (
|
||
<div>
|
||
{visibleGroups.length > 0 && (
|
||
<div className="px-2 pt-1.5 pb-0.5 text-[10px] font-medium text-muted-foreground uppercase tracking-wide">
|
||
Без группы
|
||
</div>
|
||
)}
|
||
<div className="space-y-0.5">{ungroupedAssets.map(renderAsset)}</div>
|
||
</div>
|
||
)}
|
||
|
||
{query && visibleGroups.every((g) => g.assetIds.map((id) => assetsById.get(id)).filter((a) => a && matches(a)).length === 0) && ungroupedAssets.length === 0 && (
|
||
<p className="text-xs text-muted-foreground py-3 text-center">Ничего не найдено</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{children}
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|