Files
mkd/components/Navigation.tsx

348 lines
16 KiB
TypeScript
Raw Permalink Normal View History

2026-02-04 00:17:04 +05:00
import React, { useState, useEffect, useRef } from 'react';
import { NAV_ITEMS } from '../constants';
import { Bot, X, Menu, Send, MessageSquare } from 'lucide-react';
import { UserRole } from '../types';
import { apiClient, backendApi } from '../services/apiClient';
import { ROLE_ACCESS } from '../constants/roleAccess';
interface NavigationProps {
activeTab: string;
setActiveTab: (id: string) => void;
currentUserRole: UserRole;
/** Если передан, используется вместо ROLE_ACCESS по роли (из /auth/me) */
allowedSections?: string[];
}
const PRIMARY_COUNT = 4;
interface AIConversation {
id: number;
title: string | null;
createdAt: string;
}
interface AIMessage {
id?: number;
role: string;
content: string;
toolCallsJson?: unknown;
createdAt?: string;
}
export const Navigation: React.FC<NavigationProps> = ({ activeTab, setActiveTab, currentUserRole, allowedSections }) => {
const [showAIModal, setShowAIModal] = useState(false);
const [showMoreSheet, setShowMoreSheet] = useState(false);
const [aiEnabled, setAiEnabled] = useState(false);
const [aiConversations, setAiConversations] = useState<AIConversation[]>([]);
const [aiConversationId, setAiConversationId] = useState<number | null>(null);
const [aiMessages, setAiMessages] = useState<AIMessage[]>([]);
const [aiInput, setAiInput] = useState('');
const [aiLoading, setAiLoading] = useState(false);
const [aiError, setAiError] = useState<string | null>(null);
const aiMessagesEndRef = useRef<HTMLDivElement>(null);
const allowedTabs = allowedSections && allowedSections.length > 0
? (allowedSections.includes('all') ? ['dashboard', 'objects', 'requests', 'pr', 'finance', 'legal', 'development', 'hr', 'office', 'admin'] : allowedSections)
: (ROLE_ACCESS[currentUserRole] || []);
const visibleItems = NAV_ITEMS.filter(item => {
if (allowedTabs.includes('all')) return true;
return allowedTabs.includes(item.id);
});
const primaryItems = visibleItems.slice(0, PRIMARY_COUNT);
const middleIndex = Math.ceil(visibleItems.length / 2);
const leftItems = visibleItems.slice(0, middleIndex);
const rightItems = visibleItems.slice(middleIndex);
const closeSheetAndNavigate = (id: string) => {
setActiveTab(id);
setShowMoreSheet(false);
};
const fetchAiStatus = () => {
backendApi.getAIChatStatus().then((data) => setAiEnabled(data.enabled === true)).catch(() => setAiEnabled(false));
};
useEffect(() => {
fetchAiStatus();
}, []);
// Обновить нижнее меню (кнопка ИИ) только при смене настроек ИИ в Панели управления
useEffect(() => {
const onAiStatusChanged = () => fetchAiStatus();
window.addEventListener('mkd-ai-status-changed', onAiStatusChanged);
return () => window.removeEventListener('mkd-ai-status-changed', onAiStatusChanged);
}, []);
useEffect(() => {
if (!showAIModal || !aiEnabled) return;
apiClient.get<AIConversation[]>('/ai/conversations').then(setAiConversations).catch(() => setAiConversations([]));
}, [showAIModal, aiEnabled]);
useEffect(() => {
if (!showAIModal || !aiEnabled || aiConversationId == null) {
setAiMessages([]);
return;
}
apiClient.get<AIMessage[]>(`/ai/conversations/${aiConversationId}/messages`).then(setAiMessages).catch(() => setAiMessages([]));
}, [showAIModal, aiEnabled, aiConversationId]);
useEffect(() => {
aiMessagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [aiMessages]);
const startNewChat = () => {
setAiConversationId(null);
setAiMessages([]);
setAiInput('');
setAiError(null);
};
const sendAIMessage = async () => {
const text = aiInput.trim();
if (!text || aiLoading) return;
setAiError(null);
const userMsg: AIMessage = { role: 'user', content: text };
setAiMessages((prev) => [...prev, userMsg]);
setAiInput('');
setAiLoading(true);
try {
const res = await apiClient.post<{
conversationId: number;
assistantMessage: string;
toolResults?: { toolName: string; success: boolean; error?: string }[];
}>('/ai/chat', { conversationId: aiConversationId, message: text });
setAiConversationId(res.conversationId);
setAiMessages((prev) => [...prev, { role: 'assistant', content: res.assistantMessage }]);
setAiConversations((prev) => {
if (prev.some((c) => c.id === res.conversationId)) return prev;
return [{ id: res.conversationId, title: null, createdAt: new Date().toISOString() }, ...prev];
});
} catch (e: unknown) {
const errMsg = e && typeof e === 'object' && 'message' in e ? String((e as { message: string }).message) : 'Ошибка отправки';
setAiError(errMsg);
setAiMessages((prev) => prev.filter((m) => m !== userMsg));
} finally {
setAiLoading(false);
}
};
return (
<>
{/* ИИ-чат: только если ИИ включён в настройках */}
{aiEnabled && showAIModal && (
<div className="fixed inset-0 bg-black/60 z-[60] backdrop-blur-sm animate-fade-in flex items-center justify-center p-4" onClick={() => setShowAIModal(false)}>
<div className="bg-white rounded-2xl w-full max-w-2xl max-h-[85vh] flex flex-col shadow-2xl animate-slide-up overflow-hidden" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between p-4 border-b border-slate-200">
<div className="flex items-center gap-2">
<div className="w-10 h-10 rounded-full bg-primary-50 flex items-center justify-center">
<Bot className="w-5 h-5 text-primary-600" />
</div>
<h3 className="text-lg font-bold text-slate-800">ИИ-помощник</h3>
</div>
<button type="button" onClick={() => setShowAIModal(false)} className="p-1.5 rounded-lg text-slate-400 hover:bg-slate-100">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex flex-1 min-h-0">
<div className="w-44 border-r border-slate-200 flex-shrink-0 flex flex-col py-2">
<button
type="button"
onClick={startNewChat}
className="mx-2 mb-2 py-2 px-3 rounded-lg text-left text-sm font-medium text-primary-600 bg-primary-50 hover:bg-primary-100 flex items-center gap-2"
>
<MessageSquare className="w-4 h-4" /> Новый чат
</button>
<div className="overflow-y-auto flex-1">
{aiConversations.map((c) => (
<button
key={c.id}
type="button"
onClick={() => setAiConversationId(c.id)}
className={`w-full text-left py-2 px-3 text-sm truncate ${aiConversationId === c.id ? 'bg-primary-50 text-primary-700 font-medium' : 'text-slate-600 hover:bg-slate-100'}`}
>
{c.title || `Диалог ${c.id}`}
</button>
))}
</div>
</div>
<div className="flex-1 flex flex-col min-w-0">
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{aiMessages.length === 0 && !aiLoading && (
<p className="text-slate-500 text-sm text-center py-8">Напишите, что нужно сделать например: «Покажи список домов» или «Создай счёт на оплату».</p>
)}
{aiMessages.map((msg, i) => (
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
<div
className={`max-w-[85%] rounded-2xl px-4 py-2 text-sm ${
msg.role === 'user' ? 'bg-primary-600 text-white' : 'bg-slate-100 text-slate-800'
}`}
>
{msg.content}
</div>
</div>
))}
{aiLoading && (
<div className="flex justify-start">
<div className="bg-slate-100 text-slate-600 rounded-2xl px-4 py-2 text-sm">Думаю</div>
</div>
)}
<div ref={aiMessagesEndRef} />
</div>
{aiError && <div className="px-4 pb-1 text-sm text-red-600">{aiError}</div>}
<div className="p-4 border-t border-slate-200 flex gap-2">
<input
type="text"
value={aiInput}
onChange={(e) => setAiInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendAIMessage();
}
}}
placeholder="Сообщение..."
className="flex-1 rounded-xl border border-slate-200 px-4 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-primary-500"
disabled={aiLoading}
/>
<button
type="button"
onClick={sendAIMessage}
disabled={aiLoading || !aiInput.trim()}
className="rounded-xl bg-primary-600 text-white p-2.5 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Send className="w-5 h-5" />
</button>
</div>
</div>
</div>
</div>
</div>
)}
{/* Mobile: 4 кнопки + Ещё, снизу выезжает панель (свайп вверх) */}
<div className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 z-50 shadow-[0_-4px_20px_rgba(0,0,0,0.08)] pt-2 pb-safe px-1">
<div
className="grid gap-0 max-w-full overflow-hidden"
style={{ gridTemplateColumns: `repeat(${primaryItems.length + 1}, minmax(0, 1fr))` }}
>
{primaryItems.map((item) => (
<NavButton key={item.id} item={item} isActive={activeTab === item.id} onClick={() => setActiveTab(item.id)} />
))}
<button
onClick={() => setShowMoreSheet(true)}
className="flex flex-col items-center justify-center gap-0.5 py-2 min-h-[44px] rounded-xl transition-colors text-slate-500 hover:text-slate-700 hover:bg-slate-50 active:bg-slate-100"
aria-label="Ещё разделы"
>
<Menu className="w-6 h-6" strokeWidth={2} />
<span className="text-[9px] font-bold">Ещё</span>
</button>
</div>
</div>
{/* Bottom sheet: все разделы + ИИ */}
{showMoreSheet && (
<>
<div
className="fixed inset-0 bg-black/40 z-[54] md:hidden animate-fade-in"
onClick={() => setShowMoreSheet(false)}
aria-hidden="true"
/>
<div
className="fixed bottom-0 left-0 right-0 z-[55] md:hidden bg-white rounded-t-2xl shadow-2xl max-h-[70vh] overflow-y-auto animate-slide-up pb-safe"
onClick={e => e.stopPropagation()}
>
<div className="sticky top-0 bg-white rounded-t-2xl pt-2 pb-1 px-4 border-b border-slate-100 z-10">
<div className="w-10 h-1 rounded-full bg-slate-300 mx-auto mb-2" aria-hidden="true" />
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-slate-800">Разделы</h3>
<button
onClick={() => setShowMoreSheet(false)}
className="p-2 -mr-2 rounded-full text-slate-400 hover:bg-slate-100 hover:text-slate-600"
aria-label="Закрыть"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="p-4">
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
{visibleItems.map((item) => {
const Icon = item.icon;
return (
<button
key={item.id}
onClick={() => closeSheetAndNavigate(item.id)}
className={`
flex flex-col items-center justify-center gap-1 p-3 min-h-[64px] rounded-xl transition-all duration-300
${activeTab === item.id ? 'bg-primary-50 text-primary-600' : 'text-slate-600 hover:bg-slate-50 active:bg-slate-100'}
`}
>
<Icon className={`w-6 h-6 shrink-0 ${activeTab === item.id ? 'text-primary-600' : 'text-slate-400'}`} strokeWidth={activeTab === item.id ? 2.5 : 2} />
<span className="text-[9px] font-bold text-center leading-tight">{item.label}</span>
</button>
);
})}
{aiEnabled && (
<button
onClick={() => { setShowMoreSheet(false); setShowAIModal(true); }}
className="flex flex-col items-center justify-center gap-1 p-3 min-h-[64px] rounded-xl transition-all duration-300 bg-primary-50 text-primary-600 hover:bg-primary-100 active:bg-primary-200"
>
<Bot className="w-6 h-6 shrink-0 text-primary-600" strokeWidth={2.5} />
<span className="text-[9px] font-bold text-center leading-tight">ИИ-помощник</span>
</button>
)}
</div>
</div>
</div>
</>
)}
{/* Desktop: полный бар (слева / центр ИИ при включённом ИИ / справа) */}
<div className="hidden md:block fixed bottom-0 left-0 right-0 bg-white border-t border-slate-200 px-2 pb-safe pt-2 z-50 shadow-[0_-4px_20px_rgba(0,0,0,0.05)]">
<div className="max-w-3xl mx-auto flex justify-between items-end relative">
<div className="flex justify-around flex-1">
{leftItems.map((item) => (
<NavButton key={item.id} item={item} isActive={activeTab === item.id} onClick={() => setActiveTab(item.id)} />
))}
</div>
{aiEnabled && (
<div className="relative -top-6 px-2">
<button
onClick={() => setShowAIModal(true)}
className="w-14 h-14 rounded-full flex items-center justify-center shadow-lg shadow-primary-500/40 transition-transform active:scale-95 bg-primary-600 hover:bg-primary-700"
aria-label="ИИ-помощник"
>
<Bot className="w-8 h-8 text-white" />
</button>
</div>
)}
<div className="flex justify-around flex-1">
{rightItems.map((item) => (
<NavButton key={item.id} item={item} isActive={activeTab === item.id} onClick={() => setActiveTab(item.id)} />
))}
</div>
</div>
</div>
</>
);
};
const NavButton = ({ item, isActive, onClick }: any) => {
const Icon = item.icon;
return (
<button
onClick={onClick}
className={`
flex flex-col items-center justify-center gap-1 p-2 min-h-[44px] rounded-xl transition-all duration-300 w-full
${isActive ? 'text-primary-600' : 'text-slate-400 hover:text-slate-600'}
`}
>
<Icon className={`w-6 h-6 ${isActive ? 'fill-current' : ''}`} strokeWidth={isActive ? 2.5 : 2} />
<span className="text-[9px] font-bold hidden sm:inline">{item.label}</span>
</button>
);
};