feat: comprehensive mobile optimization, role-based AI chatbot, and multi-role dashboard enhancements

- Rewrote AI chatbot context system with deep role-aware data injection for all 7 roles (Owner, Admin, Field Agent, Contractor, Subcontractor, Vendor, Customer) plus guest fallback
- Added mobile-responsive bottom-sheet modals across all roles (ChangeOrderDrawer, InvoiceDetailModal, FinancialSummaryModal, VendorFinancialSummaryModal, FinancialDetailsModal)
- Converted Owner Project List and Vendor Orders tables to mobile card views with touch-friendly layouts
- Optimized Vendor Management and People Directory with mobile list/detail toggle and back navigation
- Fixed Document Control mobile view: horizontally scrollable filter tabs, proper overflow chain for document visibility
- Added responsive padding, text scaling, and flex-wrap across StatCard, TaskDetailsModal, OwnerSnapshot, OwnerProjectDetail, VendorDashboard, and DocumentManagement
- Expanded mock data store with richer vendor invoices, orders, documents, and compliance records
- Enhanced Owner Snapshot with financial KPI cards, urgent items panel, and Action Center modal
- Built Contractor Dashboard with task management, financial summary, and performance metrics
- Built Subcontractor Dashboard with clock-in tracking, task assignments, and invoice management
- Enhanced Vendor Dashboard with earnings summary, active orders, compliance status, and performance rating
- Added icon-only tab navigation on mobile for OwnerProjectDetail
- Extended attribution signatures across all platform pages
This commit is contained in:
Satyam
2026-02-18 12:34:55 +05:30
parent fe68947f1c
commit bc4e25f132
28 changed files with 3156 additions and 930 deletions
+433 -363
View File
@@ -14,355 +14,478 @@ const groq = new Groq({
dangerouslyAllowBrowser: true // Allowed for client-side demo
});
// --- CONTEXT GENERATORS ---
// --- CONTEXT GENERATOR (Deep, role-aware with cross-role visibility) ---
const BASE_IDENTITY = `
You are the AI Concierge for "LynkedUp Pro", the pinnacle of roofing excellence.
Your mission is to assist employees with data-driven insights and guide customers through our services.
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);
COMPANY INFO:
- **Phone**: 866-259-6533
- **Experience**: 20+ years of craftsmanship.
- **Offer**: First 3 diagnostic exams are FREE.
- **Discounts**: 15% off for Veterans & Seniors.
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.
TONE: Professional, efficient, and data-aware.
`;
const getEmployeeContext = (user, meetings, properties, salesHistory, users) => {
// --- ADMIN SPECIFIC CONTEXT ---
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') {
const unassignedCount = properties.filter(p => !p.assignedAgentId && p.canvassingStatus === 'Lead').length;
// Last contact > 24h ago logic
const hotLeadCount = properties.filter(p => p.canvassingStatus === 'Hot Lead' && (!p.lastContactDate || new Date(p.lastContactDate) < new Date(Date.now() - 86400000))).length;
const rescheduleCount = meetings.filter(m => m.status === 'Cancelled' || m.status === 'Missed').length;
const signatureCount = properties.filter(p => p.pendingSignature).length;
const totalUrgent = unassignedCount + hotLeadCount + rescheduleCount + signatureCount;
// 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 SNAPSHOT (Current Month) ---
const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
// Aggregate Sales by Agent
// Leaderboard
const agentStats = {};
salesHistory.forEach(tx => {
if (new Date(tx.date) >= startOfMonth && tx.status === 'closed_won') {
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');
// Convert to Array & Sort
const sortedStats = Object.keys(agentStats).map(agentId => {
const agent = users.find(u => u.id === agentId);
return {
name: agent ? agent.name : agentId,
revenue: agentStats[agentId].revenue,
volume: agentStats[agentId].volume
};
}).sort((a, b) => b.revenue - a.revenue).slice(0, 3);
// 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');
const leaderboardText = sortedStats.map((s, i) => `${i + 1}. ${s.name} - $${s.revenue.toLocaleString()}`).join('\n');
// Meetings today
const todayMeetings = meetings.filter(m => m.date === TODAY);
return `
ROLE: ADMIN (${user.name})
// 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);
*** URGENT ACTION CENTER SUMMARY (${totalUrgent} Items) ***
1. UNASSIGNED LEADS: ${unassignedCount}
- Action: Assign agents to these leads immediately.
2. HOT LEADS (Inactive > 24h): ${hotLeadCount}
- Action: These high-value leads need a follow-up call.
3. RESCHEDULE NEEDED: ${rescheduleCount}
- Action: Re-book these cancelled/missed appointments.
4. PENDING SIGNATURES: ${signatureCount}
- Action: Send reminders to close these deals.
// Vendor summary (admin sees all)
const nonCompliant = vendors.filter(v => v.status !== 'active');
GENERAL DATA ACCESS:
- Total Properties: ${properties.length}
- Total Revenue (All Agents): $${meetings.filter(m => m.status === 'Converted').reduce((sum, m) => sum + (m.dealValue || 0), 0).toLocaleString()}
// Personnel
const activeStaff = personnel.filter(p => p.status === 'active');
LEADERBOARD SNAPSHOT (Top 3):
${leaderboardText}
- Full details at /admin/leaderboard
`;
}
// Documents
const pendingDocs = documents.filter(d => d.status === 'pending_review').length;
// --- FIELD AGENT CONTEXT (Existing Logic) ---
// 1. Agenda (Today + Upcoming)
const today = new Date().toISOString().split('T')[0];
const myMeetings = meetings
.filter(m => m.agentId === user.id)
.sort((a, b) => new Date(a.date) - new Date(b.date));
return BASE + `ROLE: ADMIN — ${user.name}
// Simple segment: Today vs Upcoming
const agendaText = myMeetings.map(m => {
const isToday = m.date === today;
return `- [${m.date} @ ${m.time}] ${m.status.toUpperCase()} with ${m.customerName} (${m.propertyId})${isToday ? ' **(TODAY)**' : ''}
Issue: "${m.issueDescription || 'N/A'}"
Comments: "${m.customerComments || 'None'}"
Outcome: ${m.outcome || 'Pending'}`;
}).join('\n');
URGENT ACTION CENTER:
- Unassigned Leads: ${unassigned}
- Hot Leads Needing Follow-Up: ${hotLeadCount}
- Reschedule Needed (Cancelled/Missed): ${rescheduleNeeded}
- Pending Signatures: ${pendingSigs}
- Documents Pending Review: ${pendingDocs}
// 2. Hot Leads (Top 5)
const hotLeads = properties
.filter(p => p.canvassingStatus === 'Hot Lead')
.slice(0, 5)
.map(p => `- ${p.propertyData.propertyAddress} (Value: $${p.propertyData.currentEstimatedMarketValue.toLocaleString()})`)
.join('\n');
SALES PIPELINE (${totalProps} properties):
${pipelineText}
// 3. Golden Leads (Older + High Value)
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');
LEADERBOARD (All-Time Closed Won):
${lbText || ' No data.'}
// 4. Pending Signatures
const pendingSigs = properties
.filter(p => p.propertyData.pendingSignature)
.map(p => `- ${p.propertyData.propertyAddress} (Owner: ${p.ownerData.fullName})`)
.join('\n');
TODAY'S MEETINGS (${todayMeetings.length}):
${todayMeetings.slice(0, 5).map(m => ` - ${m.time} with ${m.customerName} (${m.status})`).join('\n') || ' None scheduled.'}
// 5. Revenue & Performance Snapshot
const myCompleted = meetings.filter(m => m.agentId === user.id && (m.status === 'Converted' || m.status === 'Completed'));
const myWins = meetings.filter(m => m.agentId === user.id && m.status === 'Converted');
const totalRevenue = myWins.reduce((sum, m) => sum + (m.dealValue || 0), 0);
const winRate = myCompleted.length > 0 ? Math.round((myWins.length / myCompleted.length) * 100) : 0;
PROJECTS OVERVIEW (Cross-Role: Admin sees ALL):
- Active: ${allActive}/${projects.length} | Total Budget: $${allBudget.toLocaleString()} | Spent: $${allSpent.toLocaleString()}
// 6. Neighborhood Intelligence
const avgValue = Math.round(properties.reduce((sum, p) => sum + p.propertyData.currentEstimatedMarketValue, 0) / properties.length);
const commonRoof = "Architectural Shingle"; // Hardcoded for demo, or could calculate mode
VENDOR COMPLIANCE:
- Total Vendors: ${vendors.length} | Non-Compliant: ${nonCompliant.length}
${nonCompliant.map(v => ` - ${v.vendorName}: Status=${v.status}`).join('\n') || ' All compliant.'}
return `
ROLE: FIELD AGENT / ADMIN (${user.name})
YOUR DATA ACCESS:
1. AGENDA:
${agendaText || "No meetings found."}
2. HOT LEADS (Priority):
${hotLeads || "None."}
3. GOLDEN LEADS (High Value / Old):
${goldenLeads || "None."}
4. PENDING SIGNATURES (Follow Up Needed):
${pendingSigs || "None."}
5. MY PERFORMANCE:
- Total Revenue (YTD): $${totalRevenue.toLocaleString()}
- Win Rate: ${winRate}% (${myWins.length} wins / ${myCompleted.length} completed)
6. TERRITORY INSIGHTS:
- Avg Home Value: $${avgValue.toLocaleString()}
- Most Common Roof: ${commonRoof}
STAFF: ${activeStaff.length} active / ${personnel.length} total
INSTRUCTIONS:
- If asked about "agenda", "schedule", or "next appointment", summarize the meetings.
- If asked about "leads", suggest the designated Hot or Golden leads.
- If asked about "performance" or "stats", share your win rate and revenue.
- If asked about "neighborhood" or "territory", share the average value and roof types.
`;
};
const getCustomerContext = (user, meetings, properties) => {
// 1. My Property
// In mock data, customers are linked via 'propertyId' or name matching.
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 preventiveTips = "Standard seasonal maintenance recommended.";
let warrantyStatus = "Standard Manufacturer Warranty (Expired)";
if (myProperty) {
propDetails = `
- Address: ${myProperty.propertyData.propertyAddress}
- Roof Type: ${myProperty.propertyData.roofMaterial}
- Roof Condition: ${myProperty.propertyData.roofCondition}
- Storm Risk Score: ${myProperty.propertyData.neighborhoodRating}/10
- Est. Value: $${myProperty.propertyData.currentEstimatedMarketValue.toLocaleString()}
- Last Inspection: ${myProperty.propertyData.lastInspectionDate || 'Unknown'}
`;
// Dynamic Tips
if (myProperty.propertyData.roofCondition === 'Needs Repair') {
preventiveTips = "URGENT: Schedule an inspection immediately to prevent water damage.";
} else if (myProperty.propertyData.treeCoverage > 50) {
preventiveTips = "Consider trimming overhanging branches to protect shingles.";
}
// Mock Warranty Logic
const yearBuilt = myProperty.propertyData.yearBuilt;
if (yearBuilt > 2015) warrantyStatus = "Active Workmanship Warranty (Valid until 2030)";
- 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).`;
}
// 2. History & Upcoming
const myHistory = meetings
.filter(m => m.customerId === user.id || m.customerName === user.name)
.sort((a, b) => new Date(b.date) - new Date(a.date)); // Newest first
// ──────────────── 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 historyText = myHistory.map(m => {
const isFuture = new Date(m.date) > new Date();
return `- [${m.date}] ${isFuture ? 'UPCOMING' : 'COMPLETED'} (${m.status})
Agent: ${m.agentName || 'Assigned Rep'}
Note: ${m.notes}
${m.dealValue ? `Cost: $${m.dealValue}` : ''}`;
}).join('\n');
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');
// 3. Total Spend
const totalSpend = myHistory.reduce((sum, m) => sum + (m.dealValue || 0), 0);
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');
return `
ROLE: HOMEOWNER / CUSTOMER (${user.name})
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');
YOUR DATA:
1. PROPERTY PROFILE:
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}
2. SERVICE HISTORY:
${historyText || "No records found."}
SERVICE HISTORY:
${meetingText || ' No records found.'}
3. FINANCIALS:
- Total Spend to Date: $${totalSpend.toLocaleString()}
- Warranty Status: ${warrantyStatus}
FINANCIALS:
- Total Spend: $${totalSpend.toLocaleString()}
- Warranty: ${warranty}
4. PERSONALIZED ADVICE:
- Tip: ${preventiveTips}
ADVICE: ${tips}
INSTRUCTIONS:
- Answer questions about "my roof", "my appointment", "warranty", or "spending".
- Be polite and reassuring.
`;
};
const getGuestContext = () => {
return `
ROLE: EXPERT ROOFING CONSULTANT (20+ Years Experience)
YOUR KNOWLEDGE BASE (Use this to diagnose, reassure, but EDUCATE ON HIDDEN RISKS):
1. COMMON ISSUES & RISK FACTORS:
- **Active Leak**: "A bucket stops the floor damage, but water is currently saturating your insulation and decking." -> URGENCY: "Mold begins to grow in wet insulation within 24-48 hours. We need to map the moisture path immediately."
- **Missing Shingles**: "It looks minor, but your underlayment is now exposed to UV rays which destroy it in days." -> RISK: "Leaving this can lead to a denied insurance claim later for 'neglect'."
- **Hail Dents**: "These look like small bruises, but they have fractured the fiberglass mat." -> INVISIBLE DAMAGE: "This reduces roof life by 3-5 years. If not documented now, insurance will call it 'wear and tear' later."
- **Granule Loss**: "Your roof is shedding its sunscreen." -> REALITY: "The asphalt is now baking in the Texas sun. This is a leading indicator of roof failure within 12-18 months."
2. COMPLEX ISSUE TRIAGE:
- **Ice Dams**: "Heat loss from your attic is melting snow." -> SOLUTION: "We need to assess your intake vents, not just patch the roof."
- **Sagging**: "This indicates structural fatigue or long-term water intrusion." -> ACTION: "Do not walk on this area. Needs professional stabilization."
3. THE "WHY US" HOOK:
- "Insurance adjusters use specific criteria to approve claims. We know exactly what they look for."
- "A DIY patch often voids the manufacturer warranty. Our repairs are certified."
- "We are in Plano right now—I can have a project manager stop by in 30 minutes to check the moisture levels for free."
INSTRUCTIONS:
- **Tone**: Professional, Authoritative, "Doctor-like".
- **Strategy**: Validate the user's observation -> Explain the *hidden* consequence of waiting -> Offer the *Zero-Risk* solution (Free Inspection).
- **Contact**: "Call 866-259-6533. We can be there today."
`;
};
const getOwnerContext = (user, storeData) => {
const { meetings, properties, salesHistory, vendors, personnel, documents, projects } = storeData;
// 1. Financial High-Level
const totalRevenue = salesHistory.filter(s => s.status === 'closed_won').reduce((sum, s) => sum + s.amount, 0);
const pendingPayouts = projects.reduce((sum, p) => sum + p.invoices.filter(i => i.status === 'pending').reduce((is, i) => is + i.amount, 0), 0);
// 2. Urgent Items
const expiringDocs = documents.filter(d => {
const exp = new Date(d.expirationDate);
const now = new Date();
const diffDays = Math.ceil((exp - now) / (1000 * 60 * 60 * 24));
return diffDays > 0 && diffDays <= 30;
}).length;
const pendingDocs = documents.filter(d => d.status === 'pending_review' || d.status === 'pending').length;
const nonCompliantVendors = vendors.filter(v => v.status !== 'active').length;
// 3. Operational Snapshot
const activeProjects = projects.filter(p => p.status === 'active').length;
const activePersonnel = personnel.filter(p => p.status === 'active').length;
return `
ROLE: OWNER (${user.name})
*** EXECUTIVE DASHBOARD SUMMARY ***
1. FINANCIAL HEALTH:
- Total Revenue (YTD): $${totalRevenue.toLocaleString()}
- Pending Invoice Payouts: $${pendingPayouts.toLocaleString()}
2. RISK & COMPLIANCE (Action Required):
- Documents Pending Review: ${pendingDocs}
- Documents Expiring Soon (30d): ${expiringDocs}
- Non-Compliant Vendors: ${nonCompliantVendors}
3. OPERATIONS:
- Active Projects: ${activeProjects}
- Active Staff: ${activePersonnel}
INSTRUCTIONS:
- You are the Business Intelligence Architect.
- Answer questions about revenue, burn rate, compliance risks, and high-level strategy.
- If asked about specific details (e.g., "Which vendor is non-compliant?"), you can query the valid filtered lists provided implicitly in your logic or advise checking the specific dashboard page.
- PRIORITIZE RISK ALERTS: If compliance is low, warn the owner.
`;
};
const getContractorContext = (user, storeData) => {
const { projects, documents } = storeData;
// 1. Filter Projects for THIS Contractor
const myProjects = projects.filter(p => p.contractorId === user.id || p.subcontractorIds?.includes(user.id));
if (myProjects.length === 0) {
return `
ROLE: CONTRACTOR (${user.name})
CONTEXT: You currently have no active projects assigned.
INSTRUCTIONS: Assist with general onboarding or account management questions.
`;
- Be polite, reassuring, and helpful.
- Offer to schedule a free inspection if concerns arise: 866-259-6533.`;
}
// 2. Project Details
const projectSummaries = myProjects.map(p => {
const milestones = p.milestones.filter(m => m.assignedTo === user.id);
const nextMilestone = milestones.find(m => m.status !== 'completed');
return `- Project #${p.id} (${p.projectType}): Status ${p.status}
Next Deadline: ${nextMilestone ? `${nextMilestone.name} by ${nextMilestone.dueDate}` : 'None'}
Budget: $${p.budget.toLocaleString()}`;
}).join('\n');
// 3. My Compliance
const myDocs = documents.filter(d => d.entityId === user.id);
const missingOrExpired = myDocs.filter(d => d.status === 'expired' || d.status === 'missing');
return `
ROLE: CONTRACTOR (${user.name})
YOUR ASSIGNMENTS:
${projectSummaries}
YOUR COMPLIANCE STATUS:
${missingOrExpired.length > 0 ? `WARNING: You have ${missingOrExpired.length} documents needing attention.` : "All compliance documents are up to date."}
INSTRUCTIONS:
- ONLY discuss projects listed above.
- Do NOT reveal company-wide financial data or other contractors' projects.
- Help the contractor track their deadlines and payments.
`;
// ──────────────── 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.`;
};
// ... (getEmployeeContext and getCustomerContext remain, updated signatures to use storeData object for cleaner passing)
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 } = storeData;
const { addMeeting, meetings, properties, salesHistory, users, vendors, personnel, documents, projects, owners, orders, vendorInvoices } = storeData;
const [isOpen, setIsOpen] = useState(false);
const [isMinimized, setIsMinimized] = useState(false);
@@ -391,14 +514,20 @@ const Chatbot = (props) => {
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 === 'FIELD_AGENT' || user.role === 'ADMIN') {
greeting += "I have your latest territory data and agenda ready. What do you need?";
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 revenue, compliance, or operational risks.";
} else if (user.role === 'CONTRACTOR' || user.role === 'SUBCONTRACTOR') {
greeting += "I have your project assignments loaded. Need to check deadlines or invoices?";
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 }]);
@@ -431,69 +560,10 @@ const Chatbot = (props) => {
// --- REAL AI REQUEST ---
// --- NEW: Enhanced Context Builders for Phase 7 ---
const getOwnerContext = () => {
// Summarize financials
// Real app: Calculate from store data
return "Role: OWNER. Context: You have access to deep financial insights. Total Revenue: $4.2M. Active Projects: 12. Urgent Items: 3 (1 expired insurance, 2 pending invoices).";
};
const getContractorContext = () => {
const myProjects = storeData.projects.filter(p => p.contractorId === 'con_001');
const activeCount = myProjects.filter(p => p.status === 'active').length;
return `Role: CONTRACTOR. Context: You are managing ${activeCount} active projects. Total budget volume: $${myProjects.reduce((s, p) => s + p.budget, 0).toLocaleString()}. Key deadline: 2604 Dunwick Dr (Roof Tear-off completed).`;
};
const getVendorContext = () => {
// Mock ID 'v3' for 'abc_supply' or 'v1' for generic
const myId = user.id === 'ven_001' ? 'v3' : 'v1';
const me = storeData.vendors.find(v => v.id === myId) || storeData.vendors[0];
const pending = me.spend?.pendingInvoices || 0;
return `Role: VENDOR. Context: You are ${me.vendorName}. Pending Invoices: $${pending.toLocaleString()}. Compliance Status: ${me.compliance.coi.status}. Performance Rating: ${me.performance.rating}/5.0.`;
};
const getSubContractorContext = () => {
// Mock ID 'sub_001'
const myTasks = storeData.projects.flatMap(p => p.milestones.filter(m => m.assignedTo === 'sub_001'));
const pendingPay = 6500; // Mock calculation from dashboard logic
return `Role: SUBCONTRACTOR. Context: You have ${myTasks.filter(t => t.status === 'in_progress').length} active tasks. Pending Payouts: $${pendingPay.toLocaleString()}. Next Task: Electrical Guard Install at 2604 Dunwick.`;
};
// --------------------------------------------------
const generateContext = () => {
// Base context with general knowledge
let context = `You are the LynkedUp Pro AI Assistant. Current User: ${user?.name} (${user?.role}).
System Capabilities:
- We track 5,000+ properties in DFW.
- We manage canvassing, inspections, and project execution.
- We use 'Zinc' design system (Dark/Light mode).
Today's Date: 2026-02-16.
`;
if (user?.role === 'OWNER') {
context += getOwnerContext();
} else if (user?.role === 'CONTRACTOR') {
context += getContractorContext();
} else if (user?.role === 'VENDOR') {
context += getVendorContext();
} else if (user?.role === 'SUBCONTRACTOR') {
context += getSubContractorContext();
} else if (user?.role === 'FIELD_AGENT') {
context += `Role: FIELD_AGENT. Focus: Canvassing and Leads. Your Map View is active.`;
}
// Add current page context if needed (e.g. if on a specific project page)
// For now, we keep it high-level role based.
return context;
};
// --- Generate deep, role-aware context ---
// 1. Build Dynamic Context
let systemContext = generateContext();
let systemContext = generateRoleContext(user, storeData);
// 2. Call Groq
const chatCompletion = await groq.chat.completions.create({
+2
View File
@@ -134,6 +134,7 @@ const Layout = () => {
case 'OWNER':
return [
{ to: "/owner/snapshot", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/owner/projects", icon: Briefcase, label: "Projects" },
{ to: "/owner/vendors", icon: Users, label: "Vendors" },
{ to: "/owner/people", icon: User, label: "People" },
{ to: "/owner/documents", icon: FileText, label: "Documents" },
@@ -149,6 +150,7 @@ const Layout = () => {
{ to: "/admin/dashboard", icon: LayoutDashboard, label: "Dashboard" },
{ to: "/admin/schedule", icon: Calendar, label: "Schedule" },
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
{ to: "/admin/maps", icon: Map, label: "Territory Map" },
...commonItems
];
case 'CONTRACTOR':
+2 -2
View File
@@ -68,14 +68,14 @@ export const StatCard = ({ label, value, suffix = '', icon: Icon, trend, trendLa
const bgColor = colorClass.split(' ')[1];
return (
<SpotlightCard className="h-full flex flex-col justify-between p-6">
<SpotlightCard className="h-full flex flex-col justify-between p-4 sm:p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-sm font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">{label}</h3>
<div className="mt-2 flex items-baseline gap-1">
<span
ref={counterRef}
className={`text-3xl font-mono font-bold ${textColor} tracking-tight`}
className={`text-2xl sm:text-3xl font-mono font-bold ${textColor} tracking-tight`}
>
0
</span>
@@ -138,9 +138,37 @@ const FinancialSummaryModal = ({ isOpen, onClose, role, data }) => {
</button>
</div>
{/* Table */}
<div className="flex-1 overflow-y-auto overflow-x-auto custom-scrollbar bg-white dark:bg-[#121214]">
<table className="w-full text-left border-collapse min-w-[600px]">
{/* Mobile Card View */}
<div className="flex-1 overflow-y-auto sm:hidden custom-scrollbar bg-white dark:bg-[#121214]">
{sortedData.length > 0 ? (
<div className="divide-y divide-zinc-100 dark:divide-white/5">
{sortedData.map((item, idx) => (
<div key={idx} className="px-4 py-3">
<div className="flex justify-between items-start mb-1.5">
<div className="flex-1 min-w-0 mr-3">
<p className="font-semibold text-sm text-zinc-900 dark:text-white truncate">{item.description || item.project}</p>
{item.project && <p className="text-xs text-zinc-500 truncate">{item.project}</p>}
</div>
<span className="font-mono font-medium text-sm text-zinc-900 dark:text-white shrink-0">{formatCurrency(item.amount)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500 font-mono">{item.date}</span>
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide ${item.status === 'paid' || item.status === 'completed'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
: 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400'
}`}>{item.status}</span>
</div>
</div>
))}
</div>
) : (
<div className="py-20 text-center text-zinc-500">No transactions found.</div>
)}
</div>
{/* Desktop Table View */}
<div className="flex-1 overflow-y-auto overflow-x-auto hidden sm:block custom-scrollbar bg-white dark:bg-[#121214]">
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 z-10 bg-zinc-50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10 shadow-sm">
<tr>
{[
@@ -152,7 +180,7 @@ const FinancialSummaryModal = ({ isOpen, onClose, role, data }) => {
<th
key={col.key}
onClick={() => handleSort(col.key)}
className={`px-3 sm:px-6 py-3 sm:py-4 text-[10px] sm:text-xs font-bold uppercase tracking-wider text-zinc-500 cursor-pointer hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors ${col.align === 'right' ? 'text-right' : ''}`}
className={`px-6 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 cursor-pointer hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors ${col.align === 'right' ? 'text-right' : ''}`}
>
{col.label}
</th>
@@ -162,22 +190,22 @@ const FinancialSummaryModal = ({ isOpen, onClose, role, data }) => {
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
{sortedData.length > 0 ? sortedData.map((item, idx) => (
<tr key={idx} className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
<td className="px-3 sm:px-6 py-3 sm:py-4 text-xs sm:text-sm text-zinc-600 dark:text-zinc-400 font-mono whitespace-nowrap">
<td className="px-6 py-4 text-sm text-zinc-600 dark:text-zinc-400 font-mono whitespace-nowrap">
{item.date}
</td>
<td className="px-3 sm:px-6 py-3 sm:py-4">
<div className="font-semibold text-sm sm:text-base text-zinc-900 dark:text-white">{item.description || item.project}</div>
<td className="px-6 py-4">
<div className="font-semibold text-base text-zinc-900 dark:text-white">{item.description || item.project}</div>
{item.project && <div className="text-xs text-zinc-500">{item.project}</div>}
</td>
<td className="px-3 sm:px-6 py-3 sm:py-4">
<span className={`px-2 sm:px-2.5 py-0.5 rounded-full text-[10px] sm:text-xs font-bold uppercase tracking-wide whitespace-nowrap ${item.status === 'paid' || item.status === 'completed'
<td className="px-6 py-4">
<span className={`px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wide whitespace-nowrap ${item.status === 'paid' || item.status === 'completed'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
: 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400'
}`}>
{item.status}
</span>
</td>
<td className="px-3 sm:px-6 py-3 sm:py-4 text-right font-mono font-medium text-sm sm:text-base text-zinc-900 dark:text-white whitespace-nowrap">
<td className="px-6 py-4 text-right font-mono font-medium text-base text-zinc-900 dark:text-white whitespace-nowrap">
{formatCurrency(item.amount)}
</td>
</tr>
@@ -91,7 +91,7 @@ const TaskDetailsModal = ({ isOpen, onClose, task, onUpdate }) => {
</div>
{/* Body */}
<div className="p-6 overflow-y-auto flex-1 space-y-6">
<div className="p-4 sm:p-6 overflow-y-auto flex-1 space-y-5 sm:space-y-6">
{/* Task Info Card */}
<div className="p-5 rounded-xl bg-blue-50 dark:bg-blue-500/5 border border-blue-100 dark:border-blue-500/20">
<h3 className="text-xl font-bold text-zinc-900 dark:text-white mb-3">{task.name}</h3>
@@ -153,7 +153,7 @@ const TaskDetailsModal = ({ isOpen, onClose, task, onUpdate }) => {
</div>
{/* Footer Actions */}
<div className="p-6 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 flex gap-4">
<div className="p-4 sm:p-6 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 flex gap-3 sm:gap-4">
{task.status !== 'completed' ? (
<>
<button
@@ -1,54 +1,78 @@
import React, { useState } from 'react';
import { useMockStore } from '../../data/mockStore';
import { FileText, CheckCircle, XCircle, Clock, Eye, Download } from 'lucide-react';
import { FileText, CheckCircle, XCircle, Clock, Eye, Download, AlertTriangle, Shield } from 'lucide-react';
import { toast } from 'sonner';
const DocumentReviewQueue = () => {
const { documents } = useMockStore();
const [filter, setFilter] = useState('pending'); // pending, approved, rejected, all
const [filter, setFilter] = useState('pending_review');
// Normalize status for filtering
const getFilterStatus = (status) => {
if (status === 'pending_review') return 'pending_review';
return status;
};
const filteredDocs = documents.filter(doc => {
if (filter === 'all') return true;
return doc.status === filter;
return getFilterStatus(doc.status) === filter;
});
const filterTabs = [
{ key: 'pending_review', label: 'Pending Review', count: documents.filter(d => d.status === 'pending_review').length },
{ key: 'expired', label: 'Expired', count: documents.filter(d => d.status === 'expired').length },
{ key: 'approved', label: 'Approved', count: documents.filter(d => d.status === 'approved').length },
{ key: 'all', label: 'All', count: documents.length },
];
const handleApprove = (id) => {
toast.success(`Document #${id} Approved`);
// In a real app, this would update the store/backend
};
const handleReject = (id) => {
toast.error(`Document #${id} Rejected`);
// In a real app, this would update the store/backend
toast.error(`Document #${id} Rejected — Reason: Missing signature or incorrect entity`);
};
const getStatusColor = (status) => {
switch (status) {
case 'approved': return 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20';
case 'rejected': return 'bg-red-100 text-red-700 border-red-200 dark:bg-red-500/10 dark:text-red-400 dark:border-red-500/20';
case 'pending': return 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20';
case 'expired': return 'bg-red-100 text-red-700 border-red-200 dark:bg-red-500/10 dark:text-red-400 dark:border-red-500/20';
case 'pending_review': return 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20';
default: return 'bg-zinc-100 text-zinc-700 border-zinc-200 dark:bg-zinc-500/10 dark:text-zinc-400 dark:border-zinc-500/20';
}
};
const getDocTypeLabel = (type) => {
const labels = { COI: 'Certificate of Insurance', W9: 'W-9 Form', DL: 'Driver License', PAY_PLAN: 'Pay Plan Agreement', SUB_AGREEMENT: 'Subcontractor Agreement' };
return labels[type] || type;
};
const getDocTypeIcon = (type) => {
if (type === 'COI') return <Shield size={24} />;
return <FileText size={24} />;
};
return (
<div className="flex flex-col h-full">
<div className="flex flex-wrap gap-2 mb-6">
{['pending', 'approved', 'rejected', 'all'].map(f => (
<div className="flex gap-2 mb-4 sm:mb-6 overflow-x-auto pb-2 no-scrollbar shrink-0 -mx-1 px-1">
{filterTabs.map(f => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-2 rounded-xl text-xs font-bold uppercase tracking-wider transition-all ${filter === f
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900 shadow-md transform scale-105'
key={f.key}
onClick={() => setFilter(f.key)}
className={`px-3 sm:px-4 py-2 rounded-xl text-[11px] sm:text-xs font-bold uppercase tracking-wider transition-all flex items-center gap-1.5 sm:gap-2 whitespace-nowrap shrink-0 ${filter === f.key
? 'bg-zinc-900 text-white dark:bg-white dark:text-zinc-900 shadow-md'
: 'bg-zinc-100 text-zinc-500 hover:bg-zinc-200 dark:bg-white/5 dark:text-zinc-400 dark:hover:bg-white/10'
}`}
>
{f}
{f.label}
<span className={`px-1.5 py-0.5 rounded-md text-[10px] font-bold ${filter === f.key ? 'bg-white/20 dark:bg-zinc-900/20' : 'bg-zinc-200 dark:bg-white/10'}`}>
{f.count}
</span>
</button>
))}
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar space-y-3 pr-2">
<div className="flex-1 overflow-y-auto custom-scrollbar space-y-3 pr-1 sm:pr-2">
{filteredDocs.length === 0 ? (
<div className="p-12 text-center bg-zinc-50 dark:bg-white/5 rounded-2xl border border-zinc-200 dark:border-white/5 border-dashed flex flex-col items-center justify-center">
<div className="w-16 h-16 bg-zinc-100 dark:bg-white/5 rounded-full flex items-center justify-center mb-4 text-zinc-400">
@@ -59,29 +83,56 @@ const DocumentReviewQueue = () => {
</div>
) : (
filteredDocs.map(doc => (
<div key={doc.id} className="group bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/5 rounded-xl p-5 hover:border-blue-500/30 transition-all shadow-sm">
<div className="flex flex-col md:flex-row justify-between gap-4">
<div className="flex items-start gap-4">
<div className="p-3 rounded-xl bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400 shrink-0">
<FileText size={24} />
<div key={doc.id} className="group bg-white dark:bg-[#121214] border border-zinc-200 dark:border-white/5 rounded-xl p-3 sm:p-5 hover:border-blue-500/30 transition-all shadow-sm">
<div className="flex flex-col md:flex-row justify-between gap-3 sm:gap-4">
<div className="flex items-start gap-3 sm:gap-4">
<div className={`p-2 sm:p-3 rounded-xl shrink-0 ${
doc.status === 'expired' ? 'bg-red-50 dark:bg-red-500/10 text-red-600 dark:text-red-400' :
doc.status === 'pending_review' ? 'bg-amber-50 dark:bg-amber-500/10 text-amber-600 dark:text-amber-400' :
'bg-blue-50 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400'
}`}>
{getDocTypeIcon(doc.documentType)}
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<h4 className="font-bold text-zinc-900 dark:text-white text-base">{doc.title}</h4>
<div className="flex items-center gap-2 mb-1 flex-wrap">
<h4 className="font-bold text-zinc-900 dark:text-white text-base">{doc.name}</h4>
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase border ${getStatusColor(doc.status)}`}>
{doc.status}
{doc.status === 'pending_review' ? 'Pending Review' : doc.status}
</span>
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400 space-y-1">
<p className="flex items-center gap-2">
<span className="font-semibold text-zinc-700 dark:text-zinc-300">{doc.category}</span>
<span className="font-semibold text-zinc-700 dark:text-zinc-300">{getDocTypeLabel(doc.documentType)}</span>
<span className="w-1 h-1 rounded-full bg-zinc-300 dark:bg-zinc-600"></span>
ID: <span className="font-mono">{doc.id}</span>
</p>
<p>Submitted by: <span className="font-bold text-zinc-700 dark:text-zinc-300">Vendor #{doc.relatedEntityId}</span> on {new Date(doc.uploadDate).toLocaleDateString()}</p>
<p>
Uploaded by: <span className="font-bold text-zinc-700 dark:text-zinc-300">{doc.uploadedBy}</span>
{doc.entityName && <> for <span className="font-bold text-zinc-700 dark:text-zinc-300">{doc.entityName}</span></>}
{doc.uploadedAt && <> on {new Date(doc.uploadedAt).toLocaleDateString()}</>}
</p>
{doc.aiConfidence && (
<p className="flex items-center gap-1">
AI Confidence: <span className={`font-bold ${doc.aiConfidence >= 0.9 ? 'text-emerald-500' : doc.aiConfidence >= 0.8 ? 'text-amber-500' : 'text-red-500'}`}>
{Math.round(doc.aiConfidence * 100)}%
</span>
</p>
)}
{doc.expirationDate && (
<p className="flex items-center gap-1 text-amber-600 dark:text-amber-400 font-medium pt-1">
<Clock size={12} /> Expires: {new Date(doc.expirationDate).toLocaleDateString()}
<p className={`flex items-center gap-1 font-medium pt-1 ${
new Date(doc.expirationDate) < new Date('2026-02-18')
? 'text-red-600 dark:text-red-400'
: 'text-amber-600 dark:text-amber-400'
}`}>
<Clock size={12} />
{new Date(doc.expirationDate) < new Date('2026-02-18') ? 'EXPIRED: ' : 'Expires: '}
{new Date(doc.expirationDate).toLocaleDateString()}
</p>
)}
{doc.status === 'expired' && (
<p className="flex items-center gap-1 text-red-600 dark:text-red-400 font-bold pt-1">
<AlertTriangle size={12} />
Vendor must upload renewed document
</p>
)}
</div>
@@ -90,15 +141,21 @@ const DocumentReviewQueue = () => {
<div className="flex flex-row md:flex-col justify-center items-end gap-2 shrink-0">
<div className="flex gap-2">
<button className="p-2 rounded-lg bg-zinc-100 hover:bg-zinc-200 dark:bg-white/5 dark:hover:bg-white/10 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white transition-colors" title="View">
<button
onClick={() => toast.info(`Viewing ${doc.fileName}`)}
className="p-2 rounded-lg bg-zinc-100 hover:bg-zinc-200 dark:bg-white/5 dark:hover:bg-white/10 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white transition-colors" title="View"
>
<Eye size={18} />
</button>
<button className="p-2 rounded-lg bg-zinc-100 hover:bg-zinc-200 dark:bg-white/5 dark:hover:bg-white/10 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white transition-colors" title="Download">
<button
onClick={() => toast.info(`Downloading ${doc.fileName}`)}
className="p-2 rounded-lg bg-zinc-100 hover:bg-zinc-200 dark:bg-white/5 dark:hover:bg-white/10 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white transition-colors" title="Download"
>
<Download size={18} />
</button>
</div>
{doc.status === 'pending' && (
{doc.status === 'pending_review' && (
<div className="flex gap-2 mt-2 w-full md:w-auto">
<button
onClick={() => handleReject(doc.id)}
@@ -114,6 +171,15 @@ const DocumentReviewQueue = () => {
</button>
</div>
)}
{doc.status === 'expired' && (
<button
onClick={() => toast.info(`Request sent to ${doc.entityName} for renewal`)}
className="mt-2 w-full px-3 py-2 rounded-lg bg-blue-50 hover:bg-blue-100 dark:bg-blue-500/10 dark:hover:bg-blue-500/20 text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-500/20 text-xs font-bold uppercase tracking-wide flex items-center justify-center gap-1 transition-colors"
>
Request Renewal
</button>
)}
</div>
</div>
</div>
+103
View File
@@ -0,0 +1,103 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, GitPullRequest, DollarSign, Calendar, FileText } from 'lucide-react';
const ChangeOrderDrawer = ({ isOpen, onClose, changeOrder }) => {
useEffect(() => {
const handleEsc = (e) => { if (e.key === 'Escape') onClose(); };
if (isOpen) window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose]);
useEffect(() => {
document.body.style.overflow = isOpen ? 'hidden' : '';
return () => { document.body.style.overflow = ''; };
}, [isOpen]);
if (!isOpen || !changeOrder) return null;
const co = changeOrder;
const formatCurrency = (amt) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(amt);
const getStatusColor = (s) => {
if (s === 'approved') return 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10';
if (s === 'pending') return 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/10';
return 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-500/10';
};
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-end sm:justify-end" role="dialog" aria-modal="true">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full sm:max-w-md h-[85dvh] sm:h-full bg-white dark:bg-[#121214] shadow-2xl sm:border-l border-zinc-200 dark:border-white/10 flex flex-col rounded-t-2xl sm:rounded-none animate-in slide-in-from-bottom-full sm:slide-in-from-right duration-300">
{/* Mobile Drag Handle */}
<div className="sm:hidden w-full flex justify-center pt-3 pb-1 cursor-grab active:cursor-grabbing" onClick={onClose}>
<div className="w-12 h-1.5 rounded-full bg-zinc-300 dark:bg-white/20" />
</div>
{/* Header */}
<div className="px-4 sm:px-6 py-4 sm:py-5 border-b border-zinc-200 dark:border-white/10 flex justify-between items-center bg-zinc-50/50 dark:bg-white/5">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-purple-100 dark:bg-purple-500/10 text-purple-600 dark:text-purple-400">
<GitPullRequest size={20} />
</div>
<div>
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">Change Order</h2>
<p className="text-xs text-zinc-500 font-mono">{co.id}</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:text-red-500 transition-colors" aria-label="Close">
<X size={18} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-5 sm:space-y-6 custom-scrollbar">
<div>
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">{co.title}</h3>
<span className={`inline-block mt-2 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wide ${getStatusColor(co.status)}`}>
{co.status}
</span>
</div>
{co.description && (
<div>
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mb-2">Description</div>
<p className="text-sm text-zinc-600 dark:text-zinc-400 leading-relaxed">{co.description}</p>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<div className="p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5">
<div className="flex items-center gap-2 text-zinc-500 mb-1">
<DollarSign size={14} />
<span className="text-[10px] uppercase font-bold tracking-wider">Amount</span>
</div>
<div className="text-lg font-bold text-zinc-900 dark:text-white">{formatCurrency(co.amount)}</div>
</div>
<div className="p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5">
<div className="flex items-center gap-2 text-zinc-500 mb-1">
<Calendar size={14} />
<span className="text-[10px] uppercase font-bold tracking-wider">Submitted</span>
</div>
<div className="text-lg font-bold text-zinc-900 dark:text-white">{co.dateSubmitted}</div>
</div>
</div>
{/* Cost Impact */}
<div className="p-4 rounded-xl bg-amber-50 dark:bg-amber-500/5 border border-amber-200 dark:border-amber-500/10">
<div className="text-[10px] uppercase font-bold tracking-wider text-amber-600 dark:text-amber-400 mb-2">Cost Impact</div>
<p className="text-sm text-zinc-600 dark:text-zinc-400">
This change order {co.status === 'approved' ? 'has added' : 'would add'}{' '}
<span className="font-bold text-zinc-900 dark:text-white">{formatCurrency(co.amount)}</span>{' '}
to the project budget.
</p>
</div>
</div>
</div>
</div>,
document.body
);
};
export default ChangeOrderDrawer;
+36 -9
View File
@@ -3,8 +3,9 @@ import { createPortal } from 'react-dom';
import { X, ArrowUpRight, ArrowDownRight, Filter, Download, Search, CheckCircle, Clock, AlertCircle } from 'lucide-react';
import { useMockStore } from '../../data/mockStore';
const FinancialDetailsModal = ({ isOpen, onClose, type }) => {
const { projects, vendors } = useMockStore();
const FinancialDetailsModal = ({ isOpen, onClose, type, ownerId }) => {
const { projects: allProjects, vendors } = useMockStore();
const projects = ownerId ? allProjects.filter(p => p.ownerId === ownerId) : allProjects;
const [searchTerm, setSearchTerm] = useState('');
const [sortConfig, setSortConfig] = useState({ key: 'amount', direction: 'desc' });
@@ -152,9 +153,9 @@ const FinancialDetailsModal = ({ isOpen, onClose, type }) => {
</div>
{/* Header */}
<div className="px-6 py-5 border-b border-zinc-200 dark:border-white/10 flex justify-between items-center bg-zinc-50/50 dark:bg-white/5">
<div>
<h2 id="modal-title" className="text-2xl font-bold text-zinc-900 dark:text-white flex items-center gap-2">
<div className="px-4 sm:px-6 py-4 sm:py-5 border-b border-zinc-200 dark:border-white/10 flex justify-between items-start bg-zinc-50/50 dark:bg-white/5">
<div className="flex-1 min-w-0">
<h2 id="modal-title" className="text-lg sm:text-2xl font-bold text-zinc-900 dark:text-white flex items-center gap-2">
{type === 'revenue' && <ArrowUpRight className="text-emerald-500" />}
{type === 'ar' && <Clock className="text-blue-500" />}
{type === 'payouts' && <AlertCircle className="text-amber-500" />}
@@ -180,7 +181,7 @@ const FinancialDetailsModal = ({ isOpen, onClose, type }) => {
</div>
{/* Toolbar */}
<div className="px-6 py-4 border-b border-zinc-200 dark:border-white/5 flex flex-col sm:flex-row gap-4 justify-between bg-white dark:bg-[#121214]">
<div className="px-4 sm:px-6 py-3 sm:py-4 border-b border-zinc-200 dark:border-white/5 flex flex-col sm:flex-row gap-3 sm:gap-4 justify-between bg-white dark:bg-[#121214]">
<div className="relative w-full sm:w-96">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
<input
@@ -198,8 +199,34 @@ const FinancialDetailsModal = ({ isOpen, onClose, type }) => {
</div>
</div>
{/* Table */}
<div className="flex-1 overflow-y-auto custom-scrollbar bg-white dark:bg-[#121214]">
{/* Mobile Card View */}
<div className="flex-1 overflow-y-auto sm:hidden custom-scrollbar bg-white dark:bg-[#121214]">
{sortedItems.length > 0 ? (
<div className="divide-y divide-zinc-100 dark:divide-white/5">
{sortedItems.map((item, idx) => (
<div key={idx} className="px-4 py-3">
<div className="flex justify-between items-start mb-1.5">
<div className="flex-1 min-w-0 mr-3">
<p className="font-semibold text-sm text-zinc-900 dark:text-white truncate">{item.name}</p>
<p className="text-xs text-zinc-500">{item.entity} &middot; {item.type}</p>
</div>
<span className="font-mono font-medium text-sm text-zinc-900 dark:text-white shrink-0">{formatCurrency(item.amount)}</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border border-transparent ${getStatusColor(item.status)}`}>{item.status}</span>
<span className="text-xs text-zinc-500 font-mono">{item.date}</span>
<span className="text-xs text-zinc-400 font-mono">{item.id}</span>
</div>
</div>
))}
</div>
) : (
<div className="py-20 text-center text-zinc-500">No records found.</div>
)}
</div>
{/* Desktop Table View */}
<div className="flex-1 overflow-y-auto hidden sm:block custom-scrollbar bg-white dark:bg-[#121214]">
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 z-10 bg-zinc-50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10 shadow-sm">
<tr>
@@ -258,7 +285,7 @@ const FinancialDetailsModal = ({ isOpen, onClose, type }) => {
</div>
{/* Footer Summary */}
<div className="px-6 py-4 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 flex justify-between items-center text-sm">
<div className="px-4 sm:px-6 py-3 sm:py-4 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 flex justify-between items-center text-xs sm:text-sm">
<span className="text-zinc-500 dark:text-zinc-400">Showing {sortedItems.length} records</span>
<div className="flex items-center gap-2">
<span className="text-zinc-500 dark:text-zinc-400">Net Total:</span>
+9 -10
View File
@@ -3,18 +3,17 @@ import { useMockStore } from '../../data/mockStore';
import { DollarSign, TrendingUp, TrendingDown, CreditCard } from 'lucide-react';
import { SpotlightCard } from '../SpotlightCard';
const FinancialKPICards = ({ onCardClick }) => {
const FinancialKPICards = ({ onCardClick, ownerId }) => {
const { projects, vendors } = useMockStore();
// Removed direct navigate, now delegates to parent
// Calculate KPIs derived from mock data
// Calculate KPIs derived from mock data — filtered by ownerId when provided
const metrics = useMemo(() => {
let totalRevenue = 0; // Derived from active project budgets (simplified for demo)
let outstandingAR = 0; // Invoices sent but not paid
let pendingPayouts = 0; // Vendor invoices pending
let vendorBillsDue = 0;
const ownerProjects = ownerId ? projects.filter(p => p.ownerId === ownerId) : projects;
projects.forEach(project => {
let totalRevenue = 0;
let pendingPayouts = 0;
ownerProjects.forEach(project => {
if (project.status === 'active' || project.status === 'completed') {
totalRevenue += project.budget;
}
@@ -32,11 +31,11 @@ const FinancialKPICards = ({ onCardClick }) => {
return {
totalRevenue,
outstandingAR: totalRevenue * 0.3, // Mocking AR as 30% of revenue for demo
outstandingAR: totalRevenue * 0.3,
pendingPayouts,
totalVendorSpend
};
}, [projects, vendors]);
}, [projects, vendors, ownerId]);
const cards = [
{
@@ -0,0 +1,92 @@
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, DollarSign, CheckCircle, Clock, AlertCircle } from 'lucide-react';
const InvoiceDetailModal = ({ isOpen, onClose, invoice }) => {
useEffect(() => {
const handleEsc = (e) => { if (e.key === 'Escape') onClose(); };
if (isOpen) window.addEventListener('keydown', handleEsc);
return () => window.removeEventListener('keydown', handleEsc);
}, [isOpen, onClose]);
if (!isOpen || !invoice) return null;
const inv = invoice;
const formatCurrency = (amt) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amt);
const statusConfig = {
paid: { label: 'Paid', color: 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10', icon: CheckCircle },
pending: { label: 'Pending', color: 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/10', icon: Clock },
overdue: { label: 'Overdue', color: 'text-red-600 bg-red-100 dark:text-red-400 dark:bg-red-500/10', icon: AlertCircle },
};
const cfg = statusConfig[inv.status] || statusConfig.pending;
const StatusIcon = cfg.icon;
return createPortal(
<div className="fixed top-0 left-0 w-screen h-[100dvh] z-[9999] flex items-end sm:items-center justify-center sm:p-6" role="dialog" aria-modal="true">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full sm:max-w-lg bg-white dark:bg-[#121214] rounded-t-2xl sm:rounded-2xl shadow-2xl border-t border-x sm:border border-zinc-200 dark:border-white/10 overflow-hidden animate-in slide-in-from-bottom-full sm:zoom-in-95 duration-300 sm:duration-200">
{/* Mobile Drag Handle */}
<div className="sm:hidden w-full flex justify-center pt-3 pb-1 cursor-grab active:cursor-grabbing" onClick={onClose}>
<div className="w-12 h-1.5 rounded-full bg-zinc-300 dark:bg-white/20" />
</div>
{/* Header */}
<div className="px-4 sm:px-6 py-4 sm:py-5 border-b border-zinc-200 dark:border-white/10 flex justify-between items-center bg-zinc-50/50 dark:bg-white/5">
<div className="flex items-center gap-3">
<div className="p-2 rounded-xl bg-blue-100 dark:bg-blue-500/10 text-blue-600 dark:text-blue-400">
<DollarSign size={20} />
</div>
<div>
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">Invoice Detail</h2>
<p className="text-xs text-zinc-500 font-mono">{inv.id}</p>
</div>
</div>
<button onClick={onClose} className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:text-red-500 transition-colors" aria-label="Close">
<X size={18} />
</button>
</div>
{/* Content */}
<div className="p-4 sm:p-6 space-y-5">
{/* Amount & Status */}
<div className="flex justify-between items-center">
<div>
<div className="text-3xl font-extrabold text-zinc-900 dark:text-white">{formatCurrency(inv.amount)}</div>
</div>
<span className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold uppercase tracking-wide ${cfg.color}`}>
<StatusIcon size={14} /> {cfg.label}
</span>
</div>
{/* Details Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
{[
['Submitted By', inv.submittedBy],
['Due Date', inv.dueDate],
['Date Paid', inv.datePaid || 'Not yet paid'],
['Status', inv.status],
].map(([label, value], i) => (
<div key={i} className="p-3 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5">
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mb-1">{label}</div>
<div className="text-sm font-semibold text-zinc-900 dark:text-white capitalize">{value}</div>
</div>
))}
</div>
</div>
{/* Footer */}
<div className="px-4 sm:px-6 py-3 sm:py-4 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 flex justify-end gap-3">
<button onClick={onClose} className="px-4 py-2 text-sm font-bold rounded-xl border border-zinc-200 dark:border-white/10 hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors">
Close
</button>
</div>
</div>
</div>,
document.body
);
};
export default InvoiceDetailModal;
+5 -3
View File
@@ -3,9 +3,11 @@ import { AlertCircle, Clock, FileWarning, Wallet, CheckCircle, AlertTriangle, Ch
import { SpotlightCard } from '../SpotlightCard';
import { useMockStore } from '../../data/mockStore';
const UrgentItemsPanel = ({ onActionClick }) => {
const UrgentItemsPanel = ({ onActionClick, ownerId }) => {
const { documents, vendors, projects } = useMockStore();
// Removed direct navigate
// Filter projects by owner when provided
const ownerProjects = ownerId ? projects.filter(p => p.ownerId === ownerId) : projects;
// Logic to find urgent items
const expiringDocs = documents.filter(d => {
@@ -22,7 +24,7 @@ const UrgentItemsPanel = ({ onActionClick }) => {
v.compliance.coi.status === 'expired'
);
const pendingInvoices = projects.flatMap(p =>
const pendingInvoices = ownerProjects.flatMap(p =>
(p.invoices || []).filter(i => i.status === 'pending')
);
+42 -12
View File
@@ -139,9 +139,39 @@ const VendorFinancialSummaryModal = ({ isOpen, onClose, data }) => {
</button>
</div>
{/* Table */}
<div className="flex-1 overflow-y-auto overflow-x-auto custom-scrollbar bg-white dark:bg-[#121214]">
<table className="w-full text-left border-collapse min-w-[600px]">
{/* Mobile Card View */}
<div className="flex-1 overflow-y-auto sm:hidden custom-scrollbar bg-white dark:bg-[#121214]">
{sortedData.length > 0 ? (
<div className="divide-y divide-zinc-100 dark:divide-white/5">
{sortedData.map((invoice, idx) => (
<div key={idx} className="px-4 py-3">
<div className="flex justify-between items-start mb-1.5">
<div className="flex-1 min-w-0 mr-3">
<p className="font-semibold text-sm text-zinc-900 dark:text-white truncate">{invoice.project}</p>
<p className="text-xs text-zinc-500 font-mono">{invoice.id}</p>
</div>
<span className="font-mono font-medium text-sm text-zinc-900 dark:text-white shrink-0">{formatCurrency(invoice.amount)}</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide ${invoice.status === 'paid'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
: invoice.status === 'pending'
? 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400'
: 'bg-red-100 text-red-700 dark:bg-red-500/10 dark:text-red-400'
}`}>{invoice.status}</span>
<span className="text-xs text-zinc-500">Due: {invoice.dueDate}</span>
</div>
</div>
))}
</div>
) : (
<div className="py-20 text-center text-zinc-500">No invoices found.</div>
)}
</div>
{/* Desktop Table View */}
<div className="flex-1 overflow-y-auto overflow-x-auto hidden sm:block custom-scrollbar bg-white dark:bg-[#121214]">
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 z-10 bg-zinc-50 dark:bg-[#18181b] border-b border-zinc-200 dark:border-white/10 shadow-sm">
<tr>
{[
@@ -155,7 +185,7 @@ const VendorFinancialSummaryModal = ({ isOpen, onClose, data }) => {
<th
key={col.key}
onClick={() => handleSort(col.key)}
className={`px-3 sm:px-6 py-3 sm:py-4 text-[10px] sm:text-xs font-bold uppercase tracking-wider text-zinc-500 cursor-pointer hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors ${col.align === 'right' ? 'text-right' : ''}`}
className={`px-6 py-4 text-xs font-bold uppercase tracking-wider text-zinc-500 cursor-pointer hover:text-zinc-700 dark:hover:text-zinc-300 transition-colors ${col.align === 'right' ? 'text-right' : ''}`}
>
{col.label}
</th>
@@ -165,21 +195,21 @@ const VendorFinancialSummaryModal = ({ isOpen, onClose, data }) => {
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
{sortedData.length > 0 ? sortedData.map((invoice, idx) => (
<tr key={idx} className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
<td className="px-3 sm:px-6 py-3 sm:py-4 text-xs sm:text-sm text-zinc-600 dark:text-zinc-400 font-mono whitespace-nowrap">
<td className="px-6 py-4 text-sm text-zinc-600 dark:text-zinc-400 font-mono whitespace-nowrap">
{invoice.id}
</td>
<td className="px-3 sm:px-6 py-3 sm:py-4">
<div className="font-semibold text-sm sm:text-base text-zinc-900 dark:text-white">{invoice.project}</div>
<td className="px-6 py-4">
<div className="font-semibold text-base text-zinc-900 dark:text-white">{invoice.project}</div>
{invoice.description && <div className="text-xs text-zinc-500">{invoice.description}</div>}
</td>
<td className="px-3 sm:px-6 py-3 sm:py-4 text-xs sm:text-sm text-zinc-600 dark:text-zinc-400 font-mono whitespace-nowrap">
<td className="px-6 py-4 text-sm text-zinc-600 dark:text-zinc-400 font-mono whitespace-nowrap">
{invoice.invoiceDate}
</td>
<td className="px-3 sm:px-6 py-3 sm:py-4 text-xs sm:text-sm text-zinc-600 dark:text-zinc-400 font-mono whitespace-nowrap">
<td className="px-6 py-4 text-sm text-zinc-600 dark:text-zinc-400 font-mono whitespace-nowrap">
{invoice.dueDate}
</td>
<td className="px-3 sm:px-6 py-3 sm:py-4">
<span className={`px-2 sm:px-2.5 py-0.5 rounded-full text-[10px] sm:text-xs font-bold uppercase tracking-wide whitespace-nowrap ${invoice.status === 'paid'
<td className="px-6 py-4">
<span className={`px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wide whitespace-nowrap ${invoice.status === 'paid'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
: invoice.status === 'pending'
? 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400'
@@ -188,7 +218,7 @@ const VendorFinancialSummaryModal = ({ isOpen, onClose, data }) => {
{invoice.status}
</span>
</td>
<td className="px-3 sm:px-6 py-3 sm:py-4 text-right font-mono font-medium text-sm sm:text-base text-zinc-900 dark:text-white whitespace-nowrap">
<td className="px-6 py-4 text-right font-mono font-medium text-base text-zinc-900 dark:text-white whitespace-nowrap">
{formatCurrency(invoice.amount)}
</td>
</tr>