Files
mkd/components/Navigation.tsx
2026-02-04 00:17:04 +05:00

348 lines
16 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 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>
);
};