5fef584d7d
- Added visually hidden labels, IDs, and names to form inputs across all key components and pages (modals, directories, chatbots) - Added tabIndex and onKeyDown handlers to clickable table rows in OwnerProjectDetail for keyboard accessibility - Validated existing ARIA roles and Escape key bindings on modal components
854 lines
46 KiB
React
854 lines
46 KiB
React
import React, { useState, useEffect, useRef } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import Groq from 'groq-sdk';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import { useAuth } from '../context/AuthContext';
|
|
import { useMockStore } from '../data/mockStore';
|
|
import { MessageCircle, Send, X, Minimize2, Maximize2, Loader2, Calendar, Trophy } from 'lucide-react';
|
|
import { logger } from '../utils/logger';
|
|
import { toast } from 'sonner';
|
|
import { config } from '../config/env';
|
|
|
|
const groq = new Groq({
|
|
apiKey: config.groqApiKey,
|
|
dangerouslyAllowBrowser: true // Allowed for client-side demo
|
|
});
|
|
|
|
// --- CONTEXT GENERATOR (Deep, role-aware with cross-role visibility) ---
|
|
|
|
const generateRoleContext = (user, store) => {
|
|
const { meetings, properties, salesHistory, users, vendors, personnel, documents, projects, owners, orders, vendorInvoices } = store;
|
|
const TODAY = '2026-02-18';
|
|
const todayDate = new Date(TODAY);
|
|
|
|
const BASE = `You are the LynkedUp Pro AI Assistant — an intelligent operations concierge for a construction & roofing management platform.
|
|
Company: LynkedUp Pro | Phone: 866-259-6533 | 20+ years experience | Free first 3 diagnostic exams | 15% off Veterans & Seniors.
|
|
Today's Date: ${TODAY}. Tone: Professional, data-driven, concise. Use markdown formatting. Never fabricate data — only reference what's provided below.
|
|
|
|
`;
|
|
|
|
if (!user) {
|
|
// Guest / unauthenticated
|
|
return BASE + `ROLE: EXPERT ROOFING CONSULTANT (Guest)
|
|
|
|
KNOWLEDGE BASE:
|
|
- Active Leak: Water saturates insulation/decking → mold in 24-48h. Urgency: map moisture path immediately.
|
|
- Missing Shingles: Underlayment exposed to UV → destroyed in days. Risk: insurance may deny for "neglect".
|
|
- Hail Dents: Fractured fiberglass mat → reduces roof life 3-5 years. Document now or insurance calls it "wear and tear".
|
|
- Granule Loss: Asphalt baking in Texas sun → roof failure in 12-18 months.
|
|
- Ice Dams: Heat loss from attic → assess intake vents, not just patch.
|
|
- Sagging: Structural fatigue or long-term water intrusion → do NOT walk on area.
|
|
|
|
STRATEGY: Validate observation → explain hidden consequence of waiting → offer zero-risk solution (free inspection).
|
|
HOOK: "Insurance adjusters use specific criteria. We know exactly what they look for." | "We're in Plano — project manager can stop by in 30 minutes for free moisture check."
|
|
Contact: 866-259-6533`;
|
|
}
|
|
|
|
// ──────────────── OWNER ────────────────
|
|
if (user.role === 'OWNER') {
|
|
const ownerProfile = (owners || []).find(o => o.id === user.id);
|
|
const myProjects = projects.filter(p => p.ownerId === user.id);
|
|
const activeProj = myProjects.filter(p => p.status === 'active');
|
|
const completedProj = myProjects.filter(p => p.status === 'completed');
|
|
const onHoldProj = myProjects.filter(p => p.status === 'on_hold');
|
|
const delayedProj = myProjects.filter(p => p.status === 'delayed');
|
|
const totalBudget = myProjects.reduce((s, p) => s + (p.budget || 0), 0);
|
|
const totalSpent = myProjects.reduce((s, p) => s + (p.spent || 0), 0);
|
|
const pendingInvoices = myProjects.reduce((s, p) => s + (p.invoices || []).filter(i => i.status === 'pending').reduce((is, i) => is + i.amount, 0), 0);
|
|
|
|
// Vendor compliance overview (owners see all vendors)
|
|
const activeVendors = vendors.filter(v => v.status === 'active');
|
|
const nonCompliant = vendors.filter(v => v.status !== 'active');
|
|
const vendorSummary = vendors.slice(0, 6).map(v => {
|
|
const coiStatus = v.compliance?.coi?.status || 'unknown';
|
|
const w9Status = v.compliance?.w9?.status || 'unknown';
|
|
return ` - ${v.vendorName}: COI=${coiStatus}, W9=${w9Status}, Rating=${v.performance?.rating || 'N/A'}/5, Spend=$${(v.spend?.totalSpend || 0).toLocaleString()}`;
|
|
}).join('\n');
|
|
|
|
// Documents overview
|
|
const pendingDocs = documents.filter(d => d.status === 'pending_review').length;
|
|
const expiredDocs = documents.filter(d => d.status === 'expired').length;
|
|
const expiringSoon = documents.filter(d => {
|
|
if (!d.expirationDate || d.status === 'expired') return false;
|
|
const diff = (new Date(d.expirationDate) - todayDate) / (1000 * 60 * 60 * 24);
|
|
return diff > 0 && diff <= 30;
|
|
}).length;
|
|
|
|
// Personnel overview
|
|
const activeStaff = personnel.filter(p => p.status === 'active').length;
|
|
const totalStaff = personnel.length;
|
|
|
|
// Project details
|
|
const projDetails = myProjects.map(p => {
|
|
const variance = p.budget > 0 ? Math.round(((p.spent - p.budget) / p.budget) * 100) : 0;
|
|
const nextMs = (p.milestones || []).find(m => m.status !== 'completed');
|
|
return ` - ${p.address} (${p.projectType}): ${p.status.toUpperCase()}, ${p.completionPercentage || 0}% done, Budget $${p.budget.toLocaleString()}, Spent $${p.spent.toLocaleString()} (${variance > 0 ? '+' : ''}${variance}%), Next: ${nextMs ? `${nextMs.name} by ${nextMs.dueDate}` : 'N/A'}`;
|
|
}).join('\n');
|
|
|
|
// Upcoming milestones across all owner projects
|
|
const upcomingMs = myProjects.flatMap(p => (p.milestones || []).filter(m => {
|
|
const diff = (new Date(m.dueDate) - todayDate) / (1000 * 60 * 60 * 24);
|
|
return diff >= -3 && diff <= 30 && m.status !== 'completed';
|
|
}).map(m => ({ ...m, address: p.address }))).sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate));
|
|
|
|
const msText = upcomingMs.slice(0, 8).map(m => ` - ${m.name} @ ${m.address} — due ${m.dueDate} (${m.status})`).join('\n');
|
|
|
|
const dashSummary = ownerProfile?.dashboardSummary;
|
|
const riskScore = dashSummary?.riskScore ?? ownerProfile?.riskScore ?? 'N/A';
|
|
const roiProjection = dashSummary?.roiProjection ?? 'N/A';
|
|
|
|
return BASE + `ROLE: OWNER — ${user.name} (${ownerProfile?.companyName || user.companyName || 'N/A'})
|
|
|
|
EXECUTIVE SUMMARY:
|
|
- Total Projects: ${myProjects.length} (Active: ${activeProj.length}, Completed: ${completedProj.length}, On Hold: ${onHoldProj.length}, Delayed: ${delayedProj.length})
|
|
- Total Budget: $${totalBudget.toLocaleString()} | Total Spent: $${totalSpent.toLocaleString()} | Remaining: $${(totalBudget - totalSpent).toLocaleString()}
|
|
- Pending Invoice Payouts: $${pendingInvoices.toLocaleString()}
|
|
- Risk Score: ${riskScore}/100 (lower is better) | ROI Projection: ${roiProjection}%
|
|
- Active Staff: ${activeStaff}/${totalStaff}
|
|
|
|
MY PROJECTS:
|
|
${projDetails || ' None.'}
|
|
|
|
UPCOMING MILESTONES (Next 30 Days):
|
|
${msText || ' None due.'}
|
|
|
|
VENDOR COMPLIANCE (Cross-Role: Owner sees ALL vendors):
|
|
- Active: ${activeVendors.length} | Non-Compliant: ${nonCompliant.length}
|
|
${vendorSummary}
|
|
|
|
DOCUMENT STATUS:
|
|
- Pending Review: ${pendingDocs} | Expired: ${expiredDocs} | Expiring Soon (30d): ${expiringSoon}
|
|
|
|
INSTRUCTIONS:
|
|
- You are the Business Intelligence Architect for this owner.
|
|
- Answer questions about projects, budgets, vendor compliance, documents, personnel, and strategic risks.
|
|
- When asked "which vendor is non-compliant?", list them by name with specific issues (COI/W9 status).
|
|
- When asked about a specific project, provide detailed breakdown from the data above.
|
|
- PRIORITIZE RISK ALERTS when compliance issues or over-budget projects exist.`;
|
|
}
|
|
|
|
// ──────────────── ADMIN ────────────────
|
|
if (user.role === 'ADMIN') {
|
|
// Urgent action center
|
|
const unassigned = properties.filter(p => !p.assignedAgentId && p.canvassingStatus === 'Lead').length;
|
|
const hotLeadCount = properties.filter(p => p.canvassingStatus === 'Hot Lead').length;
|
|
const rescheduleNeeded = meetings.filter(m => m.status === 'Cancelled' || m.status === 'Missed').length;
|
|
const pendingSigs = properties.filter(p => p.pendingSignature).length;
|
|
|
|
// Leaderboard
|
|
const agentStats = {};
|
|
salesHistory.forEach(tx => {
|
|
if (tx.status === 'closed_won') {
|
|
if (!agentStats[tx.agentId]) agentStats[tx.agentId] = { revenue: 0, volume: 0 };
|
|
agentStats[tx.agentId].revenue += tx.amount;
|
|
agentStats[tx.agentId].volume += 1;
|
|
}
|
|
});
|
|
const leaderboard = Object.entries(agentStats).map(([id, stats]) => {
|
|
const agent = users.find(u => u.id === id);
|
|
return { name: agent?.name || id, ...stats };
|
|
}).sort((a, b) => b.revenue - a.revenue).slice(0, 5);
|
|
const lbText = leaderboard.map((s, i) => ` ${i + 1}. ${s.name} — $${s.revenue.toLocaleString()} (${s.volume} deals)`).join('\n');
|
|
|
|
// Pipeline
|
|
const totalProps = properties.length;
|
|
const statusBreakdown = {};
|
|
properties.forEach(p => { statusBreakdown[p.canvassingStatus] = (statusBreakdown[p.canvassingStatus] || 0) + 1; });
|
|
const pipelineText = Object.entries(statusBreakdown).map(([k, v]) => ` - ${k}: ${v}`).join('\n');
|
|
|
|
// Meetings today
|
|
const todayMeetings = meetings.filter(m => m.date === TODAY);
|
|
|
|
// All projects overview (admin sees everything)
|
|
const allActive = projects.filter(p => p.status === 'active').length;
|
|
const allBudget = projects.reduce((s, p) => s + (p.budget || 0), 0);
|
|
const allSpent = projects.reduce((s, p) => s + (p.spent || 0), 0);
|
|
|
|
// Vendor summary (admin sees all)
|
|
const nonCompliant = vendors.filter(v => v.status !== 'active');
|
|
|
|
// Personnel
|
|
const activeStaff = personnel.filter(p => p.status === 'active');
|
|
|
|
// Documents
|
|
const pendingDocs = documents.filter(d => d.status === 'pending_review').length;
|
|
|
|
return BASE + `ROLE: ADMIN — ${user.name}
|
|
|
|
URGENT ACTION CENTER:
|
|
- Unassigned Leads: ${unassigned}
|
|
- Hot Leads Needing Follow-Up: ${hotLeadCount}
|
|
- Reschedule Needed (Cancelled/Missed): ${rescheduleNeeded}
|
|
- Pending Signatures: ${pendingSigs}
|
|
- Documents Pending Review: ${pendingDocs}
|
|
|
|
SALES PIPELINE (${totalProps} properties):
|
|
${pipelineText}
|
|
|
|
LEADERBOARD (All-Time Closed Won):
|
|
${lbText || ' No data.'}
|
|
|
|
TODAY'S MEETINGS (${todayMeetings.length}):
|
|
${todayMeetings.slice(0, 5).map(m => ` - ${m.time} with ${m.customerName} (${m.status})`).join('\n') || ' None scheduled.'}
|
|
|
|
PROJECTS OVERVIEW (Cross-Role: Admin sees ALL):
|
|
- Active: ${allActive}/${projects.length} | Total Budget: $${allBudget.toLocaleString()} | Spent: $${allSpent.toLocaleString()}
|
|
|
|
VENDOR COMPLIANCE:
|
|
- Total Vendors: ${vendors.length} | Non-Compliant: ${nonCompliant.length}
|
|
${nonCompliant.map(v => ` - ${v.vendorName}: Status=${v.status}`).join('\n') || ' All compliant.'}
|
|
|
|
STAFF: ${activeStaff.length} active / ${personnel.length} total
|
|
|
|
INSTRUCTIONS:
|
|
- You manage the entire operation. Answer questions about pipeline, agents, revenue, meetings, and compliance.
|
|
- When asked "who's the top performer?", use the leaderboard data.
|
|
- When asked about a specific agent, cross-reference meetings + salesHistory.
|
|
- You can also answer questions about projects, vendors, and documents (full cross-role access).`;
|
|
}
|
|
|
|
// ──────────────── FIELD AGENT ────────────────
|
|
if (user.role === 'FIELD_AGENT') {
|
|
const myMeetings = meetings.filter(m => m.agentId === user.id).sort((a, b) => new Date(a.date) - new Date(b.date));
|
|
const todayMeetings = myMeetings.filter(m => m.date === TODAY);
|
|
const upcomingMeetings = myMeetings.filter(m => new Date(m.date) >= todayDate);
|
|
|
|
const agendaText = upcomingMeetings.slice(0, 8).map(m => {
|
|
const isToday = m.date === TODAY;
|
|
return ` - [${m.date} @ ${m.time}] ${m.status} with ${m.customerName}${isToday ? ' **(TODAY)**' : ''} — ${m.issueDescription || 'General'}`;
|
|
}).join('\n');
|
|
|
|
const hotLeads = properties.filter(p => p.canvassingStatus === 'Hot Lead').slice(0, 5)
|
|
.map(p => ` - ${p.propertyData?.propertyAddress || p.address || 'N/A'} (Value: $${(p.propertyData?.currentEstimatedMarketValue || 0).toLocaleString()})`).join('\n');
|
|
|
|
const goldenLeads = properties.filter(p => p.propertyData?.yearBuilt < 2000 && p.propertyData?.currentEstimatedMarketValue > 400000).slice(0, 3)
|
|
.map(p => ` - ${p.propertyData.propertyAddress} (Built: ${p.propertyData.yearBuilt})`).join('\n');
|
|
|
|
const pendingSigs = properties.filter(p => p.pendingSignature || p.propertyData?.pendingSignature)
|
|
.map(p => ` - ${p.propertyData?.propertyAddress || 'N/A'} (Owner: ${p.ownerData?.fullName || 'N/A'})`).join('\n');
|
|
|
|
const myWins = meetings.filter(m => m.agentId === user.id && m.status === 'Converted');
|
|
const myCompleted = meetings.filter(m => m.agentId === user.id && (m.status === 'Converted' || m.status === 'Completed'));
|
|
const totalRevenue = myWins.reduce((s, m) => s + (m.dealValue || 0), 0);
|
|
const winRate = myCompleted.length > 0 ? Math.round((myWins.length / myCompleted.length) * 100) : 0;
|
|
|
|
return BASE + `ROLE: FIELD AGENT — ${user.name}
|
|
|
|
MY AGENDA (${todayMeetings.length} today, ${upcomingMeetings.length} upcoming):
|
|
${agendaText || ' No upcoming meetings.'}
|
|
|
|
HOT LEADS (Priority):
|
|
${hotLeads || ' None.'}
|
|
|
|
GOLDEN LEADS (High Value / Older Homes):
|
|
${goldenLeads || ' None.'}
|
|
|
|
PENDING SIGNATURES:
|
|
${pendingSigs || ' None.'}
|
|
|
|
MY PERFORMANCE:
|
|
- Revenue (YTD): $${totalRevenue.toLocaleString()}
|
|
- Win Rate: ${winRate}% (${myWins.length} wins / ${myCompleted.length} completed)
|
|
|
|
TERRITORY: ${properties.length} properties tracked in DFW.
|
|
|
|
INSTRUCTIONS:
|
|
- Help with agenda, leads, performance stats, and territory insights.
|
|
- When asked "what's next?", show the next upcoming meeting.
|
|
- When asked about leads, suggest Hot or Golden leads.
|
|
- Be efficient and action-oriented.`;
|
|
}
|
|
|
|
// ──────────────── CONTRACTOR ────────────────
|
|
if (user.role === 'CONTRACTOR') {
|
|
const myProjects = projects.filter(p => p.contractorId === user.id || p.contractorId === 'con_001');
|
|
const activeProj = myProjects.filter(p => p.status === 'active');
|
|
const totalBudget = myProjects.reduce((s, p) => s + (p.budget || 0), 0);
|
|
const totalSpent = myProjects.reduce((s, p) => s + (p.spent || 0), 0);
|
|
const pendingInvoices = myProjects.reduce((s, p) => s + (p.invoices || []).filter(i => i.status === 'pending').reduce((is, i) => is + i.amount, 0), 0);
|
|
|
|
const projDetails = myProjects.map(p => {
|
|
const nextMs = (p.milestones || []).find(m => m.status !== 'completed');
|
|
return ` - ${p.address} (${p.projectType}): ${p.status.toUpperCase()}, ${p.completionPercentage || 0}% done, Budget $${p.budget.toLocaleString()}, Spent $${p.spent.toLocaleString()}, Next: ${nextMs ? `${nextMs.name} by ${nextMs.dueDate}` : 'All complete'}`;
|
|
}).join('\n');
|
|
|
|
// Upcoming milestones
|
|
const upcomingMs = myProjects.flatMap(p => (p.milestones || []).filter(m => {
|
|
const diff = (new Date(m.dueDate) - todayDate) / (1000 * 60 * 60 * 24);
|
|
return diff >= -3 && diff <= 14 && m.status !== 'completed';
|
|
}).map(m => ({ ...m, address: p.address }))).sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate));
|
|
|
|
const msText = upcomingMs.map(m => ` - ${m.name} @ ${m.address} — due ${m.dueDate} (${m.status})`).join('\n');
|
|
|
|
// My documents / compliance
|
|
const myDocs = documents.filter(d => d.entityId === user.id || d.entityId === 'con_001');
|
|
const expiredDocs = myDocs.filter(d => d.status === 'expired');
|
|
|
|
// Cross-role: Contractor sees subcontractors on their projects
|
|
const subIds = [...new Set(myProjects.flatMap(p => p.subcontractorIds || []))];
|
|
const subUsers = users.filter(u => subIds.includes(u.id));
|
|
|
|
// Cross-role: Contractor sees vendors delivering to their projects
|
|
const vendorNames = [...new Set(myProjects.flatMap(p => (p.milestones || []).filter(m => m.vendorId).map(m => {
|
|
const v = vendors.find(vv => vv.id === m.vendorId);
|
|
return v ? v.vendorName : m.vendorId;
|
|
})))];
|
|
|
|
return BASE + `ROLE: CONTRACTOR — ${user.name}
|
|
|
|
MY PROJECTS (${myProjects.length} total, ${activeProj.length} active):
|
|
${projDetails || ' None assigned.'}
|
|
|
|
CRITICAL TASKS (Next 14 Days):
|
|
${msText || ' No critical tasks due.'}
|
|
|
|
FINANCIALS:
|
|
- Total Budget: $${totalBudget.toLocaleString()} | Spent: $${totalSpent.toLocaleString()}
|
|
- Pending Invoice Payouts: $${pendingInvoices.toLocaleString()}
|
|
|
|
COMPLIANCE:
|
|
${expiredDocs.length > 0 ? `- WARNING: ${expiredDocs.length} expired documents need renewal: ${expiredDocs.map(d => d.name).join(', ')}` : '- All documents current.'}
|
|
|
|
CROSS-ROLE VISIBILITY:
|
|
- My Subcontractors: ${subUsers.map(s => s.name).join(', ') || 'None assigned'}
|
|
- Vendors on My Projects: ${vendorNames.join(', ') || 'None'}
|
|
|
|
INSTRUCTIONS:
|
|
- Help track project progress, deadlines, invoices, and crew coordination.
|
|
- Only discuss THIS contractor's projects — never reveal other contractors' data.
|
|
- When asked about a subcontractor, reference their tasks on shared projects.`;
|
|
}
|
|
|
|
// ──────────────── SUBCONTRACTOR ────────────────
|
|
if (user.role === 'SUBCONTRACTOR') {
|
|
const myId = user.id || 'sub_001';
|
|
const myProjects = projects.filter(p => (p.subcontractorIds || []).includes(myId));
|
|
|
|
// Only milestones assigned to this sub
|
|
const myTasks = myProjects.flatMap(p => (p.milestones || []).filter(m => m.assignedTo === myId).map(m => ({ ...m, address: p.address, projectId: p.id })));
|
|
const activeTasks = myTasks.filter(t => t.status === 'in_progress');
|
|
const pendingTasks = myTasks.filter(t => t.status === 'pending');
|
|
const completedTasks = myTasks.filter(t => t.status === 'completed');
|
|
|
|
const taskDetails = myTasks.filter(t => t.status !== 'completed').map(t => ` - ${t.name} @ ${t.address} — ${t.status} (due ${t.dueDate})`).join('\n');
|
|
|
|
// Pending payouts (from invoices on projects I'm on)
|
|
const pendingPay = myProjects.reduce((s, p) => s + (p.invoices || []).filter(i => i.status === 'pending' && (i.payeeTo === myId || i.payeeTo === 'sub_001')).reduce((is, i) => is + i.amount, 0), 0);
|
|
|
|
// My compliance docs
|
|
const myDocs = documents.filter(d => d.entityId === myId);
|
|
const expiredDocs = myDocs.filter(d => d.status === 'expired');
|
|
|
|
// Cross-role: Sub sees the contractor managing their projects
|
|
const contractorIds = [...new Set(myProjects.map(p => p.contractorId))];
|
|
const contractorUsers = users.filter(u => contractorIds.includes(u.id));
|
|
|
|
return BASE + `ROLE: SUBCONTRACTOR — ${user.name}
|
|
|
|
MY TASKS (${activeTasks.length} active, ${pendingTasks.length} pending, ${completedTasks.length} completed):
|
|
${taskDetails || ' No open tasks.'}
|
|
|
|
ASSIGNED PROJECTS: ${myProjects.map(p => `${p.address} (${p.status})`).join(', ') || 'None'}
|
|
|
|
FINANCIALS:
|
|
- Pending Payouts: $${pendingPay.toLocaleString()}
|
|
|
|
COMPLIANCE:
|
|
${expiredDocs.length > 0 ? `- WARNING: ${expiredDocs.length} expired docs: ${expiredDocs.map(d => d.name).join(', ')}` : '- All documents current.'}
|
|
|
|
CROSS-ROLE VISIBILITY:
|
|
- My General Contractor: ${contractorUsers.map(c => c.name).join(', ') || 'N/A'}
|
|
|
|
INSTRUCTIONS:
|
|
- Help track task progress, deadlines, and payouts.
|
|
- Only show THIS subcontractor's tasks — never reveal other subs' data or full project budgets.
|
|
- When asked "what's next?", show the nearest pending/in-progress task by due date.`;
|
|
}
|
|
|
|
// ──────────────── VENDOR ────────────────
|
|
if (user.role === 'VENDOR') {
|
|
// Map user to vendor record
|
|
const vendorMap = { 'ven_001': 'v3', 'ven_002': 'v1' };
|
|
const vendorId = vendorMap[user.id] || 'v1';
|
|
const myVendor = vendors.find(v => v.id === vendorId) || vendors[0];
|
|
|
|
const coiStatus = myVendor.compliance?.coi?.status || 'unknown';
|
|
const coiExpiry = myVendor.compliance?.coi?.expirationDate || 'N/A';
|
|
const w9Status = myVendor.compliance?.w9?.status || 'unknown';
|
|
const rating = myVendor.performance?.rating || 'N/A';
|
|
const onTime = myVendor.performance?.onTimeDelivery || 'N/A';
|
|
const defectRate = myVendor.performance?.defectRate || 'N/A';
|
|
|
|
// Orders for this vendor
|
|
const myOrders = (orders || []).filter(o => o.vendorId === vendorId);
|
|
const activeOrders = myOrders.filter(o => o.status === 'shipped' || o.status === 'processing');
|
|
const orderText = myOrders.slice(0, 5).map(o => ` - PO#${o.id}: ${o.items?.map(i => i.name).join(', ') || 'Items'} — ${o.status.toUpperCase()}, $${(o.total || 0).toLocaleString()}, ETA: ${o.estimatedDelivery || 'N/A'}`).join('\n');
|
|
|
|
// Invoices for this vendor
|
|
const myInvoices = (vendorInvoices || []).filter(i => i.vendorId === vendorId);
|
|
const pendingInv = myInvoices.filter(i => i.status === 'pending');
|
|
const totalPending = pendingInv.reduce((s, i) => s + (i.amount || 0), 0);
|
|
const invText = myInvoices.slice(0, 5).map(i => ` - INV#${i.id}: $${(i.amount || 0).toLocaleString()} — ${i.status.toUpperCase()} (${i.dueDate || 'N/A'})`).join('\n');
|
|
|
|
// Spend
|
|
const totalSpend = myVendor.spend?.totalSpend || 0;
|
|
const ytdSpend = myVendor.spend?.ytdSpend || totalSpend;
|
|
|
|
return BASE + `ROLE: VENDOR — ${user.name} (${myVendor.vendorName})
|
|
|
|
COMPANY PROFILE:
|
|
- Category: ${myVendor.category || 'N/A'} | Status: ${myVendor.status || 'N/A'}
|
|
- Rating: ${rating}/5.0 | On-Time Delivery: ${onTime}% | Defect Rate: ${defectRate}%
|
|
|
|
COMPLIANCE:
|
|
- COI: ${coiStatus} (expires ${coiExpiry})
|
|
- W-9: ${w9Status}
|
|
${coiStatus === 'expired' || w9Status === 'expired' ? '⚠️ WARNING: Expired documents — upload renewed versions to avoid order holds.' : ''}
|
|
|
|
ORDERS (${myOrders.length} total, ${activeOrders.length} active):
|
|
${orderText || ' No orders.'}
|
|
|
|
INVOICES:
|
|
- Pending: ${pendingInv.length} ($${totalPending.toLocaleString()})
|
|
${invText || ' No invoices.'}
|
|
|
|
SPEND:
|
|
- Total All-Time: $${totalSpend.toLocaleString()}
|
|
- YTD: $${ytdSpend.toLocaleString()}
|
|
|
|
INSTRUCTIONS:
|
|
- Help track orders, invoices, compliance status, and deliveries.
|
|
- Only show THIS vendor's data — never reveal other vendors' info or full project financials.
|
|
- When asked about compliance, explain exactly what needs renewal and urgency.
|
|
- When asked "what orders are pending?", list active orders with ETAs.`;
|
|
}
|
|
|
|
// ──────────────── CUSTOMER ────────────────
|
|
if (user.role === 'CUSTOMER') {
|
|
const myProperty = properties.find(p => p.propertyData?.propertyId === user.propertyId)
|
|
|| properties.find(p => p.ownerData?.fullName === user.name);
|
|
|
|
let propDetails = ' Property details not found.';
|
|
let tips = 'Standard seasonal maintenance recommended.';
|
|
let warranty = 'Standard Manufacturer Warranty (check status with your rep)';
|
|
|
|
if (myProperty) {
|
|
const pd = myProperty.propertyData;
|
|
propDetails = ` - Address: ${pd.propertyAddress}
|
|
- Roof Type: ${pd.roofMaterial} | Condition: ${pd.roofCondition}
|
|
- Storm Risk: ${pd.neighborhoodRating}/10
|
|
- Est. Value: $${pd.currentEstimatedMarketValue?.toLocaleString() || 'N/A'}
|
|
- Last Inspection: ${pd.lastInspectionDate || 'Unknown'}`;
|
|
|
|
if (pd.roofCondition === 'Needs Repair') tips = 'URGENT: Schedule an inspection immediately to prevent water damage.';
|
|
else if (pd.treeCoverage > 50) tips = 'Consider trimming overhanging branches to protect shingles.';
|
|
|
|
if (pd.yearBuilt > 2015) warranty = 'Active Workmanship Warranty (Valid until 2030)';
|
|
}
|
|
|
|
const myMeetings = meetings.filter(m => m.customerId === user.id || m.customerName === user.name)
|
|
.sort((a, b) => new Date(b.date) - new Date(a.date));
|
|
const meetingText = myMeetings.slice(0, 5).map(m => {
|
|
const isFuture = new Date(m.date) > todayDate;
|
|
return ` - [${m.date}] ${isFuture ? 'UPCOMING' : 'COMPLETED'} (${m.status}) — ${m.notes || 'No notes'}`;
|
|
}).join('\n');
|
|
|
|
const totalSpend = myMeetings.reduce((s, m) => s + (m.dealValue || 0), 0);
|
|
|
|
return BASE + `ROLE: HOMEOWNER — ${user.name}
|
|
|
|
MY PROPERTY:
|
|
${propDetails}
|
|
|
|
SERVICE HISTORY:
|
|
${meetingText || ' No records found.'}
|
|
|
|
FINANCIALS:
|
|
- Total Spend: $${totalSpend.toLocaleString()}
|
|
- Warranty: ${warranty}
|
|
|
|
ADVICE: ${tips}
|
|
|
|
INSTRUCTIONS:
|
|
- Answer questions about "my roof", "my appointment", "warranty", or "spending".
|
|
- Be polite, reassuring, and helpful.
|
|
- Offer to schedule a free inspection if concerns arise: 866-259-6533.`;
|
|
}
|
|
|
|
// ──────────────── FALLBACK ────────────────
|
|
return BASE + `ROLE: ${user.role} — ${user.name}
|
|
You are logged in but your role context is being set up. I can help with general questions about LynkedUp Pro.`;
|
|
};
|
|
|
|
const Chatbot = (props) => {
|
|
const { user, isAuthenticated } = useAuth();
|
|
// Pull ALL data needed for context generation
|
|
const storeData = useMockStore();
|
|
// Destructure specifically for local usage if needed, but we pass the whole object to context functions
|
|
const { addMeeting, meetings, properties, salesHistory, users, vendors, personnel, documents, projects, owners, orders, vendorInvoices } = storeData;
|
|
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isMinimized, setIsMinimized] = useState(false);
|
|
const [input, setInput] = useState('');
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [isDemoMode, setIsDemoMode] = useState(false);
|
|
const [messages, setMessages] = useState([
|
|
{ role: 'assistant', content: 'Hello! Welcome to LynkedUp Pro. How can I help you with your roofing needs today?' }
|
|
]);
|
|
const messagesEndRef = useRef(null);
|
|
|
|
// Initial check for API Key
|
|
useEffect(() => {
|
|
if (config.isDemoMode(config.groqApiKey)) {
|
|
setIsDemoMode(true);
|
|
setMessages(prev => [...prev, { role: 'system', content: '⚠️ Demo Mode: Default API Key detected. Responses will be simulated.' }]);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [messages]);
|
|
|
|
// Update greeting and context when user changes
|
|
useEffect(() => {
|
|
let greeting = 'Hello! Welcome to LynkedUp Pro. How can I help you with your roofing needs today?';
|
|
if (user) {
|
|
greeting = `Welcome back, ${user.name}! `;
|
|
if (user.role === 'ADMIN') {
|
|
greeting += "Operations HQ is live. Ask me about pipeline, agents, compliance, or revenue.";
|
|
} else if (user.role === 'FIELD_AGENT') {
|
|
greeting += "I have your territory data and agenda ready. What do you need?";
|
|
} else if (user.role === 'CUSTOMER') {
|
|
greeting += "I have your property records open. How can I assist?";
|
|
} else if (user.role === 'OWNER') {
|
|
greeting += "Executive Dashboard is live. Ask me about projects, budgets, vendors, or compliance.";
|
|
} else if (user.role === 'CONTRACTOR') {
|
|
greeting += "Command Center loaded. Need project status, deadlines, or invoice info?";
|
|
} else if (user.role === 'SUBCONTRACTOR') {
|
|
greeting += "Your tasks are loaded. Ask about deadlines, payouts, or compliance.";
|
|
} else if (user.role === 'VENDOR') {
|
|
greeting += "Your orders and compliance data are ready. Ask about invoices, deliveries, or documents.";
|
|
}
|
|
}
|
|
setMessages([{ role: 'assistant', content: greeting }]);
|
|
}, [user]);
|
|
|
|
const handleSend = async () => {
|
|
if (!input.trim()) return;
|
|
|
|
const originalInput = input; // Backup input
|
|
const newMessages = [...messages, { role: 'user', content: input }];
|
|
setMessages(newMessages);
|
|
setInput('');
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
if (isDemoMode) {
|
|
// ... existing demo logic ... (Simulated for brevity, keeping existing structure if needed, but replacing with smart demo response)
|
|
setTimeout(() => {
|
|
const responses = [
|
|
"I can help with that! At LynkedUp Pro, we've been in business for 20+ years. I can't access real-time data in demo mode, but I can help you navigate.",
|
|
"Please call us at 866-259-6533 for a real quote!",
|
|
"Would you like to schedule a callback? Please log in first."
|
|
];
|
|
const randomResponse = responses[Math.floor(Math.random() * responses.length)];
|
|
setMessages(prev => [...prev, { role: 'assistant', content: randomResponse }]);
|
|
setIsLoading(false);
|
|
}, 1000);
|
|
return;
|
|
}
|
|
|
|
// --- REAL AI REQUEST ---
|
|
|
|
// --- Generate deep, role-aware context ---
|
|
|
|
// 1. Build Dynamic Context
|
|
let systemContext = generateRoleContext(user, storeData);
|
|
|
|
// 2. Call Groq
|
|
const chatCompletion = await groq.chat.completions.create({
|
|
messages: [
|
|
{ role: 'system', content: systemContext },
|
|
...newMessages
|
|
],
|
|
model: 'qwen/qwen3-32b',
|
|
temperature: 0.7,
|
|
max_tokens: 1024,
|
|
});
|
|
|
|
let responseContent = chatCompletion.choices[0]?.message?.content || "I'm having trouble connecting right now.";
|
|
|
|
// Filter out <think> blocks if present
|
|
responseContent = responseContent.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
|
|
|
|
// Check for Function Call Pattern (Legacy Callback)
|
|
const callbackMatch = responseContent.match(/:::CALLBACK_REQUEST({.*?}):::/);
|
|
if (callbackMatch) {
|
|
const jsonStr = callbackMatch[1];
|
|
try {
|
|
const data = JSON.parse(jsonStr);
|
|
|
|
// Create Meeting / Callback
|
|
const mockDate = new Date();
|
|
mockDate.setDate(mockDate.getDate() + 1); // Next day default
|
|
|
|
const meeting = {
|
|
agentId: user ? user.id : 'e1', // Use logged in user ID
|
|
customerName: data.name || 'Potential Client',
|
|
propertyId: 'Callback Request',
|
|
date: mockDate.toISOString().split('T')[0],
|
|
time: data.time || '10:00',
|
|
status: 'Pending',
|
|
notes: `Callback requested for ${data.phone}`
|
|
};
|
|
addMeeting(meeting);
|
|
|
|
// Clean the response to hide the JSON
|
|
responseContent = responseContent.replace(callbackMatch[0], '').trim();
|
|
if (!responseContent) responseContent = "I've successfully scheduled a callback for you! We will contact you shortly.";
|
|
else responseContent += "\n\n✅ Callback scheduled successfully!";
|
|
|
|
} catch (e) {
|
|
console.error("Failed to parse callback JSON", e);
|
|
}
|
|
}
|
|
|
|
setMessages(prev => [...prev, { role: 'assistant', content: responseContent }]);
|
|
|
|
} catch (error) {
|
|
logger.error("Groq API Error", error);
|
|
setInput(originalInput); // <--- RESTORE INPUT ON ERROR
|
|
|
|
let errorMsg = "Sorry, I'm having trouble connecting to the Groq server.";
|
|
|
|
if (error.message) {
|
|
if (error.message.includes('401')) {
|
|
errorMsg += " (Error 401: Invalid API Key)";
|
|
toast.error("AI Configuration Error", { description: "Invalid API Key." });
|
|
}
|
|
else if (error.message.includes('404')) {
|
|
errorMsg += " (Error 404: Model not found)";
|
|
toast.error("AI Model Error", { description: "Model not found." });
|
|
}
|
|
else errorMsg += ` (${error.message})`;
|
|
}
|
|
|
|
setMessages(prev => [...prev, { role: 'assistant', content: errorMsg }]);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleKeyPress = (e) => {
|
|
if (e.key === 'Enter') handleSend();
|
|
};
|
|
|
|
// --- INLINE MODE RENDER ---
|
|
if (props.inline) {
|
|
return (
|
|
<div className="flex flex-col w-full h-full bg-slate-50 dark:bg-zinc-900/50">
|
|
{/* Header (Simplified) */}
|
|
<div className="bg-slate-900 dark:bg-black p-4 flex items-center justify-between shrink-0">
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-3 h-3 rounded-full bg-green-400 animate-pulse"></div>
|
|
<h3 className="text-white font-bold text-lg">LynkedUp AI Concierge</h3>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-50 dark:bg-zinc-950/50">
|
|
{messages.map((msg, i) => (
|
|
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
|
<div className={`max-w-[85%] p-3 rounded-2xl text-sm ${msg.role === 'user'
|
|
? 'bg-blue-600 text-white rounded-br-none'
|
|
: 'bg-white dark:bg-zinc-800 border border-gray-100 dark:border-zinc-700 shadow-sm text-slate-700 dark:text-zinc-200 rounded-bl-none'
|
|
}`}>
|
|
{msg.role === 'system' ? (
|
|
<em className="text-xs opacity-70">{msg.content}</em>
|
|
) : (
|
|
<div className="prose prose-sm dark:prose-invert max-w-none leading-relaxed break-words text-inherit">
|
|
<ReactMarkdown
|
|
components={{
|
|
ul: ({ node, ...props }) => <ul className="list-disc ml-4 space-y-1 my-2" {...props} />,
|
|
ol: ({ node, ...props }) => <ol className="list-decimal ml-4 space-y-1 my-2" {...props} />,
|
|
li: ({ node, ...props }) => <li className="pl-1" {...props} />,
|
|
p: ({ node, ...props }) => <p className="mb-2 last:mb-0" {...props} />,
|
|
strong: ({ node, ...props }) => <strong className="font-bold" {...props} />,
|
|
a: ({ node, ...props }) => <a className="underline hover:text-blue-200" target="_blank" rel="noopener noreferrer" {...props} />,
|
|
code: ({ node, inline, className, children, ...props }) => {
|
|
const match = /language-(\w+)/.exec(className || '');
|
|
return !inline ? (
|
|
<div className="relative my-4 rounded-lg overflow-hidden bg-zinc-900 text-zinc-100 dark:bg-black dark:border dark:border-zinc-800">
|
|
<div className="flex items-center justify-between px-4 py-2 bg-zinc-800/50 text-xs text-zinc-400 border-b border-white/5">
|
|
<span>{match?.[1] || 'code'}</span>
|
|
</div>
|
|
<pre className="p-4 overflow-x-auto selection:bg-blue-500/30">
|
|
<code className={className} {...props}>
|
|
{children}
|
|
</code>
|
|
</pre>
|
|
</div>
|
|
) : (
|
|
<code className="bg-zinc-200 dark:bg-zinc-700 px-1.5 py-0.5 rounded text-sm font-mono text-pink-600 dark:text-pink-400" {...props}>
|
|
{children}
|
|
</code>
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
{msg.content}
|
|
</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{isLoading && (
|
|
<div className="flex justify-start">
|
|
<div className="bg-white dark:bg-zinc-800 border border-gray-100 dark:border-zinc-700 p-3 rounded-2xl rounded-bl-none shadow-sm">
|
|
<Loader2 size={16} className="animate-spin text-blue-500" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div className="p-4 bg-white dark:bg-zinc-900 border-t border-gray-100 dark:border-zinc-800">
|
|
<div className="flex items-center space-x-2 bg-slate-100 dark:bg-zinc-800 rounded-full px-4 py-3">
|
|
<label htmlFor="chat-input-inline" className="sr-only">Message</label>
|
|
<input
|
|
id="chat-input-inline"
|
|
name="chat-input"
|
|
type="text"
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyPress={handleKeyPress}
|
|
placeholder="Type a message..."
|
|
className="flex-1 bg-transparent text-sm focus:outline-none text-slate-900 dark:text-white"
|
|
/>
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={!input.trim()}
|
|
className={`p-2 rounded-full transition-colors ${input.trim() ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-slate-300 dark:bg-zinc-600 text-slate-500 dark:text-zinc-400'}`}
|
|
>
|
|
<Send size={16} />
|
|
</button>
|
|
</div>
|
|
<div className="text-center mt-2">
|
|
<p className="text-xs text-slate-400 dark:text-zinc-500">Powered by Groq LPU™ & Qwen</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- WIDGET MODE (DEFAULT) ---
|
|
if (!isOpen) {
|
|
return createPortal(
|
|
<button
|
|
onClick={() => setIsOpen(true)}
|
|
className="fixed bottom-6 right-6 w-14 h-14 bg-gradient-to-tr from-blue-600 to-cyan-500 rounded-full shadow-xl shadow-blue-500/30 flex items-center justify-center text-white hover:scale-110 transition-transform z-[9999]"
|
|
>
|
|
<div className="absolute top-0 right-0 w-3 h-3 bg-green-400 rounded-full border-2 border-white animate-pulse"></div>
|
|
<MessageCircle size={28} />
|
|
</button>,
|
|
document.body
|
|
);
|
|
}
|
|
|
|
return createPortal(
|
|
<div className={`fixed bottom-6 right-6 bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl z-[9999] transition-all duration-300 overflow-hidden border border-gray-100 dark:border-zinc-800 flex flex-col ${isMinimized ? 'w-72 h-14' : 'w-80 md:w-96 h-[500px]'}`}>
|
|
|
|
{/* Header */}
|
|
<div className="bg-slate-900 dark:bg-black p-4 flex items-center justify-between shrink-0 cursor-pointer" onClick={() => !isMinimized && setIsMinimized(!isMinimized)}>
|
|
<div className="flex items-center space-x-2">
|
|
<div className="w-2 h-2 rounded-full bg-green-400 animate-pulse"></div>
|
|
<h3 className="text-white font-bold text-sm">LynkedUp AI</h3>
|
|
</div>
|
|
<div className="flex items-center space-x-1">
|
|
<button onClick={(e) => { e.stopPropagation(); setIsMinimized(!isMinimized); }} className="p-1 text-slate-400 hover:text-white">
|
|
{isMinimized ? <Maximize2 size={14} /> : <Minimize2 size={14} />}
|
|
</button>
|
|
<button onClick={(e) => { e.stopPropagation(); setIsOpen(false); }} className="p-1 text-slate-400 hover:text-red-400">
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
{!isMinimized && (
|
|
<>
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-slate-50 dark:bg-zinc-950/50">
|
|
{messages.map((msg, i) => (
|
|
<div key={i} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
|
<div className={`max-w-[85%] p-3 rounded-2xl text-sm ${msg.role === 'user'
|
|
? 'bg-blue-600 text-white rounded-br-none'
|
|
: 'bg-white dark:bg-zinc-800 border border-gray-100 dark:border-zinc-700 shadow-sm text-slate-700 dark:text-zinc-200 rounded-bl-none'
|
|
}`}>
|
|
{msg.role === 'system' ? (
|
|
<em className="text-xs opacity-70">{msg.content}</em>
|
|
) : (
|
|
<div className="prose prose-sm dark:prose-invert max-w-none leading-relaxed break-words text-inherit">
|
|
<ReactMarkdown
|
|
components={{
|
|
ul: ({ node, ...props }) => <ul className="list-disc ml-4 space-y-1 my-2" {...props} />,
|
|
ol: ({ node, ...props }) => <ol className="list-decimal ml-4 space-y-1 my-2" {...props} />,
|
|
li: ({ node, ...props }) => <li className="pl-1" {...props} />,
|
|
p: ({ node, ...props }) => <p className="mb-2 last:mb-0" {...props} />,
|
|
strong: ({ node, ...props }) => <strong className="font-bold" {...props} />,
|
|
a: ({ node, ...props }) => <a className="underline hover:text-blue-200" target="_blank" rel="noopener noreferrer" {...props} />
|
|
}}
|
|
>
|
|
{msg.content}
|
|
</ReactMarkdown>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{isLoading && (
|
|
<div className="flex justify-start">
|
|
<div className="bg-white dark:bg-zinc-800 border border-gray-100 dark:border-zinc-700 p-3 rounded-2xl rounded-bl-none shadow-sm">
|
|
<Loader2 size={16} className="animate-spin text-blue-500" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Input */}
|
|
<div className="p-3 bg-white dark:bg-zinc-900 border-t border-gray-100 dark:border-zinc-800">
|
|
<div className="flex items-center space-x-2 bg-slate-100 dark:bg-zinc-800 rounded-full px-4 py-2">
|
|
<label htmlFor="chat-input-widget" className="sr-only">Message</label>
|
|
<input
|
|
id="chat-input-widget"
|
|
name="chat-input"
|
|
type="text"
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyPress={handleKeyPress}
|
|
placeholder="Type a message..."
|
|
className="flex-1 bg-transparent text-sm focus:outline-none text-slate-900 dark:text-white"
|
|
/>
|
|
<button
|
|
onClick={handleSend}
|
|
disabled={!input.trim()}
|
|
className={`p-1.5 rounded-full transition-colors ${input.trim() ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-slate-300 dark:bg-zinc-600 text-slate-500 dark:text-zinc-400'}`}
|
|
>
|
|
<Send size={14} />
|
|
</button>
|
|
</div>
|
|
<div className="text-center mt-2">
|
|
<p className="text-[10px] text-slate-400 dark:text-zinc-500">Powered by Groq LPU™ & Qwen</p>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>,
|
|
document.body
|
|
);
|
|
};
|
|
|
|
export default Chatbot;
|