feat: CI/CD pipeline + Learning/Medicine/Travel services
- Add Gitea Actions workflow for automated build & deploy - Add K8s manifests: webui, travel-svc, medicine-svc, sandbox-svc - Update kustomization for localhost:5000 registry - Add ingress for gooseek.ru and api.gooseek.ru - Learning cabinet with onboarding, courses, sandbox integration - Medicine service with symptom analysis and doctor matching - Travel service with itinerary planning - Server setup scripts (NVIDIA/CUDA, K3s, Gitea runner) Made-with: Cursor
This commit is contained in:
954
backend/webui/src/components/EditableItinerary.tsx
Normal file
954
backend/webui/src/components/EditableItinerary.tsx
Normal file
@@ -0,0 +1,954 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragOverlay,
|
||||
useDroppable,
|
||||
type DragStartEvent,
|
||||
type DragEndEvent,
|
||||
type DragOverEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
GripVertical,
|
||||
X,
|
||||
Plus,
|
||||
Check,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
Lightbulb,
|
||||
Search,
|
||||
MapPin,
|
||||
Ticket,
|
||||
Utensils,
|
||||
Camera,
|
||||
ShoppingBag,
|
||||
Coffee,
|
||||
Footprints,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Ban,
|
||||
} from 'lucide-react';
|
||||
import type {
|
||||
ItineraryItem,
|
||||
POICard,
|
||||
EventCard,
|
||||
DailyForecast,
|
||||
} from '@/lib/types';
|
||||
import {
|
||||
useEditableItinerary,
|
||||
validateItemPlacement,
|
||||
type EditableDay,
|
||||
type ValidationResult,
|
||||
type LLMValidationResponse,
|
||||
type LLMValidationWarning,
|
||||
type CustomItemType,
|
||||
} from '@/lib/hooks/useEditableItinerary';
|
||||
import type { ItineraryDay } from '@/lib/types';
|
||||
|
||||
// --- Sortable Item ---
|
||||
|
||||
interface SortableItemProps {
|
||||
item: ItineraryItem;
|
||||
itemId: string;
|
||||
dayIdx: number;
|
||||
itemIdx: number;
|
||||
onRemove: () => void;
|
||||
warning?: LLMValidationWarning;
|
||||
}
|
||||
|
||||
function SortableItem({ item, itemId, dayIdx, itemIdx, onRemove, warning }: SortableItemProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: itemId });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.4 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`group flex items-center gap-2 px-3 py-2 rounded-lg border transition-colors ${
|
||||
warning
|
||||
? 'border-amber-500/40 bg-amber-500/5'
|
||||
: 'border-border/30 bg-elevated/20 hover:border-border/50'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex-shrink-0 p-0.5 text-muted hover:text-secondary cursor-grab active:cursor-grabbing touch-none"
|
||||
aria-label="Перетащить"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.startTime && (
|
||||
<span className="text-[10px] text-accent font-mono bg-accent/8 px-1.5 py-px rounded flex-shrink-0">
|
||||
{item.startTime}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[12px] font-medium text-primary truncate">{item.title}</span>
|
||||
<span className="text-[9px] text-muted bg-surface/50 px-1.5 py-px rounded-full flex-shrink-0">
|
||||
{item.refType}
|
||||
</span>
|
||||
</div>
|
||||
{warning && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<AlertTriangle className="w-3 h-3 text-amber-400 flex-shrink-0" />
|
||||
<span className="text-[10px] text-amber-400">{warning.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="flex-shrink-0 p-1 rounded text-muted hover:text-error hover:bg-error/10 opacity-0 group-hover:opacity-100 transition-all"
|
||||
aria-label="Удалить"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Drag Overlay Item ---
|
||||
|
||||
function DragOverlayItem({ item }: { item: ItineraryItem }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-accent/50 bg-elevated shadow-lg shadow-accent/10">
|
||||
<GripVertical className="w-4 h-4 text-accent" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.startTime && (
|
||||
<span className="text-[10px] text-accent font-mono bg-accent/8 px-1.5 py-px rounded">
|
||||
{item.startTime}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[12px] font-medium text-primary truncate">{item.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Validation Toast ---
|
||||
|
||||
function ValidationToast({ result, onDismiss }: { result: ValidationResult; onDismiss: () => void }) {
|
||||
if (result.valid) return null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.95 }}
|
||||
className="fixed bottom-24 left-1/2 -translate-x-1/2 z-50 max-w-sm"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 py-3 rounded-xl bg-red-950/90 border border-red-500/40 shadow-lg backdrop-blur-sm">
|
||||
<Ban className="w-5 h-5 text-red-400 flex-shrink-0" />
|
||||
<p className="text-sm text-red-200">{result.reason}</p>
|
||||
<button onClick={onDismiss} className="p-1 text-red-400 hover:text-red-300 flex-shrink-0">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Add Item Panel ---
|
||||
|
||||
interface AddItemPanelProps {
|
||||
pois: POICard[];
|
||||
events: EventCard[];
|
||||
dayIdx: number;
|
||||
dayDate: string;
|
||||
dayItems: ItineraryItem[];
|
||||
poisMap: Map<string, POICard>;
|
||||
eventsMap: Map<string, EventCard>;
|
||||
onAdd: (dayIdx: number, item: ItineraryItem) => ValidationResult;
|
||||
onAddCustom: (dayIdx: number, title: string, refType: CustomItemType, duration: number) => ValidationResult;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type AddTab = 'poi' | 'event' | 'custom';
|
||||
|
||||
const customTypeOptions: { value: CustomItemType; label: string; icon: typeof Utensils }[] = [
|
||||
{ value: 'food', label: 'Еда', icon: Utensils },
|
||||
{ value: 'walk', label: 'Прогулка', icon: Footprints },
|
||||
{ value: 'rest', label: 'Отдых', icon: Coffee },
|
||||
{ value: 'shopping', label: 'Шопинг', icon: ShoppingBag },
|
||||
{ value: 'custom', label: 'Другое', icon: MapPin },
|
||||
];
|
||||
|
||||
function AddItemPanel({
|
||||
pois,
|
||||
events,
|
||||
dayIdx,
|
||||
dayDate,
|
||||
dayItems,
|
||||
poisMap,
|
||||
eventsMap,
|
||||
onAdd,
|
||||
onAddCustom,
|
||||
onClose,
|
||||
}: AddItemPanelProps) {
|
||||
const [tab, setTab] = useState<AddTab>('poi');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<POICategory>('all');
|
||||
const [customTitle, setCustomTitle] = useState('');
|
||||
const [customType, setCustomType] = useState<CustomItemType>('food');
|
||||
const [customDuration, setCustomDuration] = useState(60);
|
||||
const [lastError, setLastError] = useState<string | null>(null);
|
||||
|
||||
const filteredPois = useMemo(() => {
|
||||
let result = pois;
|
||||
if (categoryFilter !== 'all') {
|
||||
result = result.filter((p) => p.category.toLowerCase().includes(categoryFilter));
|
||||
}
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.category.toLowerCase().includes(q) ||
|
||||
(p.address && p.address.toLowerCase().includes(q)),
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}, [pois, searchQuery, categoryFilter]);
|
||||
|
||||
const filteredEvents = useMemo(() => {
|
||||
if (!searchQuery.trim()) return events;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return events.filter(
|
||||
(e) =>
|
||||
e.title.toLowerCase().includes(q) ||
|
||||
(e.address && e.address.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [events, searchQuery]);
|
||||
|
||||
const handleAddPOI = useCallback(
|
||||
(poi: POICard) => {
|
||||
const item: ItineraryItem = {
|
||||
refType: 'poi',
|
||||
refId: poi.id,
|
||||
title: poi.name,
|
||||
lat: poi.lat,
|
||||
lng: poi.lng,
|
||||
note: poi.description || '',
|
||||
cost: poi.price || 0,
|
||||
currency: poi.currency || 'RUB',
|
||||
};
|
||||
const result = onAdd(dayIdx, item);
|
||||
if (!result.valid) {
|
||||
setLastError(result.reason || 'Невозможно добавить');
|
||||
setTimeout(() => setLastError(null), 3000);
|
||||
} else {
|
||||
setLastError(null);
|
||||
}
|
||||
},
|
||||
[dayIdx, onAdd],
|
||||
);
|
||||
|
||||
const handleAddEvent = useCallback(
|
||||
(event: EventCard) => {
|
||||
const item: ItineraryItem = {
|
||||
refType: 'event',
|
||||
refId: event.id,
|
||||
title: event.title,
|
||||
lat: event.lat || 0,
|
||||
lng: event.lng || 0,
|
||||
note: event.description || '',
|
||||
cost: event.price || 0,
|
||||
currency: event.currency || 'RUB',
|
||||
};
|
||||
const result = onAdd(dayIdx, item);
|
||||
if (!result.valid) {
|
||||
setLastError(result.reason || 'Невозможно добавить');
|
||||
setTimeout(() => setLastError(null), 3000);
|
||||
} else {
|
||||
setLastError(null);
|
||||
}
|
||||
},
|
||||
[dayIdx, onAdd],
|
||||
);
|
||||
|
||||
const handleAddCustom = useCallback(() => {
|
||||
if (!customTitle.trim()) return;
|
||||
const result = onAddCustom(dayIdx, customTitle.trim(), customType, customDuration);
|
||||
if (!result.valid) {
|
||||
setLastError(result.reason || 'Невозможно добавить');
|
||||
setTimeout(() => setLastError(null), 3000);
|
||||
} else {
|
||||
setCustomTitle('');
|
||||
setLastError(null);
|
||||
}
|
||||
}, [dayIdx, customTitle, customType, customDuration, onAddCustom]);
|
||||
|
||||
const isItemAlreadyInDay = useCallback(
|
||||
(refId: string) => dayItems.some((i) => i.refId === refId),
|
||||
[dayItems],
|
||||
);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="p-3 border border-border/30 rounded-lg bg-elevated/30 mt-2">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex gap-1">
|
||||
{[
|
||||
{ key: 'poi' as AddTab, label: 'Места', icon: Camera },
|
||||
{ key: 'event' as AddTab, label: 'События', icon: Ticket },
|
||||
{ key: 'custom' as AddTab, label: 'Своё', icon: Plus },
|
||||
].map(({ key, label, icon: Icon }) => (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => { setTab(key); setSearchQuery(''); }}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] rounded-lg transition-colors ${
|
||||
tab === key
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'text-muted hover:text-secondary hover:bg-surface/30'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 text-muted hover:text-primary">
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{lastError && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 mb-2 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<Ban className="w-3.5 h-3.5 text-red-400 flex-shrink-0" />
|
||||
<span className="text-[11px] text-red-300">{lastError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab !== 'custom' && (
|
||||
<div className="space-y-2 mb-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={tab === 'poi' ? 'Поиск мест...' : 'Поиск событий...'}
|
||||
className="w-full pl-8 pr-3 py-2 bg-surface/40 border border-border/30 rounded-lg text-[12px] text-primary placeholder:text-muted focus:outline-none focus:border-accent/40"
|
||||
/>
|
||||
</div>
|
||||
{tab === 'poi' && (
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{categoryFilters.map((cf) => (
|
||||
<button
|
||||
key={cf.value}
|
||||
onClick={() => setCategoryFilter(cf.value)}
|
||||
className={`px-2 py-1 text-[9px] rounded-md transition-colors ${
|
||||
categoryFilter === cf.value
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'bg-surface/30 text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
{cf.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'poi' && (
|
||||
<div className="max-h-[200px] overflow-y-auto space-y-1 scrollbar-thin">
|
||||
{filteredPois.length === 0 ? (
|
||||
<p className="text-[11px] text-muted text-center py-4">Нет доступных мест</p>
|
||||
) : (
|
||||
filteredPois.map((poi) => {
|
||||
const alreadyAdded = isItemAlreadyInDay(poi.id);
|
||||
const precheck = validateItemPlacement(
|
||||
{ refType: 'poi', refId: poi.id, title: poi.name, lat: poi.lat, lng: poi.lng },
|
||||
dayDate,
|
||||
dayItems,
|
||||
poisMap,
|
||||
eventsMap,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={poi.id}
|
||||
onClick={() => !alreadyAdded && precheck.valid && handleAddPOI(poi)}
|
||||
disabled={alreadyAdded || !precheck.valid}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors ${
|
||||
alreadyAdded
|
||||
? 'opacity-50 cursor-not-allowed bg-surface/20'
|
||||
: !precheck.valid
|
||||
? 'opacity-60 cursor-not-allowed bg-red-500/5'
|
||||
: 'hover:bg-surface/30'
|
||||
}`}
|
||||
>
|
||||
<Camera className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[11px] font-medium text-primary truncate block">{poi.name}</span>
|
||||
{!precheck.valid && (
|
||||
<span className="text-[9px] text-red-400">{precheck.reason}</span>
|
||||
)}
|
||||
</div>
|
||||
{alreadyAdded ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />
|
||||
) : (
|
||||
<Plus className="w-3.5 h-3.5 text-muted flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'event' && (
|
||||
<div className="max-h-[200px] overflow-y-auto space-y-1 scrollbar-thin">
|
||||
{filteredEvents.length === 0 ? (
|
||||
<p className="text-[11px] text-muted text-center py-4">Нет доступных событий</p>
|
||||
) : (
|
||||
filteredEvents.map((event) => {
|
||||
const alreadyAdded = isItemAlreadyInDay(event.id);
|
||||
const precheck = validateItemPlacement(
|
||||
{ refType: 'event', refId: event.id, title: event.title, lat: event.lat || 0, lng: event.lng || 0 },
|
||||
dayDate,
|
||||
dayItems,
|
||||
poisMap,
|
||||
eventsMap,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={event.id}
|
||||
onClick={() => !alreadyAdded && precheck.valid && handleAddEvent(event)}
|
||||
disabled={alreadyAdded || !precheck.valid}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-left transition-colors ${
|
||||
alreadyAdded
|
||||
? 'opacity-50 cursor-not-allowed bg-surface/20'
|
||||
: !precheck.valid
|
||||
? 'opacity-60 cursor-not-allowed bg-red-500/5'
|
||||
: 'hover:bg-surface/30'
|
||||
}`}
|
||||
>
|
||||
<Ticket className="w-3.5 h-3.5 text-accent flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-[11px] font-medium text-primary truncate block">{event.title}</span>
|
||||
{event.dateStart && (
|
||||
<span className="text-[9px] text-muted">{event.dateStart}</span>
|
||||
)}
|
||||
{!precheck.valid && (
|
||||
<span className="text-[9px] text-red-400 block">{precheck.reason}</span>
|
||||
)}
|
||||
</div>
|
||||
{alreadyAdded ? (
|
||||
<Check className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />
|
||||
) : (
|
||||
<Plus className="w-3.5 h-3.5 text-muted flex-shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'custom' && (
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
placeholder="Название пункта..."
|
||||
className="w-full px-3 py-2 bg-surface/40 border border-border/30 rounded-lg text-[12px] text-primary placeholder:text-muted focus:outline-none focus:border-accent/40"
|
||||
/>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{customTypeOptions.map(({ value, label, icon: Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setCustomType(value)}
|
||||
className={`flex items-center gap-1 px-2.5 py-1.5 text-[10px] rounded-lg transition-colors ${
|
||||
customType === value
|
||||
? 'bg-accent/20 text-accent border border-accent/30'
|
||||
: 'bg-surface/30 text-muted hover:text-secondary border border-border/20'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-3.5 h-3.5 text-muted" />
|
||||
<input
|
||||
type="number"
|
||||
value={customDuration}
|
||||
onChange={(e) => setCustomDuration(Math.max(15, Number(e.target.value)))}
|
||||
min={15}
|
||||
max={480}
|
||||
step={15}
|
||||
className="w-20 px-2 py-1.5 bg-surface/40 border border-border/30 rounded-lg text-[11px] text-primary focus:outline-none focus:border-accent/40"
|
||||
/>
|
||||
<span className="text-[10px] text-muted">мин</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddCustom}
|
||||
disabled={!customTitle.trim()}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-accent/20 text-accent rounded-lg text-[11px] font-medium hover:bg-accent/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Добавить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Droppable Day Container ---
|
||||
|
||||
function DroppableDayZone({ dayIdx, children }: { dayIdx: number; children: React.ReactNode }) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: `droppable-day-${dayIdx}`,
|
||||
data: { dayIdx },
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`transition-colors rounded-lg ${isOver ? 'ring-2 ring-accent/40 bg-accent/5' : ''}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Category filter for AddItemPanel ---
|
||||
|
||||
type POICategory = 'all' | 'attraction' | 'museum' | 'park' | 'restaurant' | 'shopping' | 'entertainment' | 'religious';
|
||||
|
||||
const categoryFilters: { value: POICategory; label: string }[] = [
|
||||
{ value: 'all', label: 'Все' },
|
||||
{ value: 'attraction', label: 'Достопримечательности' },
|
||||
{ value: 'museum', label: 'Музеи' },
|
||||
{ value: 'park', label: 'Парки' },
|
||||
{ value: 'restaurant', label: 'Рестораны' },
|
||||
{ value: 'shopping', label: 'Шопинг' },
|
||||
{ value: 'entertainment', label: 'Развлечения' },
|
||||
];
|
||||
|
||||
// --- Main EditableItinerary Component ---
|
||||
|
||||
interface EditableItineraryProps {
|
||||
days: ItineraryDay[];
|
||||
pois: POICard[];
|
||||
events: EventCard[];
|
||||
dailyForecast?: DailyForecast[];
|
||||
onApply: (days: EditableDay[]) => void;
|
||||
onCancel: () => void;
|
||||
onValidateWithLLM?: (days: EditableDay[]) => Promise<LLMValidationResponse | null>;
|
||||
}
|
||||
|
||||
export function EditableItinerary({
|
||||
days,
|
||||
pois,
|
||||
events,
|
||||
dailyForecast,
|
||||
onApply,
|
||||
onCancel,
|
||||
onValidateWithLLM,
|
||||
}: EditableItineraryProps) {
|
||||
const poisMap = useMemo(() => new Map(pois.map((p) => [p.id, p])), [pois]);
|
||||
const eventsMap = useMemo(() => new Map(events.map((e) => [e.id, e])), [events]);
|
||||
|
||||
const {
|
||||
editableDays,
|
||||
isEditing,
|
||||
hasChanges,
|
||||
isValidating,
|
||||
llmWarnings,
|
||||
llmSuggestions,
|
||||
startEditing,
|
||||
stopEditing,
|
||||
resetChanges,
|
||||
moveItem,
|
||||
addItem,
|
||||
removeItem,
|
||||
addCustomItem,
|
||||
applyChanges,
|
||||
} = useEditableItinerary({
|
||||
initialDays: days,
|
||||
poisMap,
|
||||
eventsMap,
|
||||
dailyForecast,
|
||||
onValidateWithLLM,
|
||||
});
|
||||
|
||||
const [activeItem, setActiveItem] = useState<ItineraryItem | null>(null);
|
||||
const [activeDragId, setActiveDragId] = useState<string | null>(null);
|
||||
const [validationToast, setValidationToast] = useState<ValidationResult | null>(null);
|
||||
const [addPanelDayIdx, setAddPanelDayIdx] = useState<number | null>(null);
|
||||
const [expandedDays, setExpandedDays] = useState<Set<number>>(new Set([0]));
|
||||
const [showLLMResults, setShowLLMResults] = useState(false);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
// Start editing on mount
|
||||
if (!isEditing) {
|
||||
startEditing();
|
||||
}
|
||||
|
||||
const getItemId = (dayIdx: number, itemIdx: number): string =>
|
||||
`day-${dayIdx}-item-${itemIdx}`;
|
||||
|
||||
const parseItemId = (id: string): { dayIdx: number; itemIdx: number } | null => {
|
||||
const match = id.match(/^day-(\d+)-item-(\d+)$/);
|
||||
if (!match) return null;
|
||||
return { dayIdx: parseInt(match[1], 10), itemIdx: parseInt(match[2], 10) };
|
||||
};
|
||||
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const parsed = parseItemId(String(event.active.id));
|
||||
if (!parsed) return;
|
||||
const item = editableDays[parsed.dayIdx]?.items[parsed.itemIdx];
|
||||
if (item) {
|
||||
setActiveItem(item);
|
||||
setActiveDragId(String(event.active.id));
|
||||
}
|
||||
},
|
||||
[editableDays],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const from = parseItemId(String(active.id));
|
||||
if (!from) return;
|
||||
|
||||
const overId = String(over.id);
|
||||
let toDayIdx: number | null = null;
|
||||
|
||||
if (overId.startsWith('droppable-day-')) {
|
||||
toDayIdx = parseInt(overId.replace('droppable-day-', ''), 10);
|
||||
} else {
|
||||
const to = parseItemId(overId);
|
||||
if (to) toDayIdx = to.dayIdx;
|
||||
}
|
||||
|
||||
if (toDayIdx !== null && toDayIdx !== from.dayIdx) {
|
||||
const toItemIdx = editableDays[toDayIdx]?.items.length ?? 0;
|
||||
const result = moveItem(from.dayIdx, from.itemIdx, toDayIdx, toItemIdx);
|
||||
if (!result.valid) {
|
||||
setValidationToast(result);
|
||||
setTimeout(() => setValidationToast(null), 4000);
|
||||
}
|
||||
}
|
||||
},
|
||||
[editableDays, moveItem],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveItem(null);
|
||||
setActiveDragId(null);
|
||||
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const from = parseItemId(String(active.id));
|
||||
const to = parseItemId(String(over.id));
|
||||
if (!from || !to) return;
|
||||
|
||||
if (from.dayIdx === to.dayIdx) {
|
||||
const result = moveItem(from.dayIdx, from.itemIdx, to.dayIdx, to.itemIdx);
|
||||
if (!result.valid) {
|
||||
setValidationToast(result);
|
||||
setTimeout(() => setValidationToast(null), 4000);
|
||||
}
|
||||
}
|
||||
},
|
||||
[moveItem],
|
||||
);
|
||||
|
||||
const handleApply = useCallback(async () => {
|
||||
if (onValidateWithLLM) {
|
||||
const result = await applyChanges();
|
||||
if (result) {
|
||||
setShowLLMResults(true);
|
||||
const hasCritical = result.warnings.length > 0;
|
||||
if (!hasCritical) {
|
||||
onApply(editableDays);
|
||||
}
|
||||
} else {
|
||||
onApply(editableDays);
|
||||
}
|
||||
} else {
|
||||
onApply(editableDays);
|
||||
}
|
||||
}, [applyChanges, editableDays, onApply, onValidateWithLLM]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
stopEditing();
|
||||
onCancel();
|
||||
}, [stopEditing, onCancel]);
|
||||
|
||||
const toggleDay = useCallback((idx: number) => {
|
||||
setExpandedDays((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(idx)) next.delete(idx);
|
||||
else next.add(idx);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const getWarningForItem = useCallback(
|
||||
(dayIdx: number, itemIdx: number): LLMValidationWarning | undefined =>
|
||||
llmWarnings.find((w) => w.dayIdx === dayIdx && w.itemIdx === itemIdx),
|
||||
[llmWarnings],
|
||||
);
|
||||
|
||||
if (!isEditing || editableDays.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between gap-2 px-1 pb-2 border-b border-border/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleApply}
|
||||
disabled={!hasChanges || isValidating}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-medium bg-accent/20 text-accent rounded-lg hover:bg-accent/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isValidating ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{isValidating ? 'Проверка...' : 'Применить'}
|
||||
</button>
|
||||
<button
|
||||
onClick={resetChanges}
|
||||
disabled={!hasChanges}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-muted hover:text-secondary rounded-lg hover:bg-surface/30 transition-colors disabled:opacity-40"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-muted hover:text-primary rounded-lg hover:bg-surface/30 transition-colors"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* LLM Validation Results */}
|
||||
<AnimatePresence>
|
||||
{showLLMResults && (llmWarnings.length > 0 || llmSuggestions.length > 0) && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="space-y-2 pb-2">
|
||||
{llmWarnings.length > 0 && (
|
||||
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-[11px] font-medium text-amber-300">Предупреждения</span>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{llmWarnings.map((w, i) => (
|
||||
<li key={i} className="text-[10px] text-amber-200/80 flex items-start gap-1.5">
|
||||
<span className="text-amber-400 flex-shrink-0">•</span>
|
||||
{w.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
onClick={() => { onApply(editableDays); setShowLLMResults(false); }}
|
||||
className="mt-2 text-[10px] text-accent hover:underline"
|
||||
>
|
||||
Применить всё равно
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{llmSuggestions.length > 0 && (
|
||||
<div className="p-3 rounded-lg bg-accent/5 border border-accent/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Lightbulb className="w-4 h-4 text-accent" />
|
||||
<span className="text-[11px] font-medium text-accent">Рекомендации</span>
|
||||
</div>
|
||||
<ul className="space-y-1">
|
||||
{llmSuggestions.map((s, i) => (
|
||||
<li key={i} className="text-[10px] text-secondary flex items-start gap-1.5">
|
||||
<span className="text-accent flex-shrink-0">•</span>
|
||||
{s.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* DnD Context */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{editableDays.map((day, dayIdx) => {
|
||||
const isExpanded = expandedDays.has(dayIdx);
|
||||
const itemIds = day.items.map((_, i) => getItemId(dayIdx, i));
|
||||
|
||||
return (
|
||||
<DroppableDayZone key={`edit-day-${dayIdx}`} dayIdx={dayIdx}>
|
||||
<div
|
||||
className="border border-border/30 rounded-xl overflow-hidden bg-elevated/20"
|
||||
>
|
||||
<button
|
||||
onClick={() => toggleDay(dayIdx)}
|
||||
className="w-full flex items-center justify-between px-3.5 py-2.5 hover:bg-surface/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="w-6 h-6 rounded-md bg-accent text-white text-[10px] font-bold flex items-center justify-center">
|
||||
{dayIdx + 1}
|
||||
</span>
|
||||
<span className="text-[13px] font-medium text-primary">{day.date}</span>
|
||||
<span className="text-[11px] text-muted">{day.items.length} мест</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isExpanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-3.5 pb-3 pt-1 space-y-1.5">
|
||||
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
|
||||
{day.items.map((item, itemIdx) => (
|
||||
<SortableItem
|
||||
key={getItemId(dayIdx, itemIdx)}
|
||||
item={item}
|
||||
itemId={getItemId(dayIdx, itemIdx)}
|
||||
dayIdx={dayIdx}
|
||||
itemIdx={itemIdx}
|
||||
onRemove={() => removeItem(dayIdx, itemIdx)}
|
||||
warning={getWarningForItem(dayIdx, itemIdx)}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
||||
{day.items.length === 0 && (
|
||||
<div className="py-4 text-center text-[11px] text-muted">
|
||||
Пусто — добавьте пункты
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
setAddPanelDayIdx(addPanelDayIdx === dayIdx ? null : dayIdx)
|
||||
}
|
||||
className="w-full flex items-center justify-center gap-1.5 px-3 py-2 border border-dashed border-border/40 rounded-lg text-[11px] text-muted hover:text-accent hover:border-accent/30 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Добавить пункт
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{addPanelDayIdx === dayIdx && (
|
||||
<AddItemPanel
|
||||
pois={pois}
|
||||
events={events}
|
||||
dayIdx={dayIdx}
|
||||
dayDate={day.date}
|
||||
dayItems={day.items}
|
||||
poisMap={poisMap}
|
||||
eventsMap={eventsMap}
|
||||
onAdd={addItem}
|
||||
onAddCustom={addCustomItem}
|
||||
onClose={() => setAddPanelDayIdx(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</DroppableDayZone>
|
||||
);
|
||||
})}
|
||||
|
||||
<DragOverlay>
|
||||
{activeItem ? <DragOverlayItem item={activeItem} /> : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
{/* Validation Toast */}
|
||||
<AnimatePresence>
|
||||
{validationToast && !validationToast.valid && (
|
||||
<ValidationToast
|
||||
result={validationToast}
|
||||
onDismiss={() => setValidationToast(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
668
backend/webui/src/components/MedicineWidgetTabs.tsx
Normal file
668
backend/webui/src/components/MedicineWidgetTabs.tsx
Normal file
@@ -0,0 +1,668 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
ArrowUpRight,
|
||||
Beaker,
|
||||
Calendar,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CircleAlert,
|
||||
ExternalLink,
|
||||
HeartPulse,
|
||||
HelpCircle,
|
||||
Home,
|
||||
Loader2,
|
||||
MapPin,
|
||||
Monitor,
|
||||
Pill,
|
||||
Shield,
|
||||
Stethoscope,
|
||||
Thermometer,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import type { MedicineWidget } from '@/lib/hooks/useMedicineChat';
|
||||
|
||||
type TabId = 'assessment' | 'doctors' | 'booking' | 'reference';
|
||||
|
||||
interface TabDef {
|
||||
id: TabId;
|
||||
label: string;
|
||||
icon: typeof HeartPulse;
|
||||
emptyLabel: string;
|
||||
}
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ id: 'assessment', label: 'Оценка', icon: HeartPulse, emptyLabel: 'Оценка симптомов появится после запроса' },
|
||||
{ id: 'doctors', label: 'Врачи', icon: Stethoscope, emptyLabel: 'Специалисты подбираются после анализа' },
|
||||
{ id: 'booking', label: 'Запись', icon: Calendar, emptyLabel: 'Ссылки на запись появятся после подбора врачей' },
|
||||
{ id: 'reference', label: 'Справка', icon: Pill, emptyLabel: 'Справочная информация появится после ответа' },
|
||||
];
|
||||
|
||||
interface ConditionItem {
|
||||
name: string;
|
||||
likelihood: string;
|
||||
why: string;
|
||||
}
|
||||
|
||||
interface SpecialtyItem {
|
||||
specialty: string;
|
||||
reason: string;
|
||||
priority: string;
|
||||
}
|
||||
|
||||
interface DoctorOption {
|
||||
id: string;
|
||||
name: string;
|
||||
specialty: string;
|
||||
clinic: string;
|
||||
city: string;
|
||||
address?: string;
|
||||
sourceUrl: string;
|
||||
sourceName: string;
|
||||
snippet?: string;
|
||||
}
|
||||
|
||||
interface BookingLink {
|
||||
id: string;
|
||||
doctorId: string;
|
||||
doctor: string;
|
||||
specialty: string;
|
||||
clinic: string;
|
||||
bookUrl: string;
|
||||
remote: boolean;
|
||||
}
|
||||
|
||||
interface MedicationInfoItem {
|
||||
name: string;
|
||||
forWhat: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface SupplementInfoItem {
|
||||
name: string;
|
||||
purpose: string;
|
||||
evidence: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
interface ProcedureInfoItem {
|
||||
name: string;
|
||||
purpose: string;
|
||||
whenUseful: string;
|
||||
}
|
||||
|
||||
const TRIAGE_CONFIG: Record<string, { label: string; color: string; bg: string; border: string; icon: typeof Zap }> = {
|
||||
low: { label: 'Низкий', color: 'text-green-400', bg: 'bg-green-500/10', border: 'border-green-500/30', icon: Shield },
|
||||
medium: { label: 'Средний', color: 'text-amber-400', bg: 'bg-amber-500/10', border: 'border-amber-500/30', icon: Activity },
|
||||
high: { label: 'Высокий', color: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/30', icon: AlertTriangle },
|
||||
emergency: { label: 'Экстренный', color: 'text-red-400', bg: 'bg-red-500/10', border: 'border-red-500/30', icon: Zap },
|
||||
};
|
||||
|
||||
const LIKELIHOOD_CONFIG: Record<string, { label: string; width: string; color: string }> = {
|
||||
low: { label: 'Маловероятно', width: 'w-1/4', color: 'bg-blue-500/60' },
|
||||
medium: { label: 'Возможно', width: 'w-2/4', color: 'bg-amber-500/60' },
|
||||
high: { label: 'Вероятно', width: 'w-3/4', color: 'bg-orange-500/60' },
|
||||
};
|
||||
|
||||
const EVIDENCE_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
|
||||
low: { label: 'Слабая доказательность', color: 'text-zinc-400', bg: 'bg-zinc-500/15' },
|
||||
medium: { label: 'Умеренная доказательность', color: 'text-amber-400', bg: 'bg-amber-500/15' },
|
||||
high: { label: 'Высокая доказательность', color: 'text-green-400', bg: 'bg-green-500/15' },
|
||||
};
|
||||
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
icon: Icon,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
count,
|
||||
}: {
|
||||
title: string;
|
||||
icon: typeof HeartPulse;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
count?: number;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div className="border border-border/20 rounded-xl overflow-hidden">
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-surface/30 hover:bg-surface/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-primary">
|
||||
<Icon className="w-4 h-4 text-accent" />
|
||||
{title}
|
||||
{count !== undefined && count > 0 && (
|
||||
<span className="text-xs text-muted bg-surface/50 px-1.5 py-0.5 rounded-md">{count}</span>
|
||||
)}
|
||||
</div>
|
||||
{open ? <ChevronUp className="w-3.5 h-3.5 text-muted" /> : <ChevronDown className="w-3.5 h-3.5 text-muted" />}
|
||||
</button>
|
||||
<AnimatePresence initial={false}>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="p-3 space-y-2">{children}</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TriageBadge({ level }: { level: string }) {
|
||||
const cfg = TRIAGE_CONFIG[level] || TRIAGE_CONFIG.medium;
|
||||
const Icon = cfg.icon;
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 p-4 rounded-xl border ${cfg.bg} ${cfg.border}`}>
|
||||
<div className={`w-10 h-10 rounded-xl ${cfg.bg} flex items-center justify-center`}>
|
||||
<Icon className={`w-5 h-5 ${cfg.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted uppercase tracking-wider">Уровень приоритета</div>
|
||||
<div className={`text-lg font-bold ${cfg.color}`}>{cfg.label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LikelihoodBar({ likelihood }: { likelihood: string }) {
|
||||
const cfg = LIKELIHOOD_CONFIG[likelihood] || LIKELIHOOD_CONFIG.medium;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 bg-surface/40 rounded-full overflow-hidden">
|
||||
<div className={`h-full ${cfg.width} ${cfg.color} rounded-full transition-all`} />
|
||||
</div>
|
||||
<span className="text-xs text-muted whitespace-nowrap">{cfg.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssessmentTab({ widget }: { widget: MedicineWidget | undefined }) {
|
||||
if (!widget) return null;
|
||||
|
||||
const p = widget.params;
|
||||
const triageLevel = String(p.triageLevel || 'medium');
|
||||
const urgentSigns = (p.urgentSigns || []) as string[];
|
||||
const conditions = (p.possibleConditions || []) as ConditionItem[];
|
||||
const specialists = (p.recommendedSpecialists || []) as SpecialtyItem[];
|
||||
const questions = (p.questionsToClarify || []) as string[];
|
||||
const homeCare = (p.homeCare || []) as string[];
|
||||
const disclaimer = String(p.disclaimer || '');
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<TriageBadge level={triageLevel} />
|
||||
|
||||
{urgentSigns.length > 0 && (
|
||||
<div className="p-3 rounded-xl bg-red-500/5 border border-red-500/20">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CircleAlert className="w-4 h-4 text-red-400" />
|
||||
<span className="text-sm font-medium text-red-400">Красные флаги</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{urgentSigns.map((sign, i) => (
|
||||
<div key={`us-${i}`} className="flex items-start gap-2 text-xs text-secondary">
|
||||
<span className="text-red-400 mt-0.5">!</span>
|
||||
<span>{sign} — <strong className="text-red-400">вызывайте 103/112</strong></span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conditions.length > 0 && (
|
||||
<CollapsibleSection title="Вероятные состояния" icon={Thermometer} count={conditions.length}>
|
||||
{conditions.map((c, i) => (
|
||||
<div key={`cond-${i}`} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="text-sm font-medium text-primary">{c.name}</span>
|
||||
</div>
|
||||
<LikelihoodBar likelihood={c.likelihood} />
|
||||
{c.why && <p className="text-xs text-muted leading-relaxed">{c.why}</p>}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{specialists.length > 0 && (
|
||||
<CollapsibleSection title="Рекомендуемые специалисты" icon={Stethoscope} count={specialists.length}>
|
||||
{specialists.map((sp, i) => (
|
||||
<div key={`sp-${i}`} className="flex items-start gap-3 p-2.5 rounded-lg bg-surface/20 border border-border/15">
|
||||
<div className={`w-8 h-8 rounded-lg flex items-center justify-center shrink-0 ${sp.priority === 'high' ? 'bg-accent/15' : 'bg-surface/40'}`}>
|
||||
<Stethoscope className={`w-4 h-4 ${sp.priority === 'high' ? 'text-accent' : 'text-muted'}`} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-primary">{sp.specialty}</span>
|
||||
{sp.priority === 'high' && (
|
||||
<span className="text-3xs px-1.5 py-0.5 rounded bg-accent/15 text-accent font-medium">Приоритет</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted mt-0.5">{sp.reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{questions.length > 0 && (
|
||||
<CollapsibleSection title="Уточняющие вопросы" icon={HelpCircle} count={questions.length} defaultOpen={false}>
|
||||
<p className="text-xs text-muted mb-2">Ответы на эти вопросы помогут уточнить оценку:</p>
|
||||
{questions.map((q, i) => (
|
||||
<div key={`q-${i}`} className="flex items-start gap-2 p-2 rounded-lg bg-surface/20">
|
||||
<HelpCircle className="w-3.5 h-3.5 text-accent mt-0.5 shrink-0" />
|
||||
<span className="text-xs text-secondary">{q}</span>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{homeCare.length > 0 && (
|
||||
<CollapsibleSection title="Что делать дома до визита" icon={Home} count={homeCare.length} defaultOpen={false}>
|
||||
{homeCare.map((tip, i) => (
|
||||
<div key={`hc-${i}`} className="flex items-start gap-2 p-2 rounded-lg bg-surface/20">
|
||||
<span className="text-green-400 mt-0.5 text-xs">+</span>
|
||||
<span className="text-xs text-secondary">{tip}</span>
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{disclaimer && (
|
||||
<div className="p-3 rounded-xl bg-surface/20 border border-border/15">
|
||||
<p className="text-xs text-muted italic leading-relaxed">{disclaimer}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DoctorsTab({ widget }: { widget: MedicineWidget | undefined }) {
|
||||
if (!widget) return null;
|
||||
|
||||
const p = widget.params;
|
||||
const doctors = (p.doctors || []) as DoctorOption[];
|
||||
const specialists = (p.specialists || []) as SpecialtyItem[];
|
||||
const city = String(p.city || '');
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, { specialist: SpecialtyItem | null; doctors: DoctorOption[] }>();
|
||||
for (const d of doctors) {
|
||||
const key = d.specialty;
|
||||
if (!map.has(key)) {
|
||||
const sp = specialists.find((s) => s.specialty === key) || null;
|
||||
map.set(key, { specialist: sp, doctors: [] });
|
||||
}
|
||||
map.get(key)!.doctors.push(d);
|
||||
}
|
||||
return Array.from(map.entries());
|
||||
}, [doctors, specialists]);
|
||||
|
||||
if (doctors.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<Stethoscope className="w-8 h-8 text-muted/30 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted">Специалисты не найдены</p>
|
||||
<p className="text-xs text-muted/70 mt-1">Попробуйте указать другой город</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{city && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
<MapPin className="w-3.5 h-3.5" />
|
||||
<span>Поиск в городе: <strong className="text-secondary">{city}</strong></span>
|
||||
<span className="text-muted/50">|</span>
|
||||
<span>Найдено: <strong className="text-secondary">{doctors.length}</strong></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{grouped.map(([specialty, group]) => (
|
||||
<CollapsibleSection
|
||||
key={specialty}
|
||||
title={specialty}
|
||||
icon={Stethoscope}
|
||||
count={group.doctors.length}
|
||||
>
|
||||
{group.specialist && (
|
||||
<div className="flex items-center gap-2 mb-2 px-1">
|
||||
<span className="text-xs text-muted">Причина направления:</span>
|
||||
<span className="text-xs text-secondary">{group.specialist.reason}</span>
|
||||
{group.specialist.priority === 'high' && (
|
||||
<span className="text-3xs px-1.5 py-0.5 rounded bg-accent/15 text-accent font-medium">Приоритет</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{group.doctors.map((d) => (
|
||||
<div key={d.id} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-primary truncate">{d.clinic || d.name}</p>
|
||||
{d.sourceName && (
|
||||
<p className="text-xs text-muted mt-0.5">Источник: {d.sourceName}</p>
|
||||
)}
|
||||
</div>
|
||||
{d.sourceUrl && (
|
||||
<a
|
||||
href={d.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="shrink-0 flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium text-accent bg-accent/10 hover:bg-accent/20 border border-accent/20 rounded-lg transition-colors"
|
||||
>
|
||||
Записаться
|
||||
<ArrowUpRight className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{d.snippet && (
|
||||
<p className="text-xs text-muted leading-relaxed line-clamp-3">{d.snippet}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BookingTab({ widget }: { widget: MedicineWidget | undefined }) {
|
||||
if (!widget) return null;
|
||||
|
||||
const p = widget.params;
|
||||
const bookingLinks = (p.bookingLinks || p.slots || []) as BookingLink[];
|
||||
|
||||
if (bookingLinks.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<Calendar className="w-8 h-8 text-muted/30 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted">Ссылки на запись не найдены</p>
|
||||
<p className="text-xs text-muted/70 mt-1">Попробуйте уточнить город или специальность</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const grouped = useMemo(() => {
|
||||
const map = new Map<string, BookingLink[]>();
|
||||
for (const link of bookingLinks) {
|
||||
const key = link.specialty || 'Другое';
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(link);
|
||||
}
|
||||
return Array.from(map.entries());
|
||||
}, [bookingLinks]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 rounded-xl bg-accent/5 border border-accent/15">
|
||||
<div className="flex items-start gap-2">
|
||||
<Calendar className="w-4 h-4 text-accent mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-primary">Запись к специалистам</p>
|
||||
<p className="text-xs text-muted mt-0.5">
|
||||
Нажмите на ссылку, чтобы перейти на сайт клиники и выбрать удобное время.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{grouped.map(([specialty, links]) => (
|
||||
<CollapsibleSection key={specialty} title={specialty} icon={Stethoscope} count={links.length}>
|
||||
{links.map((link) => (
|
||||
<a
|
||||
key={link.id}
|
||||
href={link.bookUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center gap-3 p-3 rounded-lg bg-surface/20 border border-border/15 hover:border-accent/30 hover:bg-accent/5 transition-all group"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg bg-surface/40 flex items-center justify-center shrink-0 group-hover:bg-accent/15 transition-colors">
|
||||
{link.remote ? (
|
||||
<Monitor className="w-4 h-4 text-muted group-hover:text-accent transition-colors" />
|
||||
) : (
|
||||
<MapPin className="w-4 h-4 text-muted group-hover:text-accent transition-colors" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-primary truncate">{link.doctor}</p>
|
||||
<div className="flex items-center gap-2 mt-0.5">
|
||||
<span className="text-xs text-muted truncate">{link.clinic}</span>
|
||||
{link.remote && (
|
||||
<span className="text-3xs px-1.5 py-0.5 rounded bg-blue-500/15 text-blue-400 font-medium shrink-0">Онлайн</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink className="w-4 h-4 text-muted group-hover:text-accent transition-colors shrink-0" />
|
||||
</a>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReferenceTab({ widget }: { widget: MedicineWidget | undefined }) {
|
||||
if (!widget) return null;
|
||||
|
||||
const p = widget.params;
|
||||
const medications = (p.medicationInfo || []) as MedicationInfoItem[];
|
||||
const supplements = (p.supplementInfo || []) as SupplementInfoItem[];
|
||||
const procedures = (p.procedureInfo || []) as ProcedureInfoItem[];
|
||||
const note = String(p.note || '');
|
||||
|
||||
const hasContent = medications.length > 0 || supplements.length > 0 || procedures.length > 0;
|
||||
|
||||
if (!hasContent) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<Pill className="w-8 h-8 text-muted/30 mx-auto mb-2" />
|
||||
<p className="text-sm text-muted">Справочная информация отсутствует</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{note && (
|
||||
<div className="p-3 rounded-xl bg-amber-500/5 border border-amber-500/15">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500 mt-0.5 shrink-0" />
|
||||
<p className="text-xs text-muted leading-relaxed">{note}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{medications.length > 0 && (
|
||||
<CollapsibleSection title="Лекарственные препараты" icon={Pill} count={medications.length}>
|
||||
<p className="text-xs text-muted mb-2">Только справочная информация. Назначения делает врач.</p>
|
||||
{medications.map((m, i) => (
|
||||
<div key={`med-${i}`} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span className="text-sm font-medium text-primary">{m.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary"><strong>Применяется:</strong> {m.forWhat}</p>
|
||||
{m.notes && <p className="text-xs text-muted italic">{m.notes}</p>}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{supplements.length > 0 && (
|
||||
<CollapsibleSection title="БАДы и добавки" icon={Beaker} count={supplements.length}>
|
||||
{supplements.map((s, i) => {
|
||||
const ev = EVIDENCE_CONFIG[s.evidence] || EVIDENCE_CONFIG.low;
|
||||
return (
|
||||
<div key={`sup-${i}`} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Beaker className="w-3.5 h-3.5 text-green-400" />
|
||||
<span className="text-sm font-medium text-primary">{s.name}</span>
|
||||
</div>
|
||||
<span className={`text-3xs px-1.5 py-0.5 rounded font-medium ${ev.bg} ${ev.color}`}>
|
||||
{ev.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary"><strong>Назначение:</strong> {s.purpose}</p>
|
||||
{s.notes && <p className="text-xs text-muted italic">{s.notes}</p>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{procedures.length > 0 && (
|
||||
<CollapsibleSection title="Обследования и процедуры" icon={Activity} count={procedures.length}>
|
||||
{procedures.map((pr, i) => (
|
||||
<div key={`proc-${i}`} className="p-3 rounded-lg bg-surface/20 border border-border/15 space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-3.5 h-3.5 text-purple-400" />
|
||||
<span className="text-sm font-medium text-primary">{pr.name}</span>
|
||||
</div>
|
||||
<p className="text-xs text-secondary"><strong>Что покажет:</strong> {pr.purpose}</p>
|
||||
{pr.whenUseful && (
|
||||
<p className="text-xs text-muted"><strong>Когда назначают:</strong> {pr.whenUseful}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MedicineWidgetTabsProps {
|
||||
widgets: MedicineWidget[];
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function MedicineWidgetTabs({ widgets, isLoading }: MedicineWidgetTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('assessment');
|
||||
|
||||
const byType = useMemo(() => {
|
||||
const out = new Map<string, MedicineWidget>();
|
||||
for (const w of widgets) {
|
||||
if (w.type.startsWith('medicine_')) out.set(w.type, w);
|
||||
}
|
||||
return out;
|
||||
}, [widgets]);
|
||||
|
||||
const assessment = byType.get('medicine_assessment');
|
||||
const doctors = byType.get('medicine_doctors');
|
||||
const appointments = byType.get('medicine_appointments');
|
||||
const reference = byType.get('medicine_reference');
|
||||
|
||||
const tabHasData = useCallback(
|
||||
(id: TabId): boolean => {
|
||||
switch (id) {
|
||||
case 'assessment': return Boolean(assessment);
|
||||
case 'doctors': return Boolean(doctors);
|
||||
case 'booking': return Boolean(appointments);
|
||||
case 'reference': return Boolean(reference);
|
||||
default: return false;
|
||||
}
|
||||
},
|
||||
[assessment, doctors, appointments, reference],
|
||||
);
|
||||
|
||||
const tabCounts = useMemo((): Record<TabId, number> => {
|
||||
const doctorList = (doctors?.params.doctors || []) as DoctorOption[];
|
||||
const bookingList = (appointments?.params.bookingLinks || appointments?.params.slots || []) as BookingLink[];
|
||||
const conditions = (assessment?.params.possibleConditions || []) as ConditionItem[];
|
||||
const meds = (reference?.params.medicationInfo || []) as MedicationInfoItem[];
|
||||
const sups = (reference?.params.supplementInfo || []) as SupplementInfoItem[];
|
||||
const procs = (reference?.params.procedureInfo || []) as ProcedureInfoItem[];
|
||||
|
||||
return {
|
||||
assessment: conditions.length,
|
||||
doctors: doctorList.length,
|
||||
booking: bookingList.length,
|
||||
reference: meds.length + sups.length + procs.length,
|
||||
};
|
||||
}, [assessment, doctors, appointments, reference]);
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 rounded-xl border border-border/50 bg-base overflow-hidden flex flex-col">
|
||||
<div className="h-11 shrink-0 flex items-center gap-0.5 px-2 pt-2 pb-0 overflow-x-auto scrollbar-hide bg-base border-b border-border/30">
|
||||
{TABS.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
const Icon = tab.icon;
|
||||
const count = tabCounts[tab.id];
|
||||
const hasData = tabHasData(tab.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`h-9 flex items-center gap-1.5 px-3 text-ui-sm font-medium rounded-t-lg transition-all whitespace-nowrap flex-shrink-0 border-b-2 ${
|
||||
isActive
|
||||
? 'bg-surface/60 text-primary border-accent'
|
||||
: 'text-muted hover:text-secondary hover:bg-surface/30 border-transparent'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{tab.label}
|
||||
{hasData && count > 0 && (
|
||||
<span
|
||||
className={`min-w-[18px] h-[14px] px-1.5 text-3xs rounded-full inline-flex items-center justify-center ${
|
||||
isActive ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-muted'
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{hasData && count === 0 && <span className="w-1.5 h-1.5 rounded-full bg-accent" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="p-3"
|
||||
>
|
||||
{isLoading && widgets.length === 0 ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-muted">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-xs">Анализ симптомов...</span>
|
||||
</div>
|
||||
) : !tabHasData(activeTab) ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted">
|
||||
<CircleAlert className="w-5 h-5 opacity-40" />
|
||||
<span className="text-xs">{TABS.find((t) => t.id === activeTab)?.emptyLabel}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'assessment' && <AssessmentTab widget={assessment} />}
|
||||
{activeTab === 'doctors' && <DoctorsTab widget={doctors} />}
|
||||
{activeTab === 'booking' && <BookingTab widget={appointments} />}
|
||||
{activeTab === 'reference' && <ReferenceTab widget={reference} />}
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
643
backend/webui/src/components/SandboxPanel.tsx
Normal file
643
backend/webui/src/components/SandboxPanel.tsx
Normal file
@@ -0,0 +1,643 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import dynamic from 'next/dynamic';
|
||||
import {
|
||||
FileCode,
|
||||
Terminal,
|
||||
Play,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
File,
|
||||
Folder,
|
||||
RefreshCw,
|
||||
Save,
|
||||
Plus,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/lib/contexts/ThemeContext';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
|
||||
const MonacoEditor = dynamic(() => import('@monaco-editor/react'), { ssr: false });
|
||||
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
const headers: HeadersInit = { 'Content-Type': 'application/json' };
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
interface FileNode {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'dir';
|
||||
children?: FileNode[];
|
||||
}
|
||||
|
||||
interface SandboxPanelProps {
|
||||
sessionId: string;
|
||||
onVerifyResult?: (passed: boolean, result: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export default function SandboxPanel({ sessionId, onVerifyResult }: SandboxPanelProps) {
|
||||
const { theme } = useTheme();
|
||||
const [activeTab, setActiveTab] = useState<'editor' | 'terminal'>('editor');
|
||||
const [files, setFiles] = useState<FileNode[]>([]);
|
||||
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
|
||||
const [loadingDirs, setLoadingDirs] = useState<Set<string>>(new Set());
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set(['/home/user']));
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState('');
|
||||
const [originalFileContent, setOriginalFileContent] = useState<string>('');
|
||||
const [isLoadingFile, setIsLoadingFile] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [terminalOutput, setTerminalOutput] = useState('');
|
||||
const [terminalCmd, setTerminalCmd] = useState('');
|
||||
const [isRunningCmd, setIsRunningCmd] = useState(false);
|
||||
const [cmdHistory, setCmdHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number>(-1);
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
const [verifyResult, setVerifyResult] = useState<{ passed: boolean; stdout: string } | null>(null);
|
||||
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<unknown>(null);
|
||||
|
||||
const isDirty = useMemo(() => selectedFile != null && fileContent !== originalFileContent, [
|
||||
selectedFile,
|
||||
fileContent,
|
||||
originalFileContent,
|
||||
]);
|
||||
|
||||
const parseFileNodes = useCallback((data: unknown): FileNode[] => {
|
||||
const rawEntries: unknown[] = Array.isArray(data)
|
||||
? data
|
||||
: (data as { entries?: unknown[] } | null)?.entries && Array.isArray((data as { entries?: unknown[] }).entries)
|
||||
? ((data as { entries?: unknown[] }).entries as unknown[])
|
||||
: [];
|
||||
|
||||
return rawEntries
|
||||
.map((e) => {
|
||||
const entry = e as { name?: unknown; path?: unknown; type?: unknown };
|
||||
const name = typeof entry.name === 'string' ? entry.name : '';
|
||||
const path = typeof entry.path === 'string' ? entry.path : '';
|
||||
const typeRaw = typeof entry.type === 'string' ? entry.type : 'file';
|
||||
const type: 'file' | 'dir' = typeRaw === 'dir' || typeRaw === 'directory' ? 'dir' : 'file';
|
||||
if (!name || !path) return null;
|
||||
return { name, path, type } satisfies FileNode;
|
||||
})
|
||||
.filter((v): v is FileNode => v != null)
|
||||
.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateNodeChildren = useCallback((nodes: FileNode[], dirPath: string, children: FileNode[]): FileNode[] => {
|
||||
return nodes.map((n) => {
|
||||
if (n.type === 'dir' && n.path === dirPath) {
|
||||
return { ...n, children };
|
||||
}
|
||||
if (n.type === 'dir' && n.children) {
|
||||
return { ...n, children: updateNodeChildren(n.children, dirPath, children) };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchDir = useCallback(
|
||||
async (path: string): Promise<FileNode[]> => {
|
||||
const resp = await fetch(
|
||||
`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/files?path=${encodeURIComponent(path)}`,
|
||||
{ headers: getAuthHeaders() }
|
||||
);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`list files failed: ${resp.status}`);
|
||||
}
|
||||
const data: unknown = await resp.json();
|
||||
return parseFileNodes(data);
|
||||
},
|
||||
[sessionId, parseFileNodes]
|
||||
);
|
||||
|
||||
const loadFiles = useCallback(async (path = '/home/user') => {
|
||||
setIsLoadingFiles(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const nodes = await fetchDir(path);
|
||||
if (path === '/home/user') setFiles(nodes);
|
||||
return nodes;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'failed to load files';
|
||||
setErrorMessage(msg);
|
||||
return [];
|
||||
} finally {
|
||||
setIsLoadingFiles(false);
|
||||
}
|
||||
}, [fetchDir]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFiles();
|
||||
}, [loadFiles]);
|
||||
|
||||
const openFile = useCallback(async (path: string) => {
|
||||
setSelectedFile(path);
|
||||
setActiveTab('editor');
|
||||
setIsLoadingFile(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/file?path=${encodeURIComponent(path)}`, {
|
||||
headers: getAuthHeaders(),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`read file failed: ${resp.status}`);
|
||||
const data: unknown = await resp.json();
|
||||
const content = (data as { content?: unknown } | null)?.content;
|
||||
const text = typeof content === 'string' ? content : '';
|
||||
setFileContent(text);
|
||||
setOriginalFileContent(text);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Error loading file';
|
||||
setErrorMessage(msg);
|
||||
setFileContent('');
|
||||
setOriginalFileContent('');
|
||||
} finally {
|
||||
setIsLoadingFile(false);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
const saveFile = useCallback(async () => {
|
||||
if (!selectedFile) return;
|
||||
setIsSaving(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/file`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ path: selectedFile, content: fileContent }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`write file failed: ${resp.status}`);
|
||||
setOriginalFileContent(fileContent);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [sessionId, selectedFile, fileContent]);
|
||||
|
||||
const toggleDir = useCallback(
|
||||
async (node: FileNode) => {
|
||||
if (node.type !== 'dir') return;
|
||||
const willExpand = !expandedDirs.has(node.path);
|
||||
setExpandedDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(node.path)) next.delete(node.path);
|
||||
else next.add(node.path);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!willExpand) return;
|
||||
if (node.children && node.children.length > 0) return;
|
||||
|
||||
setLoadingDirs((prev) => new Set(prev).add(node.path));
|
||||
try {
|
||||
const children = await fetchDir(node.path);
|
||||
setFiles((prev) => updateNodeChildren(prev, node.path, children));
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'failed to load directory';
|
||||
setErrorMessage(msg);
|
||||
} finally {
|
||||
setLoadingDirs((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(node.path);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[expandedDirs, fetchDir, updateNodeChildren]
|
||||
);
|
||||
|
||||
const runCommand = useCallback(async () => {
|
||||
if (!terminalCmd.trim()) return;
|
||||
const cmd = terminalCmd.trim();
|
||||
setTerminalCmd('');
|
||||
setIsRunningCmd(true);
|
||||
setHistoryIndex(-1);
|
||||
setCmdHistory((prev) => (prev[prev.length - 1] === cmd ? prev : [...prev, cmd]));
|
||||
setTerminalOutput((prev) => prev + `$ ${cmd}\n`);
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/commands/run`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ command: cmd }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setTerminalOutput((prev) => prev + `Error: ${resp.status}\n`);
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const stdout = data.stdout || '';
|
||||
const stderr = data.stderr || '';
|
||||
const exitCode = data.exit_code ?? 0;
|
||||
setTerminalOutput((prev) =>
|
||||
prev + stdout + (stderr ? `\nstderr: ${stderr}` : '') + `\n[exit: ${exitCode}]\n\n`
|
||||
);
|
||||
} catch (err) {
|
||||
setTerminalOutput((prev) => prev + `Error: ${err}\n`);
|
||||
} finally {
|
||||
setIsRunningCmd(false);
|
||||
}
|
||||
}, [sessionId, terminalCmd]);
|
||||
|
||||
const runVerify = useCallback(async () => {
|
||||
setIsVerifying(true);
|
||||
setVerifyResult(null);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
if (isDirty && selectedFile) {
|
||||
await saveFile();
|
||||
}
|
||||
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/verify`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setVerifyResult({ passed: false, stdout: `Error: ${resp.status}` });
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
const result = {
|
||||
passed: data.passed === true,
|
||||
stdout: (data.result?.stdout || '') as string,
|
||||
};
|
||||
setVerifyResult(result);
|
||||
onVerifyResult?.(result.passed, data.result || {});
|
||||
} catch {
|
||||
setVerifyResult({ passed: false, stdout: 'Verification failed' });
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
}, [sessionId, onVerifyResult, isDirty, selectedFile, saveFile]);
|
||||
|
||||
useEffect(() => {
|
||||
terminalRef.current?.scrollTo(0, terminalRef.current.scrollHeight);
|
||||
}, [terminalOutput]);
|
||||
|
||||
const fileName = useMemo(() => {
|
||||
if (!selectedFile) return '';
|
||||
return selectedFile.split('/').pop() || '';
|
||||
}, [selectedFile]);
|
||||
|
||||
const editorLanguage = useMemo(() => detectLanguage(selectedFile), [selectedFile]);
|
||||
|
||||
const createNewFile = useCallback(async () => {
|
||||
const p = window.prompt('Путь нового файла (пример: /home/user/main.go)');
|
||||
if (!p) return;
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/v1/sandbox/sessions/${sessionId}/file`, {
|
||||
method: 'PUT',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({ path: p, content: '' }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`create file failed: ${resp.status}`);
|
||||
await loadFiles('/home/user');
|
||||
await openFile(p);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'create file failed';
|
||||
setErrorMessage(msg);
|
||||
}
|
||||
}, [sessionId, loadFiles, openFile]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background border-l border-border/30">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border/30 bg-elevated/40">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded icon-gradient flex items-center justify-center">
|
||||
<FileCode className="w-3 h-3" />
|
||||
</div>
|
||||
<span className="text-xs font-medium text-primary">Песочница</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => loadFiles()}
|
||||
className="p-1.5 rounded-md text-muted hover:text-primary hover:bg-surface/60 transition-all"
|
||||
title="Обновить файлы"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={createNewFile}
|
||||
className="p-1.5 rounded-md text-muted hover:text-primary hover:bg-surface/60 transition-all"
|
||||
title="Новый файл"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={runVerify}
|
||||
disabled={isVerifying}
|
||||
className="flex items-center gap-1 px-2 py-1 text-[10px] btn-gradient rounded-md"
|
||||
>
|
||||
{isVerifying ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin btn-gradient-text" />
|
||||
) : (
|
||||
<Play className="w-3 h-3 btn-gradient-text" />
|
||||
)}
|
||||
<span className="btn-gradient-text">Проверить</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
<AnimatePresence>
|
||||
{errorMessage && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="px-3 py-2 text-xs bg-error/10 text-error border-b border-error/20 overflow-hidden"
|
||||
>
|
||||
{errorMessage}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Verify result banner */}
|
||||
<AnimatePresence>
|
||||
{verifyResult && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className={`px-3 py-2 text-xs flex items-center gap-2 overflow-hidden ${
|
||||
verifyResult.passed
|
||||
? 'bg-success/10 text-success border-b border-success/20'
|
||||
: 'bg-error/10 text-error border-b border-error/20'
|
||||
}`}
|
||||
>
|
||||
{verifyResult.passed ? (
|
||||
<CheckCircle2 className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
)}
|
||||
{verifyResult.passed ? 'Тест пройден!' : 'Тест не пройден'}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Main area */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* File tree */}
|
||||
<div className="w-[180px] border-r border-border/30 overflow-y-auto bg-surface/20">
|
||||
<div className="px-2 py-1.5 text-[10px] text-muted uppercase tracking-wider">Файлы</div>
|
||||
{isLoadingFiles ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-muted" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs">
|
||||
{files.map((node) => (
|
||||
<FileTreeNode
|
||||
key={node.path || node.name}
|
||||
node={node}
|
||||
expanded={expandedDirs}
|
||||
loading={loadingDirs}
|
||||
selected={selectedFile}
|
||||
onToggle={toggleDir}
|
||||
onSelect={openFile}
|
||||
depth={0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Editor / Terminal */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-0 border-b border-border/30 bg-elevated/20">
|
||||
<button
|
||||
onClick={() => setActiveTab('editor')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] border-b-2 transition-all ${
|
||||
activeTab === 'editor'
|
||||
? 'border-accent text-primary'
|
||||
: 'border-transparent text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
<FileCode className="w-3 h-3" />
|
||||
{fileName || 'Редактор'}
|
||||
{isDirty && <span className="text-[10px] text-warning ml-1">•</span>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('terminal')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-[11px] border-b-2 transition-all ${
|
||||
activeTab === 'terminal'
|
||||
? 'border-accent text-primary'
|
||||
: 'border-transparent text-muted hover:text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Terminal className="w-3 h-3" />
|
||||
Терминал
|
||||
</button>
|
||||
{activeTab === 'editor' && selectedFile && (
|
||||
<button
|
||||
onClick={saveFile}
|
||||
disabled={isSaving}
|
||||
className="ml-auto mr-2 p-1 text-muted hover:text-primary transition-all"
|
||||
title="Сохранить"
|
||||
>
|
||||
{isSaving ? <Loader2 className="w-3 h-3 animate-spin" /> : <Save className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'editor' ? (
|
||||
<div className="flex-1 overflow-auto p-0">
|
||||
{selectedFile ? (
|
||||
isLoadingFile ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-5 h-5 animate-spin text-muted" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full h-full">
|
||||
<MonacoEditor
|
||||
value={fileContent}
|
||||
onChange={(v) => setFileContent(v ?? '')}
|
||||
language={editorLanguage}
|
||||
theme={theme === 'dim' ? 'vs-dark' : 'vs'}
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
wordWrap: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
onMount={(editor, monaco) => {
|
||||
editorRef.current = editor as unknown;
|
||||
const editorLike = editor as unknown as { addCommand: (keybinding: number, handler: () => void) => void };
|
||||
const monacoLike = monaco as unknown as { KeyMod: { CtrlCmd: number }; KeyCode: { KeyS: number } };
|
||||
editorLike.addCommand(monacoLike.KeyMod.CtrlCmd | monacoLike.KeyCode.KeyS, () => {
|
||||
void saveFile();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<FileCode className="w-8 h-8 text-muted mb-2" />
|
||||
<p className="text-xs text-muted">Выберите файл</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div
|
||||
ref={terminalRef}
|
||||
className={`flex-1 overflow-auto p-3 font-mono text-xs whitespace-pre-wrap ${
|
||||
theme === 'dim'
|
||||
? 'text-emerald-300 bg-black/30'
|
||||
: 'text-emerald-700 bg-elevated/50 border border-border/30'
|
||||
}`}
|
||||
>
|
||||
{terminalOutput || '$ Введите команду...\n'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-t border-border/30 bg-surface/20">
|
||||
<span className={`text-xs font-mono ${theme === 'dim' ? 'text-emerald-300' : 'text-emerald-700'}`}>$</span>
|
||||
<input
|
||||
value={terminalCmd}
|
||||
onChange={(e) => setTerminalCmd(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
runCommand();
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (cmdHistory.length === 0) return;
|
||||
const nextIndex = historyIndex < 0 ? cmdHistory.length - 1 : Math.max(0, historyIndex - 1);
|
||||
setHistoryIndex(nextIndex);
|
||||
setTerminalCmd(cmdHistory[nextIndex] || '');
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (cmdHistory.length === 0) return;
|
||||
if (historyIndex < 0) return;
|
||||
const nextIndex = Math.min(cmdHistory.length - 1, historyIndex + 1);
|
||||
setHistoryIndex(nextIndex);
|
||||
setTerminalCmd(cmdHistory[nextIndex] || '');
|
||||
}
|
||||
}}
|
||||
placeholder="Введите команду..."
|
||||
className="flex-1 bg-transparent text-xs font-mono text-primary focus:outline-none placeholder:text-muted"
|
||||
disabled={isRunningCmd}
|
||||
/>
|
||||
{isRunningCmd && <Loader2 className="w-3 h-3 animate-spin text-muted" />}
|
||||
<button
|
||||
onClick={() => setTerminalOutput('')}
|
||||
className="p-1 rounded-md text-muted hover:text-primary hover:bg-surface/60 transition-all"
|
||||
title="Очистить"
|
||||
disabled={isRunningCmd}
|
||||
>
|
||||
<XCircle className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileTreeNode({
|
||||
node,
|
||||
expanded,
|
||||
loading,
|
||||
selected,
|
||||
onToggle,
|
||||
onSelect,
|
||||
depth,
|
||||
}: {
|
||||
node: FileNode;
|
||||
expanded: Set<string>;
|
||||
loading: Set<string>;
|
||||
selected: string | null;
|
||||
onToggle: (node: FileNode) => void | Promise<void>;
|
||||
onSelect: (path: string) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const isDir = node.type === 'dir';
|
||||
const isExpanded = expanded.has(node.path);
|
||||
const isSelected = selected === node.path;
|
||||
const isLoading = loading.has(node.path);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isDir) {
|
||||
void onToggle(node);
|
||||
} else {
|
||||
onSelect(node.path);
|
||||
}
|
||||
}}
|
||||
className={`w-full flex items-center gap-1 px-2 py-0.5 text-left hover:bg-surface/60 transition-colors ${
|
||||
isSelected ? 'bg-accent/10 text-accent' : 'text-secondary'
|
||||
}`}
|
||||
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
||||
>
|
||||
{isDir ? (
|
||||
<>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-2.5 h-2.5 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-2.5 h-2.5 flex-shrink-0" />
|
||||
)}
|
||||
<Folder className="w-3 h-3 text-amber-400 flex-shrink-0" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="w-2.5" />
|
||||
<File className="w-3 h-3 text-muted flex-shrink-0" />
|
||||
</>
|
||||
)}
|
||||
<span className="truncate text-[11px]">{node.name}</span>
|
||||
{isDir && isExpanded && isLoading && <Loader2 className="w-3 h-3 ml-auto animate-spin text-muted" />}
|
||||
</button>
|
||||
{isDir && isExpanded && node.children?.map((child) => (
|
||||
<FileTreeNode
|
||||
key={child.path || child.name}
|
||||
node={child}
|
||||
expanded={expanded}
|
||||
loading={loading}
|
||||
selected={selected}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function detectLanguage(path: string | null): string {
|
||||
if (!path) return 'plaintext';
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith('.go')) return 'go';
|
||||
if (lower.endsWith('.ts')) return 'typescript';
|
||||
if (lower.endsWith('.tsx')) return 'typescript';
|
||||
if (lower.endsWith('.js')) return 'javascript';
|
||||
if (lower.endsWith('.jsx')) return 'javascript';
|
||||
if (lower.endsWith('.py')) return 'python';
|
||||
if (lower.endsWith('.sql')) return 'sql';
|
||||
if (lower.endsWith('.json')) return 'json';
|
||||
if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return 'yaml';
|
||||
if (lower.endsWith('.md')) return 'markdown';
|
||||
if (lower.endsWith('.sh')) return 'shell';
|
||||
return 'plaintext';
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { filterMenuItems } from '@/lib/config/menu';
|
||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
|
||||
interface SidebarProps {
|
||||
onClose?: () => void;
|
||||
@@ -65,12 +66,12 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
return (
|
||||
<motion.aside
|
||||
initial={false}
|
||||
animate={{ width: isMobile ? 240 : collapsed ? 56 : 200 }}
|
||||
animate={{ width: isMobile ? 260 : collapsed ? 60 : 220 }}
|
||||
transition={{ duration: 0.2, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="h-full flex flex-col bg-base border-r border-border/50"
|
||||
className="relative z-30 h-full flex flex-col bg-base border-r border-border/50"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="h-12 px-3 flex items-center justify-between">
|
||||
<div className="h-14 px-3 flex items-center justify-between">
|
||||
<AnimatePresence mode="wait">
|
||||
{(isMobile || !collapsed) && (
|
||||
<motion.div
|
||||
@@ -79,7 +80,7 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
>
|
||||
<span className="font-black italic text-primary tracking-tight text-xl">GooSeek</span>
|
||||
<span className="font-black italic text-primary tracking-tight text-2xl">GooSeek</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
@@ -106,7 +107,7 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-2 py-2 space-y-0.5 overflow-y-auto">
|
||||
<nav className="flex-1 px-2 py-3 space-y-1 overflow-y-auto">
|
||||
{navItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.href}
|
||||
@@ -120,9 +121,9 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
))}
|
||||
|
||||
{/* Tools Section */}
|
||||
<div className="pt-4 pb-1">
|
||||
<div className="pt-5 pb-1.5">
|
||||
{(isMobile || !collapsed) && (
|
||||
<span className="px-2 text-[10px] font-semibold text-muted uppercase tracking-wider">
|
||||
<span className="px-3 text-xs font-semibold text-muted uppercase tracking-wider">
|
||||
Инструменты
|
||||
</span>
|
||||
)}
|
||||
@@ -144,6 +145,17 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
|
||||
{/* Footer - Profile Block */}
|
||||
<div className="p-2 border-t border-border/30">
|
||||
{/* Theme toggle */}
|
||||
<div className={`${isAdmin ? '' : ''} mb-2`}>
|
||||
{collapsed && !isMobile ? (
|
||||
<div className="flex justify-center">
|
||||
<ThemeToggle variant="icon" />
|
||||
</div>
|
||||
) : (
|
||||
<ThemeToggle variant="full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<NavLink
|
||||
href="/admin"
|
||||
@@ -157,21 +169,21 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
|
||||
{/* Guest - Login button */}
|
||||
{!isAuthenticated && (
|
||||
<div className={`${isAdmin ? 'mt-1' : ''}`}>
|
||||
<div className={`${isAdmin ? 'mt-1.5' : ''}`}>
|
||||
{collapsed && !isMobile ? (
|
||||
<button
|
||||
onClick={() => showAuthModal('login')}
|
||||
className="btn-gradient w-full h-9 flex items-center justify-center"
|
||||
className="btn-gradient w-full h-10 flex items-center justify-center rounded-lg"
|
||||
>
|
||||
<LogIn className="w-4 h-4 btn-gradient-text" />
|
||||
<LogIn className="w-[18px] h-[18px] btn-gradient-text" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { showAuthModal('login'); handleNavClick(); }}
|
||||
className="btn-gradient w-full h-9 flex items-center justify-center gap-2"
|
||||
className="btn-gradient w-full h-10 flex items-center justify-center gap-2.5 rounded-lg"
|
||||
>
|
||||
<LogIn className="w-3.5 h-3.5 btn-gradient-text" />
|
||||
<span className="btn-gradient-text text-xs font-medium">Войти</span>
|
||||
<LogIn className="w-[18px] h-[18px] btn-gradient-text" />
|
||||
<span className="btn-gradient-text text-sm font-medium">Войти</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -197,7 +209,7 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-surface/50">
|
||||
<Wallet className="w-2.5 h-2.5 text-accent" />
|
||||
<span className="text-[10px] font-medium text-primary">{user.balance ?? 0}</span>
|
||||
<span className="text-2xs font-medium text-primary">{user.balance ?? 0}</span>
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
@@ -216,9 +228,9 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs font-medium text-primary truncate">{user.name}</span>
|
||||
<span className={`text-[9px] font-medium px-1 py-0.5 rounded ${
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm font-medium text-primary truncate">{user.name}</span>
|
||||
<span className={`text-2xs font-medium px-1.5 py-0.5 rounded ${
|
||||
user.tier === 'business'
|
||||
? 'bg-amber-500/20 text-amber-600'
|
||||
: user.tier === 'pro'
|
||||
@@ -229,8 +241,8 @@ export function Sidebar({ onClose }: SidebarProps) {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-0.5">
|
||||
<Wallet className="w-2.5 h-2.5 text-accent" />
|
||||
<span className="text-[10px] font-medium text-secondary">{(user.balance ?? 0).toLocaleString('ru-RU')} ₽</span>
|
||||
<Wallet className="w-3 h-3 text-accent" />
|
||||
<span className="text-xs font-medium text-secondary">{(user.balance ?? 0).toLocaleString('ru-RU')} ₽</span>
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRightIcon className="w-3.5 h-3.5 text-muted group-hover:text-secondary transition-colors" />
|
||||
@@ -258,17 +270,17 @@ function NavLink({ href, icon: Icon, label, collapsed, active, onClick }: NavLin
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
className={`
|
||||
flex items-center gap-2 h-9 rounded-lg transition-all duration-150
|
||||
${collapsed ? 'justify-center px-0' : 'px-2'}
|
||||
flex items-center gap-3 h-10 rounded-lg transition-all duration-150
|
||||
${collapsed ? 'justify-center px-0' : 'px-3'}
|
||||
${active
|
||||
? 'active-gradient text-primary border-l-gradient ml-0'
|
||||
: 'text-secondary hover:text-primary hover:bg-surface/50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 flex-shrink-0 ${active ? 'text-gradient' : ''}`} />
|
||||
<Icon className={`w-[18px] h-[18px] flex-shrink-0 ${active ? 'text-gradient' : ''}`} />
|
||||
{!collapsed && (
|
||||
<span className={`text-xs font-medium truncate ${active ? 'text-gradient' : ''}`}>{label}</span>
|
||||
<span className={`text-sm font-medium truncate ${active ? 'text-gradient' : ''}`}>{label}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
|
||||
37
backend/webui/src/components/ThemeToggle.tsx
Normal file
37
backend/webui/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from '@/lib/contexts/ThemeContext';
|
||||
|
||||
export function ThemeToggle({
|
||||
variant = 'icon',
|
||||
}: {
|
||||
variant?: 'icon' | 'full';
|
||||
}) {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
|
||||
const isDim = theme === 'dim';
|
||||
const label = isDim ? 'Тема: GitHub Dimmed' : 'Тема: GitHub Light';
|
||||
|
||||
if (variant === 'full') {
|
||||
return (
|
||||
<button onClick={toggleTheme} className="btn-secondary w-full h-10 flex items-center justify-center gap-2" aria-label={label}>
|
||||
{isDim ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4" />}
|
||||
<span className="text-sm font-medium">{isDim ? 'Dimmed' : 'Light'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="btn-icon"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
type="button"
|
||||
>
|
||||
{isDim ? <Moon className="w-[18px] h-[18px]" /> : <Sun className="w-[18px] h-[18px]" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
MapPin,
|
||||
@@ -24,8 +24,11 @@ import {
|
||||
CalendarDays,
|
||||
Flag,
|
||||
User,
|
||||
Clock,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import type { RoutePoint, RouteDirection, GeoLocation } from '@/lib/types';
|
||||
import { getRoute } from '@/lib/api';
|
||||
|
||||
interface MapglAPI {
|
||||
Map: new (container: HTMLElement | string, options: Record<string, unknown>) => MapglMapInstance;
|
||||
@@ -116,6 +119,30 @@ const pointTypeColors: Record<string, string> = {
|
||||
origin: '#10B981',
|
||||
};
|
||||
|
||||
const pointTypeLabels: Record<string, string> = {
|
||||
airport: 'Аэропорт',
|
||||
hotel: 'Отель',
|
||||
restaurant: 'Ресторан',
|
||||
attraction: 'Достопримечательность',
|
||||
transport: 'Транспорт',
|
||||
custom: 'Точка',
|
||||
museum: 'Музей',
|
||||
park: 'Парк',
|
||||
theater: 'Театр',
|
||||
shopping: 'Шопинг',
|
||||
entertainment: 'Развлечения',
|
||||
religious: 'Храм',
|
||||
viewpoint: 'Смотровая',
|
||||
event: 'Событие',
|
||||
destination: 'Пункт назначения',
|
||||
poi: 'Место',
|
||||
food: 'Еда',
|
||||
transfer: 'Пересадка',
|
||||
origin: 'Старт',
|
||||
};
|
||||
|
||||
const ITINERARY_TYPES = new Set(['destination', 'origin', 'transfer', 'airport']);
|
||||
|
||||
let mapglPromise: Promise<MapglAPI> | null = null;
|
||||
|
||||
function loadMapGL(): Promise<MapglAPI> {
|
||||
@@ -128,20 +155,46 @@ function loadMapGL(): Promise<MapglAPI> {
|
||||
return mapglPromise;
|
||||
}
|
||||
|
||||
function createMarkerSVG(index: number, color: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">` +
|
||||
`<circle cx="14" cy="14" r="13" fill="${color}" stroke="white" stroke-width="2"/>` +
|
||||
`<text x="14" y="18" text-anchor="middle" fill="white" font-size="12" font-weight="bold">${index}</text>` +
|
||||
function createNumberedPinMarkerSVG(index: number, color: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="26" height="34" viewBox="0 0 26 34">` +
|
||||
`<defs>` +
|
||||
`<filter id="is${index}" x="-15%" y="-8%" width="130%" height="130%">` +
|
||||
`<feDropShadow dx="0" dy="1" stdDeviation="1" flood-color="#000" flood-opacity="0.2"/>` +
|
||||
`</filter>` +
|
||||
`</defs>` +
|
||||
`<path d="M13 32 C13 32 24 20 24 12 C24 6.5 19.1 2 13 2 C6.9 2 2 6.5 2 12 C2 20 13 32 13 32Z" fill="${color}" filter="url(#is${index})" stroke="white" stroke-width="1.5"/>` +
|
||||
`<circle cx="13" cy="12" r="7" fill="white" fill-opacity="0.95"/>` +
|
||||
`<text x="13" y="15.5" text-anchor="middle" fill="${color}" font-size="10" font-weight="700" font-family="Inter,system-ui,sans-serif">${index}</text>` +
|
||||
`</svg>`;
|
||||
}
|
||||
|
||||
function createUserLocationSVG(): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">` +
|
||||
`<circle cx="16" cy="16" r="14" fill="#3B82F6" fill-opacity="0.2" stroke="#3B82F6" stroke-width="2"/>` +
|
||||
`<circle cx="16" cy="16" r="6" fill="#3B82F6" stroke="white" stroke-width="2"/>` +
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 28 28">` +
|
||||
`<circle cx="14" cy="14" r="12" fill="#3B82F6" fill-opacity="0.15" stroke="#3B82F6" stroke-width="1.5"/>` +
|
||||
`<circle cx="14" cy="14" r="5" fill="#3B82F6" stroke="white" stroke-width="1.5"/>` +
|
||||
`</svg>`;
|
||||
}
|
||||
|
||||
function getCategoryAbbr(type: string): string {
|
||||
const map: Record<string, string> = {
|
||||
hotel: 'H',
|
||||
restaurant: 'R',
|
||||
food: 'R',
|
||||
attraction: 'A',
|
||||
museum: 'M',
|
||||
park: 'P',
|
||||
theater: 'T',
|
||||
shopping: 'S',
|
||||
entertainment: 'E',
|
||||
religious: 'C',
|
||||
viewpoint: 'V',
|
||||
event: 'Ev',
|
||||
poi: 'P',
|
||||
custom: '?',
|
||||
};
|
||||
return map[type] || type.slice(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
export function TravelMap({
|
||||
route,
|
||||
routeDirection,
|
||||
@@ -158,12 +211,16 @@ export function TravelMap({
|
||||
const mapglRef = useRef<MapglAPI | null>(null);
|
||||
const markersRef = useRef<MapglMarkerInstance[]>([]);
|
||||
const userMarkerRef = useRef<MapglMarkerInstance | null>(null);
|
||||
const polylineRef = useRef<MapglPolylineInstance | null>(null);
|
||||
const polylinesRef = useRef<MapglPolylineInstance[]>([]);
|
||||
const onMapClickRef = useRef(onMapClick);
|
||||
const onPointClickRef = useRef(onPointClick);
|
||||
const [selectedPoint, setSelectedPoint] = useState<RoutePoint | null>(null);
|
||||
const [isMapReady, setIsMapReady] = useState(false);
|
||||
const [detectedLocation, setDetectedLocation] = useState<GeoLocation | null>(null);
|
||||
const [showLegend, setShowLegend] = useState(false);
|
||||
const [routeType, setRouteType] = useState<'road' | 'straight' | 'none'>('none');
|
||||
const [fallbackDirection, setFallbackDirection] = useState<RouteDirection | null>(null);
|
||||
const fallbackRequestRef = useRef<string>('');
|
||||
const initDoneRef = useRef(false);
|
||||
|
||||
onMapClickRef.current = onMapClick;
|
||||
@@ -171,6 +228,24 @@ export function TravelMap({
|
||||
|
||||
const effectiveUserLocation = userLocation ?? detectedLocation;
|
||||
|
||||
const activeTypes = useMemo(() => {
|
||||
const types = new Set<string>();
|
||||
route.forEach((p) => types.add(p.type));
|
||||
return types;
|
||||
}, [route]);
|
||||
|
||||
const pointNumberById = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
let idx = 0;
|
||||
route.forEach((p) => {
|
||||
if (!p.id) return;
|
||||
if (!p.lat || !p.lng || p.lat === 0 || p.lng === 0) return;
|
||||
idx += 1;
|
||||
map.set(p.id, idx);
|
||||
});
|
||||
return map;
|
||||
}, [route]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || initDoneRef.current) return;
|
||||
initDoneRef.current = true;
|
||||
@@ -190,6 +265,9 @@ export function TravelMap({
|
||||
zoom,
|
||||
key: TWOGIS_API_KEY,
|
||||
lang: 'ru',
|
||||
// Hide built-in MapGL controls (we render our own UI controls).
|
||||
zoomControl: false,
|
||||
geolocationControl: false,
|
||||
});
|
||||
|
||||
map.on('click', (e: MapglClickEvent) => {
|
||||
@@ -214,10 +292,8 @@ export function TravelMap({
|
||||
userMarkerRef.current.destroy();
|
||||
userMarkerRef.current = null;
|
||||
}
|
||||
if (polylineRef.current) {
|
||||
polylineRef.current.destroy();
|
||||
polylineRef.current = null;
|
||||
}
|
||||
polylinesRef.current.forEach((p) => p.destroy());
|
||||
polylinesRef.current = [];
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.destroy();
|
||||
mapInstanceRef.current = null;
|
||||
@@ -244,9 +320,7 @@ export function TravelMap({
|
||||
mapInstanceRef.current.setZoom(12);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
// geolocation denied or unavailable
|
||||
},
|
||||
() => {},
|
||||
{ enableHighAccuracy: false, timeout: 10000, maximumAge: 300000 },
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -264,6 +338,41 @@ export function TravelMap({
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const effectiveRouteDirection = routeDirection || fallbackDirection;
|
||||
|
||||
useEffect(() => {
|
||||
if (routeDirection) {
|
||||
setFallbackDirection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const validPoints = route.filter((p) => p.lat !== 0 && p.lng !== 0 && p.lat && p.lng);
|
||||
if (validPoints.length < 2) {
|
||||
setFallbackDirection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const key = validPoints.map((p) => `${p.lat.toFixed(5)},${p.lng.toFixed(5)}`).join('|');
|
||||
if (fallbackRequestRef.current === key) return;
|
||||
fallbackRequestRef.current = key;
|
||||
|
||||
const geoPoints: GeoLocation[] = validPoints.map((p) => ({
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
name: p.name,
|
||||
}));
|
||||
|
||||
getRoute(geoPoints).then((rd) => {
|
||||
if (fallbackRequestRef.current === key) {
|
||||
setFallbackDirection(rd);
|
||||
}
|
||||
}).catch(() => {
|
||||
if (fallbackRequestRef.current === key) {
|
||||
setFallbackDirection(null);
|
||||
}
|
||||
});
|
||||
}, [route, routeDirection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMapReady || !mapInstanceRef.current || !mapglRef.current) return;
|
||||
|
||||
@@ -273,10 +382,8 @@ export function TravelMap({
|
||||
markersRef.current.forEach((m) => m.destroy());
|
||||
markersRef.current = [];
|
||||
|
||||
if (polylineRef.current) {
|
||||
polylineRef.current.destroy();
|
||||
polylineRef.current = null;
|
||||
}
|
||||
polylinesRef.current.forEach((p) => p.destroy());
|
||||
polylinesRef.current = [];
|
||||
|
||||
if (userMarkerRef.current) {
|
||||
userMarkerRef.current.destroy();
|
||||
@@ -289,11 +396,11 @@ export function TravelMap({
|
||||
coordinates: [effectiveUserLocation.lng, effectiveUserLocation.lat],
|
||||
label: {
|
||||
text: '',
|
||||
offset: [0, -48],
|
||||
offset: [0, -40],
|
||||
image: {
|
||||
url: `data:image/svg+xml,${encodeURIComponent(createUserLocationSVG())}`,
|
||||
size: [32, 32],
|
||||
anchor: [16, 16],
|
||||
size: [28, 28],
|
||||
anchor: [14, 14],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -311,21 +418,27 @@ export function TravelMap({
|
||||
return;
|
||||
}
|
||||
|
||||
route.forEach((point, index) => {
|
||||
let pointIndex = 0;
|
||||
route.forEach((point) => {
|
||||
if (!point.lat || !point.lng || point.lat === 0 || point.lng === 0) return;
|
||||
|
||||
pointIndex++;
|
||||
const color = pointTypeColors[point.type] || pointTypeColors.custom || '#EC4899';
|
||||
const isItinerary = ITINERARY_TYPES.has(point.type);
|
||||
const svgUrl = `data:image/svg+xml,${encodeURIComponent(createNumberedPinMarkerSVG(pointIndex, color))}`;
|
||||
const markerSize: [number, number] = [26, 34];
|
||||
const markerAnchor: [number, number] = [13, 32];
|
||||
|
||||
try {
|
||||
const marker = new mapgl.Marker(map, {
|
||||
coordinates: [point.lng, point.lat],
|
||||
label: {
|
||||
text: String(index + 1),
|
||||
offset: [0, -48],
|
||||
text: '',
|
||||
offset: [0, isItinerary ? -48 : -48],
|
||||
image: {
|
||||
url: `data:image/svg+xml,${encodeURIComponent(createMarkerSVG(index + 1, color))}`,
|
||||
size: [28, 28],
|
||||
anchor: [14, 14],
|
||||
url: svgUrl,
|
||||
size: markerSize,
|
||||
anchor: markerAnchor,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -341,35 +454,61 @@ export function TravelMap({
|
||||
}
|
||||
});
|
||||
|
||||
const rdCoords = routeDirection?.geometry?.coordinates;
|
||||
const rdCoords = effectiveRouteDirection?.geometry?.coordinates;
|
||||
if (rdCoords && Array.isArray(rdCoords) && rdCoords.length > 1) {
|
||||
setRouteType('road');
|
||||
const coords = rdCoords.map(
|
||||
(c: number[]) => [c[0], c[1]] as [number, number]
|
||||
);
|
||||
try {
|
||||
polylineRef.current = new mapgl.Polyline(map, {
|
||||
const outlinePoly = new mapgl.Polyline(map, {
|
||||
coordinates: coords,
|
||||
color: '#6366F1',
|
||||
color: '#ffffff',
|
||||
width: 7,
|
||||
});
|
||||
polylinesRef.current.push(outlinePoly);
|
||||
} catch {
|
||||
// outline polyline failed
|
||||
}
|
||||
try {
|
||||
const mainPoly = new mapgl.Polyline(map, {
|
||||
coordinates: coords,
|
||||
color: '#4F5BD5',
|
||||
width: 4,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[TravelMap] road polyline failed:', err, 'coords sample:', coords.slice(0, 3));
|
||||
polylinesRef.current.push(mainPoly);
|
||||
} catch {
|
||||
// main polyline failed
|
||||
}
|
||||
} else if (route.length > 1) {
|
||||
setRouteType('straight');
|
||||
const coords = route
|
||||
.filter((p) => p.lat !== 0 && p.lng !== 0)
|
||||
.map((p) => [p.lng, p.lat] as [number, number]);
|
||||
if (coords.length > 1) {
|
||||
try {
|
||||
polylineRef.current = new mapgl.Polyline(map, {
|
||||
const outlinePoly = new mapgl.Polyline(map, {
|
||||
coordinates: coords,
|
||||
color: '#6366F1',
|
||||
color: '#ffffff',
|
||||
width: 6,
|
||||
});
|
||||
polylinesRef.current.push(outlinePoly);
|
||||
} catch {
|
||||
// outline polyline failed
|
||||
}
|
||||
try {
|
||||
const mainPoly = new mapgl.Polyline(map, {
|
||||
coordinates: coords,
|
||||
color: '#94A3B8',
|
||||
width: 3,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[TravelMap] fallback polyline failed:', err);
|
||||
polylinesRef.current.push(mainPoly);
|
||||
} catch {
|
||||
// main polyline failed
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setRouteType('none');
|
||||
}
|
||||
|
||||
const allPoints: { lat: number; lng: number }[] = route
|
||||
@@ -398,13 +537,13 @@ export function TravelMap({
|
||||
} else {
|
||||
map.fitBounds(
|
||||
{
|
||||
southWest: [minLng - lngSpan * 0.1, minLat - latSpan * 0.1],
|
||||
northEast: [maxLng + lngSpan * 0.1, maxLat + latSpan * 0.1],
|
||||
southWest: [minLng - lngSpan * 0.12, minLat - latSpan * 0.12],
|
||||
northEast: [maxLng + lngSpan * 0.12, maxLat + latSpan * 0.12],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [isMapReady, route, routeDirection, effectiveUserLocation]);
|
||||
}, [isMapReady, route, effectiveRouteDirection, effectiveUserLocation]);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
const map = mapInstanceRef.current;
|
||||
@@ -469,8 +608,8 @@ export function TravelMap({
|
||||
mapInstanceRef.current.setZoom(14);
|
||||
} else {
|
||||
mapInstanceRef.current.fitBounds({
|
||||
southWest: [minLng - lngSpan * 0.1, minLat - latSpan * 0.1],
|
||||
northEast: [maxLng + lngSpan * 0.1, maxLat + latSpan * 0.1],
|
||||
southWest: [minLng - lngSpan * 0.12, minLat - latSpan * 0.12],
|
||||
northEast: [maxLng + lngSpan * 0.12, maxLat + latSpan * 0.12],
|
||||
});
|
||||
}
|
||||
}, [route, effectiveUserLocation]);
|
||||
@@ -479,90 +618,187 @@ export function TravelMap({
|
||||
? pointTypeIcons[selectedPoint.type] || MapPin
|
||||
: MapPin;
|
||||
|
||||
const selectedPointIndex = useMemo(() => {
|
||||
if (!selectedPoint) return 0;
|
||||
if (!selectedPoint.id) return 0;
|
||||
return pointNumberById.get(selectedPoint.id) || 0;
|
||||
}, [selectedPoint, pointNumberById]);
|
||||
|
||||
return (
|
||||
<div className={`relative rounded-xl overflow-hidden ${className}`}>
|
||||
<div ref={mapRef} className="w-full h-full min-h-[300px]" />
|
||||
|
||||
{showControls && (
|
||||
<div className="absolute top-4 right-4 flex flex-col gap-2 z-[1000]">
|
||||
<div className="absolute top-3 right-3 flex flex-col gap-1.5 z-30">
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
className="w-8 h-8 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
title="Увеличить"
|
||||
>
|
||||
<ZoomIn className="w-5 h-5" />
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
className="w-8 h-8 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
title="Уменьшить"
|
||||
>
|
||||
<ZoomOut className="w-5 h-5" />
|
||||
<ZoomOut className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLocate}
|
||||
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
className="w-8 h-8 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
title="Моё местоположение"
|
||||
>
|
||||
<Locate className="w-5 h-5" />
|
||||
<Locate className="w-4 h-4" />
|
||||
</button>
|
||||
{route.length > 1 && (
|
||||
<button
|
||||
onClick={handleFitRoute}
|
||||
className="w-10 h-10 bg-elevated/90 backdrop-blur-sm border border-border/50 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
className="w-8 h-8 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg flex items-center justify-center text-secondary hover:text-primary hover:bg-surface transition-all"
|
||||
title="Показать весь маршрут"
|
||||
>
|
||||
<Navigation className="w-5 h-5" />
|
||||
<Navigation className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{activeTypes.size > 1 && (
|
||||
<button
|
||||
onClick={() => setShowLegend((v) => !v)}
|
||||
className={`w-8 h-8 backdrop-blur-sm border rounded-lg flex items-center justify-center transition-all ${
|
||||
showLegend
|
||||
? 'bg-accent/20 border-accent/40 text-accent'
|
||||
: 'bg-elevated/90 border-border/40 text-secondary hover:text-primary hover:bg-surface'
|
||||
}`}
|
||||
title="Легенда"
|
||||
>
|
||||
<Layers className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{route.length > 0 && (
|
||||
<div className="absolute top-3 left-3 z-30 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-1.5 bg-elevated/90 backdrop-blur-sm border border-border/40 rounded-lg">
|
||||
<MapPin className="w-3 h-3 text-accent" />
|
||||
<span className="text-[11px] font-medium text-primary">{route.length}</span>
|
||||
<span className="text-[10px] text-muted">
|
||||
{route.length === 1 ? 'точка' : route.length < 5 ? 'точки' : 'точек'}
|
||||
</span>
|
||||
</div>
|
||||
{routeType === 'road' && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-green-500/10 backdrop-blur-sm border border-green-500/30 rounded-lg">
|
||||
<Navigation className="w-2.5 h-2.5 text-green-500" />
|
||||
<span className="text-[9px] text-green-500 font-medium">По дорогам</span>
|
||||
</div>
|
||||
)}
|
||||
{routeType === 'straight' && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-yellow-500/10 backdrop-blur-sm border border-yellow-500/30 rounded-lg">
|
||||
<Navigation className="w-2.5 h-2.5 text-yellow-500" />
|
||||
<span className="text-[9px] text-yellow-500 font-medium">Прямые линии</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<AnimatePresence>
|
||||
{showLegend && activeTypes.size > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 10 }}
|
||||
className="absolute top-14 right-3 z-30 bg-elevated/95 backdrop-blur-sm border border-border/40 rounded-lg p-2 min-w-[120px]"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{Array.from(activeTypes).map((type) => (
|
||||
<div key={type} className="flex items-center gap-2 px-1 py-0.5">
|
||||
<div
|
||||
className="w-2.5 h-2.5 rounded-sm flex-shrink-0"
|
||||
style={{ backgroundColor: pointTypeColors[type] || '#94A3B8' }}
|
||||
/>
|
||||
<span className="text-[10px] text-secondary leading-none">
|
||||
{pointTypeLabels[type] || type}
|
||||
</span>
|
||||
<span className="text-[9px] text-muted ml-auto">
|
||||
{route.filter((p) => p.type === type).length}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Selected point popup */}
|
||||
<AnimatePresence>
|
||||
{selectedPoint && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
className="absolute bottom-4 left-4 right-4 bg-elevated/95 backdrop-blur-sm border border-border/50 rounded-xl p-4 z-[1000]"
|
||||
exit={{ opacity: 0, y: 16 }}
|
||||
className="absolute bottom-3 left-3 right-3 bg-elevated/95 backdrop-blur-sm border border-border/40 rounded-xl overflow-hidden z-30"
|
||||
>
|
||||
<button
|
||||
onClick={() => setSelectedPoint(null)}
|
||||
className="absolute top-3 right-3 p-1 rounded-lg hover:bg-surface/50 text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
||||
style={{ backgroundColor: pointTypeColors[selectedPoint.type] + '20' }}
|
||||
<div className="p-3">
|
||||
<button
|
||||
onClick={() => setSelectedPoint(null)}
|
||||
className="absolute top-2.5 right-2.5 p-1 rounded-md hover:bg-surface/50 text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<PointIcon
|
||||
className="w-5 h-5"
|
||||
style={{ color: pointTypeColors[selectedPoint.type] }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="text-sm font-medium text-primary truncate">
|
||||
{selectedPoint.name}
|
||||
</h4>
|
||||
{selectedPoint.address && (
|
||||
<p className="text-xs text-muted mt-0.5 truncate">
|
||||
{selectedPoint.address}
|
||||
</p>
|
||||
)}
|
||||
{selectedPoint.aiComment && (
|
||||
<div className="flex items-start gap-2 mt-2 p-2 bg-surface/50 rounded-lg">
|
||||
<Sparkles className="w-4 h-4 text-accent flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-secondary">{selectedPoint.aiComment}</p>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-start gap-2.5 pr-6">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||
style={{ backgroundColor: pointTypeColors[selectedPoint.type] + '18' }}
|
||||
>
|
||||
<PointIcon
|
||||
className="w-4 h-4"
|
||||
style={{ color: pointTypeColors[selectedPoint.type] }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedPointIndex > 0 && (
|
||||
<span
|
||||
className="w-4.5 h-4.5 rounded text-[9px] font-bold flex items-center justify-center text-white px-1"
|
||||
style={{ backgroundColor: pointTypeColors[selectedPoint.type] }}
|
||||
>
|
||||
{selectedPointIndex}
|
||||
</span>
|
||||
)}
|
||||
<h4 className="text-[13px] font-medium text-primary truncate">
|
||||
{selectedPoint.name}
|
||||
</h4>
|
||||
</div>
|
||||
)}
|
||||
{selectedPoint.duration && (
|
||||
<p className="text-xs text-muted mt-2">
|
||||
Рекомендуемое время: {selectedPoint.duration} мин
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
<span
|
||||
className="text-[9px] font-medium px-1.5 py-px rounded-full"
|
||||
style={{
|
||||
backgroundColor: pointTypeColors[selectedPoint.type] + '15',
|
||||
color: pointTypeColors[selectedPoint.type],
|
||||
}}
|
||||
>
|
||||
{pointTypeLabels[selectedPoint.type] || selectedPoint.type}
|
||||
</span>
|
||||
{selectedPoint.address && (
|
||||
<span className="text-[10px] text-muted truncate max-w-[180px]">
|
||||
{selectedPoint.address}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedPoint.aiComment && (
|
||||
<div className="flex items-start gap-1.5 mt-2 p-2 bg-accent/5 border border-accent/10 rounded-lg">
|
||||
<Sparkles className="w-3 h-3 text-accent flex-shrink-0 mt-0.5" />
|
||||
<p className="text-[10px] text-secondary leading-relaxed">{selectedPoint.aiComment}</p>
|
||||
</div>
|
||||
)}
|
||||
{selectedPoint.duration && selectedPoint.duration > 0 && (
|
||||
<div className="flex items-center gap-1 mt-1.5">
|
||||
<Clock className="w-3 h-3 text-muted" />
|
||||
<span className="text-[10px] text-muted">~{selectedPoint.duration} мин</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
649
backend/webui/src/components/TravelWidgetTabs.tsx
Normal file
649
backend/webui/src/components/TravelWidgetTabs.tsx
Normal file
@@ -0,0 +1,649 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Calendar,
|
||||
Ticket,
|
||||
Hotel,
|
||||
Plane,
|
||||
Bus,
|
||||
DollarSign,
|
||||
CloudSun,
|
||||
Camera,
|
||||
Search,
|
||||
Filter,
|
||||
Sparkles,
|
||||
Check,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { TravelWidgetRenderer } from '@/components/TravelWidgets';
|
||||
import type { TravelWidget } from '@/lib/hooks/useTravelChat';
|
||||
import type {
|
||||
EventCard,
|
||||
POICard,
|
||||
HotelCard,
|
||||
TransportOption,
|
||||
ItineraryDay,
|
||||
} from '@/lib/types';
|
||||
import type { LLMValidationResponse } from '@/lib/hooks/useEditableItinerary';
|
||||
|
||||
type TabId = 'plan' | 'places' | 'events' | 'hotels' | 'tickets' | 'transport' | 'budget' | 'context';
|
||||
|
||||
interface TabDef {
|
||||
id: TabId;
|
||||
label: string;
|
||||
icon: typeof Calendar;
|
||||
widgetTypes: string[];
|
||||
emptyLabel: string;
|
||||
}
|
||||
|
||||
const TABS: TabDef[] = [
|
||||
{ id: 'plan', label: 'План', icon: Calendar, widgetTypes: ['travel_itinerary'], emptyLabel: 'Маршрут ещё не построен' },
|
||||
{ id: 'places', label: 'Места', icon: Camera, widgetTypes: ['travel_poi'], emptyLabel: 'Достопримечательности не найдены' },
|
||||
{ id: 'events', label: 'События', icon: Ticket, widgetTypes: ['travel_events'], emptyLabel: 'Мероприятия не найдены' },
|
||||
{ id: 'hotels', label: 'Отели', icon: Hotel, widgetTypes: ['travel_hotels'], emptyLabel: 'Отели не найдены' },
|
||||
{ id: 'tickets', label: 'Билеты', icon: Plane, widgetTypes: ['travel_transport'], emptyLabel: 'Билеты не найдены' },
|
||||
{ id: 'transport', label: 'Транспорт', icon: Bus, widgetTypes: ['travel_transport'], emptyLabel: 'Транспорт не найден' },
|
||||
{ id: 'budget', label: 'Бюджет', icon: DollarSign, widgetTypes: ['travel_budget'], emptyLabel: 'Бюджет не рассчитан' },
|
||||
{ id: 'context', label: 'Инфо', icon: CloudSun, widgetTypes: ['travel_context'], emptyLabel: 'Информация недоступна' },
|
||||
];
|
||||
|
||||
type LoadingPhase = 'idle' | 'planning' | 'collecting' | 'building' | 'routing';
|
||||
|
||||
interface TravelWidgetTabsProps {
|
||||
widgets: TravelWidget[];
|
||||
onAddEventToMap?: (event: EventCard) => void;
|
||||
onAddPOIToMap?: (poi: POICard) => void;
|
||||
onSelectHotel?: (hotel: HotelCard) => void;
|
||||
onSelectTransport?: (option: TransportOption) => void;
|
||||
onClarifyingAnswer?: (field: string, value: string) => void;
|
||||
onAction?: (kind: string) => void;
|
||||
selectedEventIds?: Set<string>;
|
||||
selectedPOIIds?: Set<string>;
|
||||
selectedHotelId?: string;
|
||||
selectedTransportId?: string;
|
||||
availablePois?: POICard[];
|
||||
availableEvents?: EventCard[];
|
||||
onItineraryUpdate?: (days: ItineraryDay[]) => void;
|
||||
onValidateItineraryWithLLM?: (days: ItineraryDay[]) => Promise<LLMValidationResponse | null>;
|
||||
isLoading?: boolean;
|
||||
loadingPhase?: LoadingPhase;
|
||||
isResearching?: boolean;
|
||||
routePointCount?: number;
|
||||
hasRouteDirection?: boolean;
|
||||
}
|
||||
|
||||
function normalizeText(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function includesAny(haystack: string, needles: string[]): boolean {
|
||||
const h = normalizeText(haystack);
|
||||
if (!h) return false;
|
||||
return needles.some((n) => h.includes(n));
|
||||
}
|
||||
|
||||
function getEventSearchText(e: EventCard): string {
|
||||
return [
|
||||
e.title,
|
||||
e.description,
|
||||
e.address,
|
||||
e.source,
|
||||
...(e.tags || []),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function getPOISearchText(p: POICard): string {
|
||||
return [p.name, p.description, p.address, p.category].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function getHotelSearchText(h: HotelCard): string {
|
||||
return [h.name, h.address, ...(h.amenities || [])].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function getTransportSearchText(t: TransportOption): string {
|
||||
return [
|
||||
t.mode,
|
||||
t.from,
|
||||
t.to,
|
||||
t.provider,
|
||||
t.airline,
|
||||
t.flightNum,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function TabToolbar({
|
||||
tab,
|
||||
query,
|
||||
setQuery,
|
||||
onlySelected,
|
||||
setOnlySelected,
|
||||
minRating,
|
||||
setMinRating,
|
||||
filtersOpen,
|
||||
setFiltersOpen,
|
||||
isLoading,
|
||||
onAskMore,
|
||||
showAskMore,
|
||||
}: {
|
||||
tab: TabId;
|
||||
query: string;
|
||||
setQuery: (value: string) => void;
|
||||
onlySelected: boolean;
|
||||
setOnlySelected: (value: boolean) => void;
|
||||
minRating: number;
|
||||
setMinRating: (value: number) => void;
|
||||
filtersOpen: boolean;
|
||||
setFiltersOpen: (value: boolean) => void;
|
||||
isLoading?: boolean;
|
||||
onAskMore?: () => void;
|
||||
showAskMore?: boolean;
|
||||
}) {
|
||||
const placeholder =
|
||||
tab === 'plan'
|
||||
? 'Поиск по маршруту...'
|
||||
: tab === 'places'
|
||||
? 'Поиск по местам...'
|
||||
: tab === 'events'
|
||||
? 'Поиск по событиям...'
|
||||
: tab === 'hotels'
|
||||
? 'Поиск по отелям...'
|
||||
: tab === 'tickets'
|
||||
? 'Поиск по билетам...'
|
||||
: tab === 'transport'
|
||||
? 'Поиск по транспорту...'
|
||||
: tab === 'budget'
|
||||
? 'Поиск по бюджету...'
|
||||
: 'Поиск...';
|
||||
|
||||
const showRating = tab === 'places' || tab === 'hotels';
|
||||
const showSelected = tab === 'places' || tab === 'events' || tab === 'hotels' || tab === 'tickets' || tab === 'transport';
|
||||
const filterCount = (showSelected && onlySelected ? 1 : 0) + (showRating && minRating > 0 ? 1 : 0);
|
||||
const shouldShowFilterPanel = filtersOpen || filterCount > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="w-3.5 h-3.5 text-muted absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full pl-8 pr-3 py-2 text-xs bg-surface/40 border border-border/30 rounded-lg text-primary placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
</div>
|
||||
{onAskMore && showAskMore && (
|
||||
<button
|
||||
onClick={onAskMore}
|
||||
disabled={isLoading}
|
||||
className={`w-9 h-9 flex items-center justify-center rounded-lg border transition-colors ${
|
||||
isLoading
|
||||
? 'bg-surface/30 text-muted border-border/20'
|
||||
: 'bg-accent/15 text-accent border-accent/25 hover:bg-accent/20'
|
||||
}`}
|
||||
title="Попросить AI найти ещё варианты"
|
||||
aria-label="Найти ещё"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setFiltersOpen(!filtersOpen)}
|
||||
className={`w-9 h-9 flex items-center justify-center rounded-lg border transition-colors ${
|
||||
shouldShowFilterPanel
|
||||
? 'bg-surface/50 text-secondary border-border/40'
|
||||
: 'bg-surface/30 text-muted border-border/25 hover:text-secondary'
|
||||
}`}
|
||||
title="Фильтры"
|
||||
aria-label="Фильтры"
|
||||
>
|
||||
<div className="relative">
|
||||
<Filter className="w-4 h-4" />
|
||||
{filterCount > 0 && (
|
||||
<span className="absolute -top-1.5 -right-1.5 min-w-[14px] h-[14px] px-1 rounded-full bg-accent text-3xs leading-[14px] text-black text-center">
|
||||
{filterCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{shouldShowFilterPanel && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{showSelected && (
|
||||
<button
|
||||
onClick={() => setOnlySelected(!onlySelected)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1 text-ui-sm rounded-lg border transition-colors ${
|
||||
onlySelected
|
||||
? 'bg-accent/20 text-accent border-accent/30'
|
||||
: 'bg-surface/30 text-secondary border-border/30 hover:text-primary'
|
||||
}`}
|
||||
title="Показывать только выбранные"
|
||||
>
|
||||
{onlySelected && <Check className="w-3.5 h-3.5" />}
|
||||
Выбранные
|
||||
</button>
|
||||
)}
|
||||
{showRating && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-ui-sm text-muted">Рейтинг</span>
|
||||
<select
|
||||
value={minRating}
|
||||
onChange={(e) => setMinRating(Number(e.target.value))}
|
||||
className="px-2 py-1 text-ui-sm bg-surface/30 border border-border/30 rounded-lg text-secondary focus:outline-none"
|
||||
aria-label="Минимальный рейтинг"
|
||||
>
|
||||
<option value={0}>Любой</option>
|
||||
<option value={4}>4.0+</option>
|
||||
<option value={4.5}>4.5+</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{!showSelected && !showRating && (
|
||||
<span className="text-ui-sm text-muted/80">Нет фильтров для этого этапа</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TravelWidgetTabs({
|
||||
widgets,
|
||||
onAddEventToMap,
|
||||
onAddPOIToMap,
|
||||
onSelectHotel,
|
||||
onSelectTransport,
|
||||
onClarifyingAnswer,
|
||||
onAction,
|
||||
selectedEventIds = new Set(),
|
||||
selectedPOIIds = new Set(),
|
||||
selectedHotelId,
|
||||
selectedTransportId,
|
||||
availablePois,
|
||||
availableEvents,
|
||||
onItineraryUpdate,
|
||||
onValidateItineraryWithLLM,
|
||||
isLoading,
|
||||
loadingPhase = 'idle',
|
||||
isResearching,
|
||||
routePointCount = 0,
|
||||
hasRouteDirection,
|
||||
}: TravelWidgetTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabId>('plan');
|
||||
const [inlineNotice, setInlineNotice] = useState<string | null>(null);
|
||||
const [queryByTab, setQueryByTab] = useState<Record<TabId, string>>({
|
||||
plan: '',
|
||||
places: '',
|
||||
events: '',
|
||||
hotels: '',
|
||||
tickets: '',
|
||||
transport: '',
|
||||
budget: '',
|
||||
context: '',
|
||||
});
|
||||
const [onlySelectedByTab, setOnlySelectedByTab] = useState<Record<TabId, boolean>>({
|
||||
plan: false,
|
||||
places: false,
|
||||
events: false,
|
||||
hotels: false,
|
||||
tickets: false,
|
||||
transport: false,
|
||||
budget: false,
|
||||
context: false,
|
||||
});
|
||||
const [minRatingByTab, setMinRatingByTab] = useState<Record<TabId, number>>({
|
||||
plan: 0,
|
||||
places: 0,
|
||||
events: 0,
|
||||
hotels: 0,
|
||||
tickets: 0,
|
||||
transport: 0,
|
||||
budget: 0,
|
||||
context: 0,
|
||||
});
|
||||
const [filtersOpenByTab, setFiltersOpenByTab] = useState<Record<TabId, boolean>>({
|
||||
plan: false,
|
||||
places: false,
|
||||
events: false,
|
||||
hotels: false,
|
||||
tickets: false,
|
||||
transport: false,
|
||||
budget: false,
|
||||
context: false,
|
||||
});
|
||||
|
||||
const travelWidgets = useMemo(
|
||||
() => widgets.filter((w) => w.type.startsWith('travel_')),
|
||||
[widgets],
|
||||
);
|
||||
|
||||
const widgetsByTab = useMemo(() => {
|
||||
const map = new Map<TabId, TravelWidget[]>();
|
||||
for (const tab of TABS) {
|
||||
map.set(tab.id, []);
|
||||
}
|
||||
for (const w of travelWidgets) {
|
||||
for (const tab of TABS) {
|
||||
if (tab.widgetTypes.includes(w.type)) {
|
||||
map.get(tab.id)!.push(w);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [travelWidgets]);
|
||||
|
||||
const tabCounts = useMemo(() => {
|
||||
const counts = new Map<TabId, number>();
|
||||
for (const tab of TABS) {
|
||||
const tabWidgets = widgetsByTab.get(tab.id) || [];
|
||||
let count = 0;
|
||||
for (const w of tabWidgets) {
|
||||
const p = w.params;
|
||||
if (p.days) count += (p.days as ItineraryDay[]).length;
|
||||
else if (p.pois) count += (p.pois as POICard[]).length;
|
||||
else if (p.events) count += (p.events as EventCard[]).length;
|
||||
else if (p.hotels) count += (p.hotels as HotelCard[]).length;
|
||||
else if (p.flights || p.ground) {
|
||||
const flightsCount = ((p.flights as TransportOption[]) || []).length;
|
||||
const groundCount = ((p.ground as TransportOption[]) || []).length;
|
||||
if (tab.id === 'tickets') count += flightsCount;
|
||||
else if (tab.id === 'transport') count += groundCount;
|
||||
else count += flightsCount + groundCount;
|
||||
}
|
||||
else if (p.breakdown) count = 1;
|
||||
else if (p.weather || p.safety) count = 1;
|
||||
}
|
||||
counts.set(tab.id, count);
|
||||
}
|
||||
return counts;
|
||||
}, [widgetsByTab]);
|
||||
|
||||
const activeWidgets = widgetsByTab.get(activeTab) || [];
|
||||
const activeTabDef = TABS.find((t) => t.id === activeTab)!;
|
||||
|
||||
const clarifyingWidgets = useMemo(
|
||||
() => travelWidgets.filter((w) => w.type === 'travel_clarifying'),
|
||||
[travelWidgets],
|
||||
);
|
||||
|
||||
const clarifyingKey = useMemo(() => clarifyingWidgets.map((w) => w.id).join('|'), [clarifyingWidgets]);
|
||||
|
||||
useEffect(() => {
|
||||
// If clarifying widget changes, show it again and clear old notice
|
||||
setInlineNotice(null);
|
||||
}, [clarifyingKey]);
|
||||
|
||||
useEffect(() => {
|
||||
// Keep the "submitted" notice only until planning really starts.
|
||||
if (!inlineNotice) return;
|
||||
if (!isLoading) return;
|
||||
if (loadingPhase !== 'planning') {
|
||||
setInlineNotice(null);
|
||||
}
|
||||
}, [inlineNotice, isLoading, loadingPhase]);
|
||||
|
||||
const handleTabClick = useCallback((tabId: TabId) => {
|
||||
setActiveTab(tabId);
|
||||
}, []);
|
||||
|
||||
const query = queryByTab[activeTab] || '';
|
||||
const queryTokens = useMemo(() => normalizeText(query).split(/\s+/).filter(Boolean), [query]);
|
||||
const onlySelected = onlySelectedByTab[activeTab] || false;
|
||||
const minRating = minRatingByTab[activeTab] || 0;
|
||||
|
||||
const filteredActiveWidgets = useMemo(() => {
|
||||
const widgetsForTab = activeWidgets;
|
||||
if (widgetsForTab.length === 0) return [];
|
||||
|
||||
const filtered: TravelWidget[] = [];
|
||||
for (const w of widgetsForTab) {
|
||||
if (activeTab === 'places' && w.type === 'travel_poi') {
|
||||
const pois = (w.params.pois || []) as POICard[];
|
||||
const list = pois
|
||||
.filter((p) => (minRating > 0 ? (p.rating || 0) >= minRating : true))
|
||||
.filter((p) => (onlySelected ? selectedPOIIds.has(p.id) : true))
|
||||
.filter((p) => (queryTokens.length ? includesAny(getPOISearchText(p), queryTokens) : true));
|
||||
if (list.length > 0) filtered.push({ ...w, params: { ...w.params, pois: list } });
|
||||
continue;
|
||||
}
|
||||
if (activeTab === 'events' && w.type === 'travel_events') {
|
||||
const events = (w.params.events || []) as EventCard[];
|
||||
const list = events
|
||||
.filter((e) => (onlySelected ? selectedEventIds.has(e.id) : true))
|
||||
.filter((e) => (queryTokens.length ? includesAny(getEventSearchText(e), queryTokens) : true));
|
||||
if (list.length > 0) filtered.push({ ...w, params: { ...w.params, events: list } });
|
||||
continue;
|
||||
}
|
||||
if (activeTab === 'hotels' && w.type === 'travel_hotels') {
|
||||
const hotels = (w.params.hotels || []) as HotelCard[];
|
||||
const list = hotels
|
||||
.filter((h) => (minRating > 0 ? (h.rating || 0) >= minRating : true))
|
||||
.filter((h) => (onlySelected ? selectedHotelId === h.id : true))
|
||||
.filter((h) => (queryTokens.length ? includesAny(getHotelSearchText(h), queryTokens) : true));
|
||||
if (list.length > 0) filtered.push({ ...w, params: { ...w.params, hotels: list } });
|
||||
continue;
|
||||
}
|
||||
if ((activeTab === 'tickets' || activeTab === 'transport') && w.type === 'travel_transport') {
|
||||
const flights = (w.params.flights || []) as TransportOption[];
|
||||
const ground = (w.params.ground || []) as TransportOption[];
|
||||
const baseList = activeTab === 'tickets' ? flights : ground;
|
||||
const list = baseList
|
||||
.filter((t) => (onlySelected ? selectedTransportId === t.id : true))
|
||||
.filter((t) => (queryTokens.length ? includesAny(getTransportSearchText(t), queryTokens) : true));
|
||||
if (list.length > 0) {
|
||||
filtered.push({
|
||||
...w,
|
||||
params: {
|
||||
...w.params,
|
||||
flights: activeTab === 'tickets' ? list : [],
|
||||
ground: activeTab === 'transport' ? list : [],
|
||||
},
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// plan/budget/context: keep as-is (we still show empty state below if nothing meaningful)
|
||||
filtered.push(w);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [
|
||||
activeWidgets,
|
||||
activeTab,
|
||||
onlySelected,
|
||||
minRating,
|
||||
queryTokens,
|
||||
selectedPOIIds,
|
||||
selectedEventIds,
|
||||
selectedHotelId,
|
||||
selectedTransportId,
|
||||
]);
|
||||
|
||||
const hasRenderableContent = useMemo(() => {
|
||||
if (activeTab === 'plan') {
|
||||
// treat itinerary as present only when it has days
|
||||
for (const w of activeWidgets) {
|
||||
if (w.type === 'travel_itinerary' && Array.isArray(w.params.days) && (w.params.days as ItineraryDay[]).length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (activeTab === 'budget') {
|
||||
return filteredActiveWidgets.some((w) => Boolean(w.params.breakdown));
|
||||
}
|
||||
if (activeTab === 'context') {
|
||||
return filteredActiveWidgets.some((w) => Boolean(w.params.weather || w.params.safety || w.params.tips));
|
||||
}
|
||||
return filteredActiveWidgets.length > 0;
|
||||
}, [activeTab, activeWidgets, filteredActiveWidgets]);
|
||||
|
||||
const askMore = useCallback(() => {
|
||||
if (!onAction) return;
|
||||
const q = queryByTab[activeTab]?.trim();
|
||||
const encoded = q ? `search:${activeTab}:${q}` : `search:${activeTab}`;
|
||||
onAction(encoded);
|
||||
}, [activeTab, onAction, queryByTab]);
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0 rounded-xl border border-border/50 bg-base overflow-hidden flex flex-col shadow-sm">
|
||||
{!inlineNotice && clarifyingWidgets.map((w) => (
|
||||
<div key={w.id} className="p-3 border-b border-border/20">
|
||||
<TravelWidgetRenderer
|
||||
widget={w}
|
||||
onClarifyingAnswer={onClarifyingAnswer}
|
||||
onInlineNotice={(text) => setInlineNotice(text)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="px-3 py-2 border-b border-border/30 bg-surface">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-ui-sm text-muted">
|
||||
{inlineNotice ? (
|
||||
<>
|
||||
<Check className="w-3.5 h-3.5 text-green-600" />
|
||||
<span className="text-green-600">{inlineNotice}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isLoading && <Loader2 className="w-3.5 h-3.5 animate-spin text-accent" />}
|
||||
<span className="text-secondary">
|
||||
{isLoading && loadingPhase === 'planning' && 'Анализ запроса'}
|
||||
{isLoading && loadingPhase === 'collecting' && 'Сбор данных'}
|
||||
{isLoading && loadingPhase === 'building' && 'План по дням'}
|
||||
{isLoading && loadingPhase === 'routing' && 'Маршрут по дорогам'}
|
||||
{!isLoading && isResearching && 'Подбор вариантов'}
|
||||
{!isLoading && !isResearching && 'Готово к действиям'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-ui-sm text-muted">
|
||||
<span>
|
||||
Точек: <span className="text-secondary">{routePointCount}</span>
|
||||
</span>
|
||||
<span className="w-1 h-1 rounded-full bg-border/60" />
|
||||
<span>
|
||||
Маршрут:{' '}
|
||||
<span className="text-secondary">
|
||||
{hasRouteDirection ? 'дороги' : routePointCount >= 2 ? 'линии' : 'нет'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-11 shrink-0 flex items-center gap-0.5 px-2 pt-2 pb-0 overflow-x-auto scrollbar-hide bg-base">
|
||||
{TABS.map((tab) => {
|
||||
const count = tabCounts.get(tab.id) || 0;
|
||||
const isActive = activeTab === tab.id;
|
||||
const Icon = tab.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTabClick(tab.id)}
|
||||
className={`h-9 flex items-center gap-1.5 px-3 text-ui-sm font-medium rounded-t-lg transition-all whitespace-nowrap flex-shrink-0 border-b-2 ${
|
||||
isActive
|
||||
? 'bg-surface/60 text-primary border-accent'
|
||||
: 'text-muted hover:text-secondary hover:bg-surface/30 border-transparent'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{tab.label}
|
||||
<span
|
||||
className={`min-w-[18px] h-[14px] px-1.5 text-3xs rounded-full inline-flex items-center justify-center ${
|
||||
count > 0 ? '' : 'opacity-0'
|
||||
} ${isActive ? 'bg-accent/20 text-accent' : 'bg-surface/50 text-muted'}`}
|
||||
aria-hidden={count === 0}
|
||||
>
|
||||
{count > 0 ? count : '0'}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -4 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="p-3"
|
||||
>
|
||||
<TabToolbar
|
||||
tab={activeTab}
|
||||
query={queryByTab[activeTab] || ''}
|
||||
setQuery={(value) => setQueryByTab((prev) => ({ ...prev, [activeTab]: value }))}
|
||||
onlySelected={onlySelectedByTab[activeTab] || false}
|
||||
setOnlySelected={(value) => setOnlySelectedByTab((prev) => ({ ...prev, [activeTab]: value }))}
|
||||
minRating={minRatingByTab[activeTab] || 0}
|
||||
setMinRating={(value) => setMinRatingByTab((prev) => ({ ...prev, [activeTab]: value }))}
|
||||
filtersOpen={filtersOpenByTab[activeTab] || false}
|
||||
setFiltersOpen={(value) => setFiltersOpenByTab((prev) => ({ ...prev, [activeTab]: value }))}
|
||||
isLoading={isLoading}
|
||||
onAskMore={activeTab === 'budget' || activeTab === 'context' ? undefined : askMore}
|
||||
showAskMore={Boolean((queryByTab[activeTab] || '').trim())}
|
||||
/>
|
||||
|
||||
{isLoading && travelWidgets.length === 0 ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-muted">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-xs">Собираю данные...</span>
|
||||
</div>
|
||||
) : !hasRenderableContent ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-muted">
|
||||
<AlertCircle className="w-5 h-5 opacity-40" />
|
||||
<span className="text-xs">{activeTabDef.emptyLabel}</span>
|
||||
{(activeTab === 'tickets' || activeTab === 'transport') && (
|
||||
<span className="text-ui-sm text-muted/80 text-center max-w-[520px]">
|
||||
Билеты и транспорт появляются после планирования (или нажмите «Найти ещё», чтобы попросить AI собрать варианты).
|
||||
</span>
|
||||
)}
|
||||
{activeTab === 'plan' && (
|
||||
<span className="text-ui-sm text-muted/80 text-center max-w-[520px]">
|
||||
План строится в 4 шага: анализ запроса → сбор мест/событий/отелей → маршрут по дням → дороги на карте.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
filteredActiveWidgets.map((widget) => (
|
||||
<TravelWidgetRenderer
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
onAddEventToMap={onAddEventToMap}
|
||||
onAddPOIToMap={onAddPOIToMap}
|
||||
onSelectHotel={onSelectHotel}
|
||||
onSelectTransport={onSelectTransport}
|
||||
onClarifyingAnswer={onClarifyingAnswer}
|
||||
onAction={onAction}
|
||||
selectedEventIds={selectedEventIds}
|
||||
selectedPOIIds={selectedPOIIds}
|
||||
selectedHotelId={selectedHotelId}
|
||||
selectedTransportId={selectedTransportId}
|
||||
availablePois={availablePois}
|
||||
availableEvents={availableEvents}
|
||||
onItineraryUpdate={onItineraryUpdate}
|
||||
onValidateItineraryWithLLM={onValidateItineraryWithLLM}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,14 @@ import {
|
||||
Ticket,
|
||||
HelpCircle,
|
||||
CloudSun,
|
||||
Cloud,
|
||||
Sun,
|
||||
CloudRain,
|
||||
CloudLightning,
|
||||
Snowflake,
|
||||
CloudFog,
|
||||
Wind,
|
||||
Droplets,
|
||||
Shield,
|
||||
AlertTriangle,
|
||||
Info,
|
||||
@@ -35,7 +43,9 @@ import {
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Phone,
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
import { EditableItinerary } from '@/components/EditableItinerary';
|
||||
import type {
|
||||
EventCard,
|
||||
POICard,
|
||||
@@ -47,6 +57,8 @@ import type {
|
||||
MapPoint,
|
||||
RouteSegment,
|
||||
WeatherAssessment,
|
||||
DailyForecast,
|
||||
WeatherIcon,
|
||||
SafetyAssessment,
|
||||
RestrictionItem,
|
||||
TravelTip,
|
||||
@@ -103,7 +115,7 @@ function EventCardComponent({ event, onAddToMap, isSelected }: EventCardComponen
|
||||
{event.tags && event.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{event.tags.slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="px-2 py-0.5 text-[10px] bg-surface/60 text-muted rounded-full">
|
||||
<span key={tag} className="px-2 py-0.5 text-2xs bg-surface/60 text-muted rounded-full">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
@@ -172,27 +184,20 @@ const categoryIconsMap: Record<string, typeof MapPin> = {
|
||||
|
||||
function POICardComponent({ poi, onAddToMap, isSelected }: POICardComponentProps) {
|
||||
const [imgError, setImgError] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const Icon = categoryIconsMap[poi.category] || MapPin;
|
||||
const hasPhoto = poi.photos && poi.photos.length > 0 && !imgError;
|
||||
|
||||
const scheduleEntries = useMemo(() => {
|
||||
if (!poi.schedule) return [];
|
||||
return Object.entries(poi.schedule);
|
||||
}, [poi.schedule]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
initial={{ opacity: 0, scale: 0.97 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={`flex-shrink-0 w-[260px] rounded-xl border overflow-hidden transition-all ${
|
||||
className={`flex-shrink-0 w-[180px] rounded-xl border overflow-hidden transition-all ${
|
||||
isSelected
|
||||
? 'border-accent/50 bg-accent/10'
|
||||
: 'border-border/40 bg-elevated/40 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
{/* Photo */}
|
||||
<div className="relative w-full h-[140px] bg-surface/60 overflow-hidden">
|
||||
<div className="relative w-full h-[100px] bg-surface/60 overflow-hidden">
|
||||
{hasPhoto ? (
|
||||
<img
|
||||
src={poi.photos![0]}
|
||||
@@ -203,109 +208,52 @@ function POICardComponent({ poi, onAddToMap, isSelected }: POICardComponentProps
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Icon className="w-10 h-10 text-muted/30" />
|
||||
<Icon className="w-8 h-8 text-muted/20" />
|
||||
</div>
|
||||
)}
|
||||
{/* Category badge */}
|
||||
<span className="absolute top-2 left-2 px-2 py-0.5 text-[10px] font-medium bg-black/60 text-white rounded-full backdrop-blur-sm">
|
||||
<span className="absolute top-1.5 left-1.5 px-1.5 py-px text-3xs font-medium bg-black/60 text-white rounded-full backdrop-blur-sm">
|
||||
{categoryLabels[poi.category] || poi.category}
|
||||
</span>
|
||||
{/* Add to map button */}
|
||||
{onAddToMap && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onAddToMap(poi); }}
|
||||
className="absolute top-2 right-2 p-1.5 rounded-full bg-black/50 text-white hover:bg-accent/80 transition-colors backdrop-blur-sm"
|
||||
className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/50 text-white hover:bg-accent/80 transition-colors backdrop-blur-sm"
|
||||
title="На карту"
|
||||
>
|
||||
{isSelected ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
|
||||
{isSelected ? <Check className="w-3 h-3" /> : <Plus className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
{/* Photo count */}
|
||||
{poi.photos && poi.photos.length > 1 && (
|
||||
<span className="absolute bottom-2 right-2 px-1.5 py-0.5 text-[10px] bg-black/60 text-white rounded-full backdrop-blur-sm">
|
||||
{poi.photos.length} фото
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3">
|
||||
<h4 className="text-sm font-medium text-primary leading-tight line-clamp-2">{poi.name}</h4>
|
||||
<div className="p-2">
|
||||
<h4 className="text-ui-sm font-medium text-primary leading-tight line-clamp-2">{poi.name}</h4>
|
||||
|
||||
{/* Rating + Reviews */}
|
||||
<div className="flex items-center gap-2 mt-1.5">
|
||||
<div className="flex items-center gap-1.5 mt-1">
|
||||
{(poi.rating ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-amber-500">
|
||||
<Star className="w-3 h-3 fill-current" />
|
||||
<span className="flex items-center gap-0.5 text-2xs font-medium text-amber-500">
|
||||
<Star className="w-2.5 h-2.5 fill-current" />
|
||||
{poi.rating!.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
{(poi.reviewCount ?? 0) > 0 && (
|
||||
<span className="text-[10px] text-muted">
|
||||
{poi.reviewCount} отзывов
|
||||
</span>
|
||||
<span className="text-3xs text-muted">({poi.reviewCount})</span>
|
||||
)}
|
||||
{(poi.duration ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-[10px] text-muted ml-auto">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
{poi.duration} мин
|
||||
{(poi.price ?? 0) > 0 && (
|
||||
<span className="text-2xs font-medium text-accent ml-auto">
|
||||
{poi.price?.toLocaleString('ru-RU')} ₽
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{poi.description && (
|
||||
<p className="text-xs text-secondary mt-1.5 line-clamp-2">{poi.description}</p>
|
||||
<p className="text-2xs text-secondary mt-1 line-clamp-2 leading-snug">{poi.description}</p>
|
||||
)}
|
||||
|
||||
{/* Address */}
|
||||
{poi.address && (
|
||||
<div className="flex items-start gap-1 mt-1.5">
|
||||
<MapPin className="w-3 h-3 text-muted flex-shrink-0 mt-0.5" />
|
||||
<span className="text-[10px] text-muted line-clamp-1">{poi.address}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
{(poi.price ?? 0) > 0 && (
|
||||
<div className="mt-1.5">
|
||||
<span className="text-xs font-medium text-accent">
|
||||
{poi.price?.toLocaleString('ru-RU')} {poi.currency || '₽'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Expandable schedule */}
|
||||
{scheduleEntries.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-1 text-[10px] text-accent hover:text-accent/80 transition-colors"
|
||||
>
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
{expanded ? 'Скрыть расписание' : 'Расписание'}
|
||||
{expanded ? <ChevronUp className="w-2.5 h-2.5" /> : <ChevronDown className="w-2.5 h-2.5" />}
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-x-3 gap-y-0.5 mt-1.5">
|
||||
{scheduleEntries.map(([day, hours]) => (
|
||||
<div key={day} className="flex justify-between text-[10px]">
|
||||
<span className="text-muted font-medium">{day}</span>
|
||||
<span className="text-secondary">{hours}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
{(poi.duration ?? 0) > 0 && (
|
||||
<span className="flex items-center gap-0.5 text-3xs text-muted mt-1">
|
||||
<Clock className="w-2.5 h-2.5" />
|
||||
~{poi.duration} мин
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -536,6 +484,50 @@ function TransportCardComponent({ option, onSelect, isSelected }: TransportCardC
|
||||
);
|
||||
}
|
||||
|
||||
// --- Timeline Step Marker ---
|
||||
|
||||
const stepIconMap: Record<string, typeof MapPin> = {
|
||||
attraction: Camera,
|
||||
museum: Camera,
|
||||
park: MapPin,
|
||||
restaurant: Utensils,
|
||||
food: Utensils,
|
||||
theater: Music,
|
||||
entertainment: Music,
|
||||
shopping: Tag,
|
||||
religious: MapPin,
|
||||
viewpoint: Camera,
|
||||
hotel: Hotel,
|
||||
transport: Navigation,
|
||||
transfer: Navigation,
|
||||
airport: Plane,
|
||||
event: Ticket,
|
||||
};
|
||||
|
||||
function TimelineStepMarker({ index, refType, isLast, hasSegment }: {
|
||||
index: number;
|
||||
refType: string;
|
||||
isLast: boolean;
|
||||
hasSegment: boolean;
|
||||
}) {
|
||||
const Icon = stepIconMap[refType] || MapPin;
|
||||
const showLine = !isLast || hasSegment;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center flex-shrink-0">
|
||||
<div className="relative w-7 h-7 rounded-full bg-accent/15 border-2 border-accent/40 flex items-center justify-center group-hover:border-accent/60 transition-colors">
|
||||
<Icon className="w-3 h-3 text-accent" />
|
||||
<span className="absolute -top-1 -right-1 w-3.5 h-3.5 rounded-full bg-accent text-white text-[8px] font-bold flex items-center justify-center leading-none">
|
||||
{index}
|
||||
</span>
|
||||
</div>
|
||||
{showLine && (
|
||||
<div className="w-px flex-1 min-h-[16px] bg-gradient-to-b from-accent/30 to-border/20 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Transport Segment Card ---
|
||||
|
||||
function TransportSegmentCard({ segment }: { segment: RouteSegment }) {
|
||||
@@ -553,33 +545,31 @@ function TransportSegmentCard({ segment }: { segment: RouteSegment }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-2 my-1 p-2 rounded-lg bg-accent/5 border border-accent/15">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Navigation className="w-3 h-3 text-accent" />
|
||||
<span className="text-[10px] text-muted uppercase tracking-wide">Как добраться</span>
|
||||
{distKm && (
|
||||
<span className="text-[10px] text-secondary">{distKm} км</span>
|
||||
)}
|
||||
{durationMin !== null && durationMin > 0 && (
|
||||
<span className="text-[10px] text-secondary">~{durationMin} мин</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{segment.transportOptions.map((opt) => {
|
||||
const Icon = modeIcons[opt.mode] || Navigation;
|
||||
return (
|
||||
<div
|
||||
key={opt.mode}
|
||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-surface/50 border border-border/30"
|
||||
>
|
||||
<Icon className="w-3 h-3 text-muted" />
|
||||
<span className="text-[11px] text-secondary">{opt.label}</span>
|
||||
<span className="text-[11px] font-medium text-primary">
|
||||
~{opt.price.toLocaleString()} {opt.currency === 'RUB' ? '₽' : opt.currency}
|
||||
<div className="ml-3 pl-5 border-l border-dashed border-accent/20">
|
||||
<div className="flex items-center gap-3 py-1.5">
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-muted">
|
||||
<Navigation className="w-2.5 h-2.5 text-accent/60" />
|
||||
{distKm && <span>{distKm} км</span>}
|
||||
{distKm && durationMin !== null && durationMin > 0 && <span className="text-border">·</span>}
|
||||
{durationMin !== null && durationMin > 0 && <span>~{durationMin} мин</span>}
|
||||
</div>
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{segment.transportOptions.map((opt) => {
|
||||
const Icon = modeIcons[opt.mode] || Navigation;
|
||||
return (
|
||||
<span
|
||||
key={opt.mode}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent/8 text-[10px] text-secondary"
|
||||
>
|
||||
<Icon className="w-2.5 h-2.5 text-accent/50" />
|
||||
{opt.label}
|
||||
<span className="font-medium text-primary">
|
||||
~{opt.price.toLocaleString()} {opt.currency === 'RUB' ? '₽' : opt.currency}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -591,10 +581,16 @@ interface ItineraryWidgetProps {
|
||||
days: ItineraryDay[];
|
||||
budget?: BudgetBreakdown;
|
||||
segments?: RouteSegment[];
|
||||
dailyForecast?: DailyForecast[];
|
||||
pois?: POICard[];
|
||||
events?: EventCard[];
|
||||
onItineraryUpdate?: (days: ItineraryDay[]) => void;
|
||||
onValidateWithLLM?: (days: ItineraryDay[]) => Promise<import('@/lib/hooks/useEditableItinerary').LLMValidationResponse | null>;
|
||||
}
|
||||
|
||||
function ItineraryWidget({ days, segments }: ItineraryWidgetProps) {
|
||||
function ItineraryWidget({ days, segments, dailyForecast, pois, events, onItineraryUpdate, onValidateWithLLM }: ItineraryWidgetProps) {
|
||||
const [expandedDay, setExpandedDay] = useState<number>(0);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
|
||||
const findSegment = useCallback((fromTitle: string, toTitle: string): RouteSegment | undefined => {
|
||||
if (!segments || segments.length === 0) return undefined;
|
||||
@@ -603,87 +599,142 @@ function ItineraryWidget({ days, segments }: ItineraryWidgetProps) {
|
||||
);
|
||||
}, [segments]);
|
||||
|
||||
const findWeather = useCallback((date: string): DailyForecast | undefined => {
|
||||
if (!dailyForecast || dailyForecast.length === 0) return undefined;
|
||||
return dailyForecast.find((d) => d.date === date);
|
||||
}, [dailyForecast]);
|
||||
|
||||
let globalStep = 0;
|
||||
|
||||
if (isEditMode && pois && events) {
|
||||
return (
|
||||
<EditableItinerary
|
||||
days={days}
|
||||
pois={pois}
|
||||
events={events}
|
||||
dailyForecast={dailyForecast}
|
||||
onApply={(editedDays) => {
|
||||
onItineraryUpdate?.(editedDays);
|
||||
setIsEditMode(false);
|
||||
}}
|
||||
onCancel={() => setIsEditMode(false)}
|
||||
onValidateWithLLM={onValidateWithLLM}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{days.map((day, idx) => (
|
||||
<motion.div
|
||||
key={day.date}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
className="border border-border/40 rounded-xl overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => setExpandedDay(expandedDay === idx ? -1 : idx)}
|
||||
className="w-full flex items-center justify-between p-3 bg-elevated/40 hover:bg-elevated/60 transition-colors"
|
||||
{days.map((day, idx) => {
|
||||
const dayStartStep = globalStep;
|
||||
globalStep += day.items.length;
|
||||
const dayWeather = findWeather(day.date) || (dailyForecast && dailyForecast[idx]);
|
||||
const WeatherDayIcon = dayWeather ? (weatherIconMap[dayWeather.icon] || CloudSun) : null;
|
||||
const weatherColor = dayWeather ? (weatherIconColors[dayWeather.icon] || 'text-sky-400') : '';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={day.date}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: idx * 0.05 }}
|
||||
className="border border-border/30 rounded-xl overflow-hidden bg-elevated/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center">
|
||||
<span className="text-xs font-bold text-accent">Д{idx + 1}</span>
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="text-sm font-medium text-primary">{day.date}</div>
|
||||
<div className="text-xs text-muted">{day.items.length} активностей</div>
|
||||
</div>
|
||||
</div>
|
||||
{expandedDay === idx ? (
|
||||
<ChevronUp className="w-4 h-4 text-muted" />
|
||||
) : (
|
||||
<ChevronDown className="w-4 h-4 text-muted" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{expandedDay === idx && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="p-3 space-y-0">
|
||||
{day.items.map((item, itemIdx) => {
|
||||
const nextItem = itemIdx < day.items.length - 1 ? day.items[itemIdx + 1] : null;
|
||||
const segment = nextItem ? findSegment(item.title, nextItem.title) : undefined;
|
||||
|
||||
return (
|
||||
<div key={`${item.refId}-${itemIdx}`}>
|
||||
<div className="flex items-start gap-3 p-2 rounded-lg bg-surface/30">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-2 h-2 rounded-full bg-accent" />
|
||||
{(itemIdx < day.items.length - 1 || segment) && (
|
||||
<div className="w-0.5 h-8 bg-border/40 mt-1" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.startTime && (
|
||||
<span className="text-xs text-accent font-mono">{item.startTime}</span>
|
||||
)}
|
||||
<span className="text-sm text-primary">{item.title}</span>
|
||||
</div>
|
||||
{item.note && (
|
||||
<p className="text-xs text-muted mt-0.5">{item.note}</p>
|
||||
)}
|
||||
{(item.cost ?? 0) > 0 && (
|
||||
<span className="text-xs text-secondary">
|
||||
~{item.cost} {item.currency || '₽'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{segment && segment.transportOptions && segment.transportOptions.length > 0 && (
|
||||
<TransportSegmentCard segment={segment} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => setExpandedDay(expandedDay === idx ? -1 : idx)}
|
||||
className="w-full flex items-center justify-between px-3.5 py-2.5 hover:bg-surface/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="w-6 h-6 rounded-md bg-accent text-white text-[10px] font-bold flex items-center justify-center leading-none">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<div className="text-left">
|
||||
<span className="text-[13px] font-medium text-primary">{day.date}</span>
|
||||
<span className="text-[11px] text-muted ml-2">{day.items.length} мест</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{idx === 0 && (pois && pois.length > 0 || events && events.length > 0) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setIsEditMode(true); }}
|
||||
className="w-7 h-7 inline-flex items-center justify-center text-muted hover:text-accent bg-surface/30 hover:bg-accent/10 rounded-lg transition-colors border border-border/30 hover:border-accent/30"
|
||||
title="Редактировать маршрут"
|
||||
aria-label="Редактировать маршрут"
|
||||
>
|
||||
<Pencil className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
{dayWeather && WeatherDayIcon && (
|
||||
<div className="flex items-center gap-1.5" title={dayWeather.conditions}>
|
||||
<WeatherDayIcon className={`w-3.5 h-3.5 ${weatherColor}`} />
|
||||
<span className="text-[10px] text-secondary">
|
||||
{formatTemp(dayWeather.tempMin)}..{formatTemp(dayWeather.tempMax)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<ChevronDown className={`w-4 h-4 text-muted transition-transform duration-200 ${expandedDay === idx ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{expandedDay === idx && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-3.5 pb-3 pt-1">
|
||||
{day.items.map((item, itemIdx) => {
|
||||
const nextItem = itemIdx < day.items.length - 1 ? day.items[itemIdx + 1] : null;
|
||||
const segment = nextItem ? findSegment(item.title, nextItem.title) : undefined;
|
||||
const stepNum = dayStartStep + itemIdx + 1;
|
||||
const isLast = itemIdx === day.items.length - 1;
|
||||
|
||||
return (
|
||||
<div key={`${item.refId}-${itemIdx}`} className="group">
|
||||
<div className="flex items-start gap-2.5 py-1.5">
|
||||
<TimelineStepMarker
|
||||
index={stepNum}
|
||||
refType={item.refType}
|
||||
isLast={isLast && !segment}
|
||||
hasSegment={!!segment}
|
||||
/>
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{item.startTime && (
|
||||
<span className="text-[11px] text-accent font-mono bg-accent/8 px-1.5 py-px rounded">
|
||||
{item.startTime}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[13px] font-medium text-primary truncate">{item.title}</span>
|
||||
</div>
|
||||
{item.note && (
|
||||
<p className="text-[11px] text-muted mt-0.5 line-clamp-2 leading-relaxed">{item.note}</p>
|
||||
)}
|
||||
{(item.cost ?? 0) > 0 && (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] text-accent/80 mt-0.5">
|
||||
<DollarSign className="w-2.5 h-2.5" />
|
||||
~{item.cost?.toLocaleString('ru-RU')} {item.currency || '₽'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{segment && segment.transportOptions && segment.transportOptions.length > 0 && (
|
||||
<TransportSegmentCard segment={segment} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -762,9 +813,10 @@ function BudgetWidget({ breakdown }: BudgetWidgetProps) {
|
||||
interface ClarifyingQuestionsWidgetProps {
|
||||
questions: ClarifyingQuestion[];
|
||||
onAnswer: (field: string, value: string) => void;
|
||||
onSubmittedNotice?: (text: string) => void;
|
||||
}
|
||||
|
||||
function ClarifyingQuestionsWidget({ questions, onAnswer }: ClarifyingQuestionsWidgetProps) {
|
||||
function ClarifyingQuestionsWidget({ questions, onAnswer, onSubmittedNotice }: ClarifyingQuestionsWidgetProps) {
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
@@ -779,18 +831,20 @@ function ClarifyingQuestionsWidget({ questions, onAnswer }: ClarifyingQuestionsW
|
||||
setSubmitted(true);
|
||||
const combined = filled.map(([field, value]) => `${field}: ${value}`).join('\n');
|
||||
onAnswer('_batch', combined);
|
||||
}, [answers, onAnswer]);
|
||||
onSubmittedNotice?.('Детали отправлены, планирую маршрут…');
|
||||
}, [answers, onAnswer, onSubmittedNotice]);
|
||||
|
||||
if (submitted) {
|
||||
if (onSubmittedNotice) return null;
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="p-4 rounded-xl border border-green-500/30 bg-green-500/5"
|
||||
className="px-3 py-2 rounded-lg border border-green-500/25 bg-green-500/5"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
<span className="text-sm text-green-600">Детали отправлены, планирую маршрут...</span>
|
||||
<Check className="w-3.5 h-3.5 text-green-600" />
|
||||
<span className="text-[12px] leading-4 text-green-600">Детали отправлены, планирую маршрут…</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
@@ -851,6 +905,119 @@ function ClarifyingQuestionsWidget({ questions, onAnswer }: ClarifyingQuestionsW
|
||||
);
|
||||
}
|
||||
|
||||
function TipsCollapsible({ tips, tipIcons }: { tips: TravelTip[]; tipIcons: Record<string, typeof Info> }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex items-center gap-2 px-1 w-full text-left"
|
||||
>
|
||||
<Lightbulb className="w-3.5 h-3.5 text-accent" />
|
||||
<span className="text-xs font-medium text-primary">Советы</span>
|
||||
<span className="px-1.5 py-0.5 text-[10px] bg-accent/20 text-accent rounded-full">{tips.length}</span>
|
||||
<span className="ml-auto">
|
||||
{open ? <ChevronUp className="w-3.5 h-3.5 text-muted" /> : <ChevronDown className="w-3.5 h-3.5 text-muted" />}
|
||||
</span>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="space-y-2 mt-2">
|
||||
{tips.map((tip, i) => {
|
||||
const TipIcon = tipIcons[tip.category] || Info;
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-2 p-3 rounded-lg bg-elevated/30 border border-border/20">
|
||||
<TipIcon className="w-3.5 h-3.5 text-accent flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-secondary">{tip.text}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Weather Icon Mapping ---
|
||||
|
||||
const weatherIconMap: Record<WeatherIcon | string, typeof Sun> = {
|
||||
sun: Sun,
|
||||
cloud: Cloud,
|
||||
'cloud-sun': CloudSun,
|
||||
rain: CloudRain,
|
||||
storm: CloudLightning,
|
||||
snow: Snowflake,
|
||||
fog: CloudFog,
|
||||
wind: Wind,
|
||||
};
|
||||
|
||||
const weatherIconColors: Record<WeatherIcon | string, string> = {
|
||||
sun: 'text-amber-400',
|
||||
cloud: 'text-slate-400',
|
||||
'cloud-sun': 'text-sky-400',
|
||||
rain: 'text-blue-400',
|
||||
storm: 'text-purple-400',
|
||||
snow: 'text-cyan-300',
|
||||
fog: 'text-slate-300',
|
||||
wind: 'text-teal-400',
|
||||
};
|
||||
|
||||
function formatTemp(temp: number): string {
|
||||
return `${temp > 0 ? '+' : ''}${Math.round(temp)}°`;
|
||||
}
|
||||
|
||||
function formatDateShort(dateStr: string): string {
|
||||
try {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const weekdays = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'];
|
||||
const day = d.getDate();
|
||||
const months = ['янв', 'фев', 'мар', 'апр', 'мая', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек'];
|
||||
return `${weekdays[d.getDay()]}, ${day} ${months[d.getMonth()]}`;
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
function DailyForecastRow({ day }: { day: DailyForecast }) {
|
||||
const Icon = weatherIconMap[day.icon] || CloudSun;
|
||||
const iconColor = weatherIconColors[day.icon] || 'text-sky-400';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-1.5 px-2 rounded-lg hover:bg-surface/30 transition-colors group">
|
||||
<span className="text-[10px] text-muted w-[70px] flex-shrink-0 leading-tight">
|
||||
{formatDateShort(day.date)}
|
||||
</span>
|
||||
<Icon className={`w-4 h-4 ${iconColor} flex-shrink-0`} />
|
||||
<span className="text-[11px] font-medium text-primary w-[52px] text-right flex-shrink-0">
|
||||
{formatTemp(day.tempMin)}..{formatTemp(day.tempMax)}
|
||||
</span>
|
||||
<span className="text-[10px] text-secondary truncate flex-1">
|
||||
{day.conditions}
|
||||
</span>
|
||||
{day.rainChance && day.rainChance !== 'низкая' && (
|
||||
<span className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<Droplets className="w-2.5 h-2.5 text-blue-400" />
|
||||
<span className="text-[9px] text-blue-400">{day.rainChance}</span>
|
||||
</span>
|
||||
)}
|
||||
{day.tip && (
|
||||
<span className="hidden group-hover:block absolute right-0 top-full z-10 p-2 bg-elevated border border-border/40 rounded-lg text-[10px] text-secondary max-w-[200px] shadow-lg">
|
||||
{day.tip}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Travel Context Widget ---
|
||||
|
||||
interface TravelContextWidgetProps {
|
||||
@@ -862,6 +1029,8 @@ interface TravelContextWidgetProps {
|
||||
}
|
||||
|
||||
function TravelContextWidget({ weather, safety, restrictions, tips, bestTimeInfo }: TravelContextWidgetProps) {
|
||||
const [showAllDays, setShowAllDays] = useState(false);
|
||||
|
||||
const safetyColors: Record<string, { bg: string; text: string; icon: typeof ShieldCheck }> = {
|
||||
safe: { bg: 'bg-green-500/10', text: 'text-green-400', icon: ShieldCheck },
|
||||
caution: { bg: 'bg-yellow-500/10', text: 'text-yellow-400', icon: Shield },
|
||||
@@ -893,29 +1062,68 @@ function TravelContextWidget({ weather, safety, restrictions, tips, bestTimeInfo
|
||||
const safetyStyle = safetyColors[safety.level] || safetyColors.safe;
|
||||
const SafetyIcon = safetyStyle.icon;
|
||||
|
||||
const dailyForecast = weather.dailyForecast || [];
|
||||
const visibleDays = showAllDays ? dailyForecast : dailyForecast.slice(0, 4);
|
||||
const hasMoreDays = dailyForecast.length > 4;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Weather */}
|
||||
<div className="p-4 rounded-xl border border-border/40 bg-elevated/40">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<CloudSun className="w-4 h-4 text-sky-400" />
|
||||
<h4 className="text-sm font-medium text-primary">Погода</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Thermometer className="w-3.5 h-3.5 text-orange-400" />
|
||||
<span className="text-xs text-secondary">
|
||||
{weather.tempMin > 0 ? '+' : ''}{weather.tempMin}° ... {weather.tempMax > 0 ? '+' : ''}{weather.tempMax}°C
|
||||
<CloudSun className="w-4 h-4 text-sky-400" />
|
||||
<h4 className="text-sm font-medium text-primary">Погода</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex items-center gap-1 text-xs text-secondary">
|
||||
<Thermometer className="w-3 h-3 text-orange-400" />
|
||||
{formatTemp(weather.tempMin)}..{formatTemp(weather.tempMax)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-secondary">
|
||||
<Umbrella className="w-3 h-3 text-blue-400" />
|
||||
{weather.rainChance}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Umbrella className="w-3.5 h-3.5 text-blue-400" />
|
||||
<span className="text-xs text-secondary">Осадки: {weather.rainChance}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-secondary mt-2">{weather.summary}</p>
|
||||
|
||||
<p className="text-xs text-secondary">{weather.summary}</p>
|
||||
|
||||
{weather.clothing && (
|
||||
<p className="text-xs text-muted mt-1.5 italic">{weather.clothing}</p>
|
||||
<p className="text-[11px] text-muted mt-1.5 italic">{weather.clothing}</p>
|
||||
)}
|
||||
|
||||
{/* Daily forecast */}
|
||||
{dailyForecast.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-border/30">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[10px] text-muted uppercase tracking-wide">Прогноз по дням</span>
|
||||
<span className="text-[10px] text-muted">{dailyForecast.length} дн.</span>
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
{visibleDays.map((day) => (
|
||||
<DailyForecastRow key={day.date} day={day} />
|
||||
))}
|
||||
</div>
|
||||
{hasMoreDays && (
|
||||
<button
|
||||
onClick={() => setShowAllDays(!showAllDays)}
|
||||
className="flex items-center gap-1 mt-1 px-2 py-1 text-[10px] text-accent hover:text-accent-hover transition-colors"
|
||||
>
|
||||
{showAllDays ? (
|
||||
<>
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
Свернуть
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
Ещё {dailyForecast.length - 4} дн.
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -965,23 +1173,9 @@ function TravelContextWidget({ weather, safety, restrictions, tips, bestTimeInfo
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tips */}
|
||||
{/* Tips (collapsed by default) */}
|
||||
{tips.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Lightbulb className="w-3.5 h-3.5 text-accent" />
|
||||
<span className="text-xs font-medium text-primary">Советы</span>
|
||||
</div>
|
||||
{tips.map((tip, i) => {
|
||||
const TipIcon = tipIcons[tip.category] || Info;
|
||||
return (
|
||||
<div key={i} className="flex items-start gap-2 p-3 rounded-lg bg-elevated/30 border border-border/20">
|
||||
<TipIcon className="w-3.5 h-3.5 text-accent flex-shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-secondary">{tip.text}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<TipsCollapsible tips={tips} tipIcons={tipIcons} />
|
||||
)}
|
||||
|
||||
{/* Best time info */}
|
||||
@@ -1008,10 +1202,15 @@ interface TravelWidgetRendererProps {
|
||||
onSelectTransport?: (option: TransportOption) => void;
|
||||
onClarifyingAnswer?: (field: string, value: string) => void;
|
||||
onAction?: (kind: string) => void;
|
||||
onInlineNotice?: (text: string) => void;
|
||||
selectedEventIds?: Set<string>;
|
||||
selectedPOIIds?: Set<string>;
|
||||
selectedHotelId?: string;
|
||||
selectedTransportId?: string;
|
||||
availablePois?: POICard[];
|
||||
availableEvents?: EventCard[];
|
||||
onItineraryUpdate?: (days: ItineraryDay[]) => void;
|
||||
onValidateItineraryWithLLM?: (days: ItineraryDay[]) => Promise<import('@/lib/hooks/useEditableItinerary').LLMValidationResponse | null>;
|
||||
}
|
||||
|
||||
export function TravelWidgetRenderer({
|
||||
@@ -1022,10 +1221,15 @@ export function TravelWidgetRenderer({
|
||||
onSelectTransport,
|
||||
onClarifyingAnswer,
|
||||
onAction,
|
||||
onInlineNotice,
|
||||
selectedEventIds = new Set(),
|
||||
selectedPOIIds = new Set(),
|
||||
selectedHotelId,
|
||||
selectedTransportId,
|
||||
availablePois,
|
||||
availableEvents,
|
||||
onItineraryUpdate,
|
||||
onValidateItineraryWithLLM,
|
||||
}: TravelWidgetRendererProps) {
|
||||
switch (widget.type) {
|
||||
case 'travel_context': {
|
||||
@@ -1130,10 +1334,20 @@ export function TravelWidgetRenderer({
|
||||
const days = (widget.params.days || []) as ItineraryDay[];
|
||||
const budgetData = widget.params.budget as BudgetBreakdown | undefined;
|
||||
const segmentsData = (widget.params.segments || []) as RouteSegment[];
|
||||
const forecastData = (widget.params.dailyForecast || []) as DailyForecast[];
|
||||
if (days.length === 0) return null;
|
||||
return (
|
||||
<WidgetSection title="Маршрут по дням" icon={<Calendar className="w-4 h-4" />} count={days.length}>
|
||||
<ItineraryWidget days={days} budget={budgetData} segments={segmentsData} />
|
||||
<ItineraryWidget
|
||||
days={days}
|
||||
budget={budgetData}
|
||||
segments={segmentsData}
|
||||
dailyForecast={forecastData}
|
||||
pois={availablePois}
|
||||
events={availableEvents}
|
||||
onItineraryUpdate={onItineraryUpdate}
|
||||
onValidateWithLLM={onValidateItineraryWithLLM}
|
||||
/>
|
||||
</WidgetSection>
|
||||
);
|
||||
}
|
||||
@@ -1151,6 +1365,7 @@ export function TravelWidgetRenderer({
|
||||
<ClarifyingQuestionsWidget
|
||||
questions={questions}
|
||||
onAnswer={onClarifyingAnswer || (() => {})}
|
||||
onSubmittedNotice={onInlineNotice}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user