Initial commit MKD fixes
This commit is contained in:
347
components/Navigation.tsx
Executable file
347
components/Navigation.tsx
Executable file
@@ -0,0 +1,347 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user