| import React, { useState, useRef, useEffect } from 'react'; |
| import { ChatMessage, User } from '../types'; |
| import { Send, Bot, User as UserIcon, X, Maximize2, Minimize2, Loader2, Sparkles } from 'lucide-react'; |
| import ReactMarkdown from 'react-markdown'; |
| import { motion, AnimatePresence } from 'framer-motion'; |
|
|
| interface LocalAssistantProps { |
| currentUser: User; |
| projectContext?: any; |
| } |
|
|
| const LocalAssistant: React.FC<LocalAssistantProps> = ({ currentUser, projectContext }) => { |
| const [isOpen, setIsOpen] = useState(false); |
| const [isMinimized, setIsMinimized] = useState(false); |
| const [input, setInput] = useState(''); |
| const [messages, setMessages] = useState<ChatMessage[]>([ |
| { |
| role: 'model', |
| parts: [{ text: "Hello! I'm BuildTrack Local Assistant. I can summarize project data without using any external AI service." }] |
| } |
| ]); |
| const [isLoading, setIsLoading] = useState(false); |
| const scrollRef = useRef<HTMLDivElement>(null); |
|
|
| useEffect(() => { |
| if (scrollRef.current) { |
| scrollRef.current.scrollTop = scrollRef.current.scrollHeight; |
| } |
| }, [messages]); |
|
|
| const buildLocalResponse = (question: string) => { |
| const project = projectContext; |
| const normalized = question.toLowerCase(); |
|
|
| if (!project) { |
| return "No project is selected yet. Open a project and I can summarize progress, risks, BOQ status, bills, documents, and pending suggestions from the local project data."; |
| } |
|
|
| const boq = project.boq || []; |
| const bills = project.bills || []; |
| const liabilities = project.liabilities || []; |
| const dprs = project.dprs || []; |
| const documents = project.documents || []; |
| const suggestions = project.aiSuggestions || []; |
| const planned = boq.reduce((sum: number, item: any) => sum + (item.plannedQty || 0) * (item.rate || 0), 0); |
| const executed = boq.reduce((sum: number, item: any) => sum + (item.executedQty || 0) * (item.rate || 0), 0); |
| const progress = planned > 0 ? ((executed / planned) * 100).toFixed(1) : "0.0"; |
| const pendingHigh = boq.filter((item: any) => item.priority === 'HIGH' && (item.executedQty || 0) < (item.plannedQty || 0)); |
| const money = (value: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value || 0); |
|
|
| if (normalized.includes('risk')) { |
| const risk = project.riskAssessment; |
| if (risk?.risks?.length) { |
| return `Current local risk score is **${risk.overallRiskScore}/100**.\n\n${risk.risks.slice(0, 3).map((r: any) => `- **${r.category} (${r.impact})**: ${r.description} Mitigation: ${r.mitigation}`).join('\n')}`; |
| } |
| return `No saved risk assessment is available yet. Based on local project data, I would first review ${pendingHigh.length} high-priority pending BOQ item(s), ${liabilities.length} liability record(s), and upcoming milestones.`; |
| } |
|
|
| if (normalized.includes('bill') || normalized.includes('finance') || normalized.includes('money')) { |
| const clientBilled = bills.filter((b: any) => b.type === 'CLIENT_RA').reduce((sum: number, b: any) => sum + (b.amount || 0), 0); |
| const expenses = bills.filter((b: any) => b.type !== 'CLIENT_RA').reduce((sum: number, b: any) => sum + (b.amount || 0), 0); |
| const liabilityTotal = liabilities.reduce((sum: number, l: any) => sum + (l.amount || 0), 0); |
| return `Financial snapshot:\n\n- Client billed: **${money(clientBilled)}**\n- Recorded expenses: **${money(expenses)}**\n- Open liabilities: **${money(liabilityTotal)}**\n- Executed value: **${money(executed)}**`; |
| } |
|
|
| if (normalized.includes('document') || normalized.includes('file')) { |
| return `This project has **${documents.length}** document(s). Pending local suggestions: **${suggestions.filter((s: any) => s.status === 'PENDING').length}**. Use the document manager's scan action to create local suggestions from file names and available text.`; |
| } |
|
|
| if (normalized.includes('boq') || normalized.includes('progress') || normalized.includes('status')) { |
| const pendingText = pendingHigh.length |
| ? pendingHigh.slice(0, 3).map((item: any) => `- ${item.description}: ${(item.plannedQty || 0) - (item.executedQty || 0)} ${item.unit} pending`).join('\n') |
| : "- No high-priority BOQ items are pending."; |
| return `Project status for **${project.name}**:\n\n- Progress: **${progress}%** by value\n- Planned value: **${money(planned)}**\n- Executed value: **${money(executed)}**\n- DPR entries: **${dprs.length}**\n\n${pendingText}`; |
| } |
|
|
| return `I am running fully locally for ${currentUser.name} (${currentUser.role}). For **${project.name}**, I can summarize progress, BOQ, risks, bills, DPRs, documents, and pending suggestions using data already stored in this app.`; |
| }; |
|
|
| const handleSend = async () => { |
| if (!input.trim() || isLoading) return; |
|
|
| const question = input.trim(); |
| const userMessage: ChatMessage = { |
| role: 'user', |
| parts: [{ text: question }] |
| }; |
|
|
| setMessages(prev => [...prev, userMessage]); |
| setInput(''); |
| setIsLoading(true); |
|
|
| try { |
| await new Promise(resolve => setTimeout(resolve, 250)); |
|
|
| const modelResponse: ChatMessage = { |
| role: 'model', |
| parts: [{ text: buildLocalResponse(question) }] |
| }; |
|
|
| setMessages(prev => [...prev, modelResponse]); |
| } catch (error) { |
| console.error("Local assistant error:", error); |
| setMessages(prev => [...prev, { |
| role: 'model', |
| parts: [{ text: "I could not process that local request. Please try a shorter question about progress, risk, bills, documents, or BOQ." }] |
| }]); |
| } finally { |
| setIsLoading(false); |
| } |
| }; |
|
|
| return ( |
| <div className="fixed bottom-6 right-6 z-50 flex flex-col items-end"> |
| <AnimatePresence> |
| {isOpen && ( |
| <motion.div |
| initial={{ opacity: 0, scale: 0.9, y: 20 }} |
| animate={{ |
| opacity: 1, |
| scale: 1, |
| y: 0, |
| height: isMinimized ? '64px' : '600px', |
| width: isMinimized ? '300px' : '400px' |
| }} |
| exit={{ opacity: 0, scale: 0.9, y: 20 }} |
| className="bg-white rounded-2xl shadow-2xl border border-slate-200 overflow-hidden mb-4 flex flex-col max-h-[80vh] w-[90vw] md:w-[400px]" |
| > |
| {/* Header */} |
| <div className="p-4 bg-blue-600 text-white flex items-center justify-between shrink-0"> |
| <div className="flex items-center gap-2"> |
| <div className="p-1.5 bg-white/20 rounded-lg"> |
| <Sparkles className="w-4 h-4 text-white" /> |
| </div> |
| <div> |
| <h3 className="font-bold text-sm leading-none">Local Assistant</h3> |
| <span className="text-[10px] text-blue-100 animate-pulse">Local & Ready</span> |
| </div> |
| </div> |
| <div className="flex items-center gap-1"> |
| <button |
| onClick={() => setIsMinimized(!isMinimized)} |
| className="p-1.5 hover:bg-white/10 rounded-lg transition-colors" |
| > |
| {isMinimized ? <Maximize2 className="w-4 h-4" /> : <Minimize2 className="w-4 h-4" />} |
| </button> |
| <button |
| onClick={() => setIsOpen(false)} |
| className="p-1.5 hover:bg-white/10 rounded-lg transition-colors" |
| > |
| <X className="w-4 h-4" /> |
| </button> |
| </div> |
| </div> |
| |
| {!isMinimized && ( |
| <> |
| {/* Messages */} |
| <div |
| ref={scrollRef} |
| className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-50" |
| > |
| {messages.map((m, i) => ( |
| <div |
| key={i} |
| className={`flex gap-3 ${m.role === 'user' ? 'flex-row-reverse' : ''}`} |
| > |
| <div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 ${ |
| m.role === 'user' ? 'bg-blue-600 text-white' : 'bg-white border border-slate-200 text-blue-600 shadow-sm' |
| }`}> |
| {m.role === 'user' ? <UserIcon className="w-4 h-4" /> : <Bot className="w-4 h-4" />} |
| </div> |
| <div className={`max-w-[80%] rounded-2xl p-3 text-sm shadow-sm ${ |
| m.role === 'user' |
| ? 'bg-blue-600 text-white rounded-tr-none' |
| : 'bg-white text-slate-700 rounded-tl-none border border-slate-100' |
| }`}> |
| <div className="prose prose-sm max-w-none prose-slate"> |
| <ReactMarkdown |
| components={{ |
| p: ({ children }) => <p className="mb-0">{children}</p>, |
| ul: ({ children }) => <ul className="my-1 list-disc pl-4">{children}</ul>, |
| ol: ({ children }) => <ol className="my-1 list-decimal pl-4">{children}</ol>, |
| }} |
| > |
| {m.parts[0].text} |
| </ReactMarkdown> |
| </div> |
| </div> |
| </div> |
| ))} |
| {isLoading && ( |
| <div className="flex gap-3"> |
| <div className="w-8 h-8 rounded-full bg-white border border-slate-200 text-blue-600 flex items-center justify-center shrink-0 shadow-sm"> |
| <Bot className="w-4 h-4" /> |
| </div> |
| <div className="bg-white border border-slate-100 rounded-2xl rounded-tl-none p-3 shadow-sm"> |
| <Loader2 className="w-4 h-4 animate-spin text-blue-600" /> |
| </div> |
| </div> |
| )} |
| </div> |
| |
| {/* Input */} |
| <div className="p-4 bg-white border-t border-slate-100 shrink-0"> |
| <div className="relative"> |
| <input |
| type="text" |
| placeholder="Ask local assistant..." |
| value={input} |
| onChange={(e) => setInput(e.target.value)} |
| onKeyDown={(e) => e.key === 'Enter' && handleSend()} |
| className="w-full pl-4 pr-12 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-600 outline-none text-sm transition-all" |
| /> |
| <button |
| onClick={handleSend} |
| disabled={!input.trim() || isLoading} |
| className="absolute right-1.5 top-1/2 -translate-y-1/2 p-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all disabled:bg-slate-300 disabled:shadow-none shadow-lg shadow-blue-200" |
| > |
| <Send className="w-4 h-4" /> |
| </button> |
| </div> |
| <p className="text-[10px] text-slate-400 mt-2 text-center"> |
| Local assistant uses project data stored in this app. |
| </p> |
| </div> |
| </> |
| )} |
| </motion.div> |
| )} |
| </AnimatePresence> |
|
|
| <motion.button |
| whileHover={{ scale: 1.05 }} |
| whileTap={{ scale: 0.95 }} |
| onClick={() => { |
| if (!isOpen) setIsOpen(true); |
| setIsMinimized(false); |
| }} |
| className={`w-14 h-14 rounded-full flex items-center justify-center shadow-2xl transition-colors ${ |
| isOpen ? 'bg-white text-blue-600 border border-blue-100' : 'bg-blue-600 text-white' |
| }`} |
| > |
| <Sparkles className="w-6 h-6" /> |
| </motion.button> |
| </div> |
| ); |
| }; |
|
|
| export default LocalAssistant; |
|
|