Files
iistwin/client/src/components/gps/AssetTreePanel.tsx

194 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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