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:
+21
-3
@@ -17,6 +17,8 @@ import OwnerSnapshot from './pages/owner/OwnerSnapshot';
|
|||||||
import PeopleDirectory from './pages/owner/PeopleDirectory';
|
import PeopleDirectory from './pages/owner/PeopleDirectory';
|
||||||
import VendorDirectory from './pages/owner/VendorDirectory';
|
import VendorDirectory from './pages/owner/VendorDirectory';
|
||||||
import DocumentManagement from './pages/owner/DocumentManagement';
|
import DocumentManagement from './pages/owner/DocumentManagement';
|
||||||
|
import OwnerProjectList from './pages/owner/OwnerProjectList';
|
||||||
|
import OwnerProjectDetail from './pages/owner/OwnerProjectDetail';
|
||||||
import PlaceholderDashboard from './pages/PlaceholderDashboard';
|
import PlaceholderDashboard from './pages/PlaceholderDashboard';
|
||||||
import ProjectList from './pages/contractor/ProjectList';
|
import ProjectList from './pages/contractor/ProjectList';
|
||||||
import AiAssistantPage from './pages/AiAssistantPage';
|
import AiAssistantPage from './pages/AiAssistantPage';
|
||||||
@@ -107,11 +109,19 @@ function App() {
|
|||||||
<DocumentManagement />
|
<DocumentManagement />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/owner/projects" element={
|
||||||
|
<ProtectedRoute allowedRoles={['OWNER']}>
|
||||||
|
<OwnerProjectList />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
|
<Route path="/owner/projects/:projectId" element={
|
||||||
|
<ProtectedRoute allowedRoles={['OWNER']}>
|
||||||
|
<OwnerProjectDetail />
|
||||||
|
</ProtectedRoute>
|
||||||
|
} />
|
||||||
<Route path="/owner/maps" element={
|
<Route path="/owner/maps" element={
|
||||||
<ProtectedRoute allowedRoles={['OWNER']}>
|
<ProtectedRoute allowedRoles={['OWNER']}>
|
||||||
<div className="h-full w-full bg-zinc-900 flex items-center justify-center text-white">
|
<Maps />
|
||||||
<span className="text-xl">Detailed Market Intelligence Map (Coming Soon)</span>
|
|
||||||
</div>
|
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
@@ -141,6 +151,14 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/admin/maps"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute allowedRoles={['ADMIN', 'OWNER']}>
|
||||||
|
<Maps />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Contractor Routes */}
|
{/* Contractor Routes */}
|
||||||
<Route path="/contractor/dashboard" element={
|
<Route path="/contractor/dashboard" element={
|
||||||
|
|||||||
+433
-363
@@ -14,355 +14,478 @@ const groq = new Groq({
|
|||||||
dangerouslyAllowBrowser: true // Allowed for client-side demo
|
dangerouslyAllowBrowser: true // Allowed for client-side demo
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- CONTEXT GENERATORS ---
|
// --- CONTEXT GENERATOR (Deep, role-aware with cross-role visibility) ---
|
||||||
|
|
||||||
const BASE_IDENTITY = `
|
const generateRoleContext = (user, store) => {
|
||||||
You are the AI Concierge for "LynkedUp Pro", the pinnacle of roofing excellence.
|
const { meetings, properties, salesHistory, users, vendors, personnel, documents, projects, owners, orders, vendorInvoices } = store;
|
||||||
Your mission is to assist employees with data-driven insights and guide customers through our services.
|
const TODAY = '2026-02-18';
|
||||||
|
const todayDate = new Date(TODAY);
|
||||||
|
|
||||||
COMPANY INFO:
|
const BASE = `You are the LynkedUp Pro AI Assistant — an intelligent operations concierge for a construction & roofing management platform.
|
||||||
- **Phone**: 866-259-6533
|
Company: LynkedUp Pro | Phone: 866-259-6533 | 20+ years experience | Free first 3 diagnostic exams | 15% off Veterans & Seniors.
|
||||||
- **Experience**: 20+ years of craftsmanship.
|
Today's Date: ${TODAY}. Tone: Professional, data-driven, concise. Use markdown formatting. Never fabricate data — only reference what's provided below.
|
||||||
- **Offer**: First 3 diagnostic exams are FREE.
|
|
||||||
- **Discounts**: 15% off for Veterans & Seniors.
|
|
||||||
|
|
||||||
TONE: Professional, efficient, and data-aware.
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const getEmployeeContext = (user, meetings, properties, salesHistory, users) => {
|
if (!user) {
|
||||||
// --- ADMIN SPECIFIC CONTEXT ---
|
// 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') {
|
if (user.role === 'ADMIN') {
|
||||||
const unassignedCount = properties.filter(p => !p.assignedAgentId && p.canvassingStatus === 'Lead').length;
|
// Urgent action center
|
||||||
// Last contact > 24h ago logic
|
const unassigned = properties.filter(p => !p.assignedAgentId && p.canvassingStatus === 'Lead').length;
|
||||||
const hotLeadCount = properties.filter(p => p.canvassingStatus === 'Hot Lead' && (!p.lastContactDate || new Date(p.lastContactDate) < new Date(Date.now() - 86400000))).length;
|
const hotLeadCount = properties.filter(p => p.canvassingStatus === 'Hot Lead').length;
|
||||||
const rescheduleCount = meetings.filter(m => m.status === 'Cancelled' || m.status === 'Missed').length;
|
const rescheduleNeeded = meetings.filter(m => m.status === 'Cancelled' || m.status === 'Missed').length;
|
||||||
const signatureCount = properties.filter(p => p.pendingSignature).length;
|
const pendingSigs = properties.filter(p => p.pendingSignature).length;
|
||||||
const totalUrgent = unassignedCount + hotLeadCount + rescheduleCount + signatureCount;
|
|
||||||
|
|
||||||
// --- LEADERBOARD SNAPSHOT (Current Month) ---
|
// Leaderboard
|
||||||
const startOfMonth = new Date(new Date().getFullYear(), new Date().getMonth(), 1);
|
|
||||||
|
|
||||||
// Aggregate Sales by Agent
|
|
||||||
const agentStats = {};
|
const agentStats = {};
|
||||||
salesHistory.forEach(tx => {
|
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 };
|
if (!agentStats[tx.agentId]) agentStats[tx.agentId] = { revenue: 0, volume: 0 };
|
||||||
agentStats[tx.agentId].revenue += tx.amount;
|
agentStats[tx.agentId].revenue += tx.amount;
|
||||||
agentStats[tx.agentId].volume += 1;
|
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
|
// Pipeline
|
||||||
const sortedStats = Object.keys(agentStats).map(agentId => {
|
const totalProps = properties.length;
|
||||||
const agent = users.find(u => u.id === agentId);
|
const statusBreakdown = {};
|
||||||
return {
|
properties.forEach(p => { statusBreakdown[p.canvassingStatus] = (statusBreakdown[p.canvassingStatus] || 0) + 1; });
|
||||||
name: agent ? agent.name : agentId,
|
const pipelineText = Object.entries(statusBreakdown).map(([k, v]) => ` - ${k}: ${v}`).join('\n');
|
||||||
revenue: agentStats[agentId].revenue,
|
|
||||||
volume: agentStats[agentId].volume
|
|
||||||
};
|
|
||||||
}).sort((a, b) => b.revenue - a.revenue).slice(0, 3);
|
|
||||||
|
|
||||||
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 `
|
// All projects overview (admin sees everything)
|
||||||
ROLE: ADMIN (${user.name})
|
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) ***
|
// Vendor summary (admin sees all)
|
||||||
1. UNASSIGNED LEADS: ${unassignedCount}
|
const nonCompliant = vendors.filter(v => v.status !== 'active');
|
||||||
- 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.
|
|
||||||
|
|
||||||
GENERAL DATA ACCESS:
|
// Personnel
|
||||||
- Total Properties: ${properties.length}
|
const activeStaff = personnel.filter(p => p.status === 'active');
|
||||||
- Total Revenue (All Agents): $${meetings.filter(m => m.status === 'Converted').reduce((sum, m) => sum + (m.dealValue || 0), 0).toLocaleString()}
|
|
||||||
|
|
||||||
LEADERBOARD SNAPSHOT (Top 3):
|
// Documents
|
||||||
${leaderboardText}
|
const pendingDocs = documents.filter(d => d.status === 'pending_review').length;
|
||||||
- Full details at /admin/leaderboard
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- FIELD AGENT CONTEXT (Existing Logic) ---
|
return BASE + `ROLE: ADMIN — ${user.name}
|
||||||
// 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));
|
|
||||||
|
|
||||||
// Simple segment: Today vs Upcoming
|
URGENT ACTION CENTER:
|
||||||
const agendaText = myMeetings.map(m => {
|
- Unassigned Leads: ${unassigned}
|
||||||
const isToday = m.date === today;
|
- Hot Leads Needing Follow-Up: ${hotLeadCount}
|
||||||
return `- [${m.date} @ ${m.time}] ${m.status.toUpperCase()} with ${m.customerName} (${m.propertyId})${isToday ? ' **(TODAY)**' : ''}
|
- Reschedule Needed (Cancelled/Missed): ${rescheduleNeeded}
|
||||||
Issue: "${m.issueDescription || 'N/A'}"
|
- Pending Signatures: ${pendingSigs}
|
||||||
Comments: "${m.customerComments || 'None'}"
|
- Documents Pending Review: ${pendingDocs}
|
||||||
Outcome: ${m.outcome || 'Pending'}`;
|
|
||||||
}).join('\n');
|
|
||||||
|
|
||||||
// 2. Hot Leads (Top 5)
|
SALES PIPELINE (${totalProps} properties):
|
||||||
const hotLeads = properties
|
${pipelineText}
|
||||||
.filter(p => p.canvassingStatus === 'Hot Lead')
|
|
||||||
.slice(0, 5)
|
|
||||||
.map(p => `- ${p.propertyData.propertyAddress} (Value: $${p.propertyData.currentEstimatedMarketValue.toLocaleString()})`)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
// 3. Golden Leads (Older + High Value)
|
LEADERBOARD (All-Time Closed Won):
|
||||||
const goldenLeads = properties
|
${lbText || ' No data.'}
|
||||||
.filter(p => p.propertyData.yearBuilt < 2000 && p.propertyData.currentEstimatedMarketValue > 400000)
|
|
||||||
.slice(0, 3)
|
|
||||||
.map(p => `- ${p.propertyData.propertyAddress} (Built: ${p.propertyData.yearBuilt})`)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
// 4. Pending Signatures
|
TODAY'S MEETINGS (${todayMeetings.length}):
|
||||||
const pendingSigs = properties
|
${todayMeetings.slice(0, 5).map(m => ` - ${m.time} with ${m.customerName} (${m.status})`).join('\n') || ' None scheduled.'}
|
||||||
.filter(p => p.propertyData.pendingSignature)
|
|
||||||
.map(p => `- ${p.propertyData.propertyAddress} (Owner: ${p.ownerData.fullName})`)
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
// 5. Revenue & Performance Snapshot
|
PROJECTS OVERVIEW (Cross-Role: Admin sees ALL):
|
||||||
const myCompleted = meetings.filter(m => m.agentId === user.id && (m.status === 'Converted' || m.status === 'Completed'));
|
- Active: ${allActive}/${projects.length} | Total Budget: $${allBudget.toLocaleString()} | Spent: $${allSpent.toLocaleString()}
|
||||||
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;
|
|
||||||
|
|
||||||
// 6. Neighborhood Intelligence
|
VENDOR COMPLIANCE:
|
||||||
const avgValue = Math.round(properties.reduce((sum, p) => sum + p.propertyData.currentEstimatedMarketValue, 0) / properties.length);
|
- Total Vendors: ${vendors.length} | Non-Compliant: ${nonCompliant.length}
|
||||||
const commonRoof = "Architectural Shingle"; // Hardcoded for demo, or could calculate mode
|
${nonCompliant.map(v => ` - ${v.vendorName}: Status=${v.status}`).join('\n') || ' All compliant.'}
|
||||||
|
|
||||||
return `
|
STAFF: ${activeStaff.length} active / ${personnel.length} total
|
||||||
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}
|
|
||||||
|
|
||||||
INSTRUCTIONS:
|
INSTRUCTIONS:
|
||||||
- If asked about "agenda", "schedule", or "next appointment", summarize the meetings.
|
- You manage the entire operation. Answer questions about pipeline, agents, revenue, meetings, and compliance.
|
||||||
- If asked about "leads", suggest the designated Hot or Golden leads.
|
- When asked "who's the top performer?", use the leaderboard data.
|
||||||
- If asked about "performance" or "stats", share your win rate and revenue.
|
- When asked about a specific agent, cross-reference meetings + salesHistory.
|
||||||
- If asked about "neighborhood" or "territory", share the average value and roof types.
|
- You can also answer questions about projects, vendors, and documents (full cross-role access).`;
|
||||||
`;
|
|
||||||
};
|
|
||||||
|
|
||||||
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)";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. History & Upcoming
|
// ──────────────── FIELD AGENT ────────────────
|
||||||
const myHistory = meetings
|
if (user.role === 'FIELD_AGENT') {
|
||||||
.filter(m => m.customerId === user.id || m.customerName === user.name)
|
const myMeetings = meetings.filter(m => m.agentId === user.id).sort((a, b) => new Date(a.date) - new Date(b.date));
|
||||||
.sort((a, b) => new Date(b.date) - new Date(a.date)); // Newest first
|
const todayMeetings = myMeetings.filter(m => m.date === TODAY);
|
||||||
|
const upcomingMeetings = myMeetings.filter(m => new Date(m.date) >= todayDate);
|
||||||
|
|
||||||
const historyText = myHistory.map(m => {
|
const agendaText = upcomingMeetings.slice(0, 8).map(m => {
|
||||||
const isFuture = new Date(m.date) > new Date();
|
const isToday = m.date === TODAY;
|
||||||
return `- [${m.date}] ${isFuture ? 'UPCOMING' : 'COMPLETED'} (${m.status})
|
return ` - [${m.date} @ ${m.time}] ${m.status} with ${m.customerName}${isToday ? ' **(TODAY)**' : ''} — ${m.issueDescription || 'General'}`;
|
||||||
Agent: ${m.agentName || 'Assigned Rep'}
|
}).join('\n');
|
||||||
Note: ${m.notes}
|
|
||||||
${m.dealValue ? `Cost: $${m.dealValue}` : ''}`;
|
|
||||||
}).join('\n');
|
|
||||||
|
|
||||||
// 3. Total Spend
|
const hotLeads = properties.filter(p => p.canvassingStatus === 'Hot Lead').slice(0, 5)
|
||||||
const totalSpend = myHistory.reduce((sum, m) => sum + (m.dealValue || 0), 0);
|
.map(p => ` - ${p.propertyData?.propertyAddress || p.address || 'N/A'} (Value: $${(p.propertyData?.currentEstimatedMarketValue || 0).toLocaleString()})`).join('\n');
|
||||||
|
|
||||||
return `
|
const goldenLeads = properties.filter(p => p.propertyData?.yearBuilt < 2000 && p.propertyData?.currentEstimatedMarketValue > 400000).slice(0, 3)
|
||||||
ROLE: HOMEOWNER / CUSTOMER (${user.name})
|
.map(p => ` - ${p.propertyData.propertyAddress} (Built: ${p.propertyData.yearBuilt})`).join('\n');
|
||||||
|
|
||||||
YOUR DATA:
|
const pendingSigs = properties.filter(p => p.pendingSignature || p.propertyData?.pendingSignature)
|
||||||
1. PROPERTY PROFILE:
|
.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}
|
${propDetails}
|
||||||
|
|
||||||
2. SERVICE HISTORY:
|
SERVICE HISTORY:
|
||||||
${historyText || "No records found."}
|
${meetingText || ' No records found.'}
|
||||||
|
|
||||||
3. FINANCIALS:
|
FINANCIALS:
|
||||||
- Total Spend to Date: $${totalSpend.toLocaleString()}
|
- Total Spend: $${totalSpend.toLocaleString()}
|
||||||
- Warranty Status: ${warrantyStatus}
|
- Warranty: ${warranty}
|
||||||
|
|
||||||
4. PERSONALIZED ADVICE:
|
ADVICE: ${tips}
|
||||||
- Tip: ${preventiveTips}
|
|
||||||
|
|
||||||
INSTRUCTIONS:
|
INSTRUCTIONS:
|
||||||
- Answer questions about "my roof", "my appointment", "warranty", or "spending".
|
- Answer questions about "my roof", "my appointment", "warranty", or "spending".
|
||||||
- Be polite and reassuring.
|
- Be polite, reassuring, and helpful.
|
||||||
`;
|
- Offer to schedule a free inspection if concerns arise: 866-259-6533.`;
|
||||||
};
|
|
||||||
|
|
||||||
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.
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Project Details
|
// ──────────────── FALLBACK ────────────────
|
||||||
const projectSummaries = myProjects.map(p => {
|
return BASE + `ROLE: ${user.role} — ${user.name}
|
||||||
const milestones = p.milestones.filter(m => m.assignedTo === user.id);
|
You are logged in but your role context is being set up. I can help with general questions about LynkedUp Pro.`;
|
||||||
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.
|
|
||||||
`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ... (getEmployeeContext and getCustomerContext remain, updated signatures to use storeData object for cleaner passing)
|
|
||||||
|
|
||||||
const Chatbot = (props) => {
|
const Chatbot = (props) => {
|
||||||
const { user, isAuthenticated } = useAuth();
|
const { user, isAuthenticated } = useAuth();
|
||||||
// Pull ALL data needed for context generation
|
// Pull ALL data needed for context generation
|
||||||
const storeData = useMockStore();
|
const storeData = useMockStore();
|
||||||
// Destructure specifically for local usage if needed, but we pass the whole object to context functions
|
// 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 [isOpen, setIsOpen] = useState(false);
|
||||||
const [isMinimized, setIsMinimized] = 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?';
|
let greeting = 'Hello! Welcome to LynkedUp Pro. How can I help you with your roofing needs today?';
|
||||||
if (user) {
|
if (user) {
|
||||||
greeting = `Welcome back, ${user.name}! `;
|
greeting = `Welcome back, ${user.name}! `;
|
||||||
if (user.role === 'FIELD_AGENT' || user.role === 'ADMIN') {
|
if (user.role === 'ADMIN') {
|
||||||
greeting += "I have your latest territory data and agenda ready. What do you need?";
|
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') {
|
} else if (user.role === 'CUSTOMER') {
|
||||||
greeting += "I have your property records open. How can I assist?";
|
greeting += "I have your property records open. How can I assist?";
|
||||||
} else if (user.role === 'OWNER') {
|
} else if (user.role === 'OWNER') {
|
||||||
greeting += "Executive Dashboard is live. Ask me about revenue, compliance, or operational risks.";
|
greeting += "Executive Dashboard is live. Ask me about projects, budgets, vendors, or compliance.";
|
||||||
} else if (user.role === 'CONTRACTOR' || user.role === 'SUBCONTRACTOR') {
|
} else if (user.role === 'CONTRACTOR') {
|
||||||
greeting += "I have your project assignments loaded. Need to check deadlines or invoices?";
|
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 }]);
|
setMessages([{ role: 'assistant', content: greeting }]);
|
||||||
@@ -431,69 +560,10 @@ const Chatbot = (props) => {
|
|||||||
|
|
||||||
// --- REAL AI REQUEST ---
|
// --- REAL AI REQUEST ---
|
||||||
|
|
||||||
// --- NEW: Enhanced Context Builders for Phase 7 ---
|
// --- Generate deep, role-aware context ---
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 1. Build Dynamic Context
|
// 1. Build Dynamic Context
|
||||||
let systemContext = generateContext();
|
let systemContext = generateRoleContext(user, storeData);
|
||||||
|
|
||||||
// 2. Call Groq
|
// 2. Call Groq
|
||||||
const chatCompletion = await groq.chat.completions.create({
|
const chatCompletion = await groq.chat.completions.create({
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ const Layout = () => {
|
|||||||
case 'OWNER':
|
case 'OWNER':
|
||||||
return [
|
return [
|
||||||
{ to: "/owner/snapshot", icon: LayoutDashboard, label: "Dashboard" },
|
{ to: "/owner/snapshot", icon: LayoutDashboard, label: "Dashboard" },
|
||||||
|
{ to: "/owner/projects", icon: Briefcase, label: "Projects" },
|
||||||
{ to: "/owner/vendors", icon: Users, label: "Vendors" },
|
{ to: "/owner/vendors", icon: Users, label: "Vendors" },
|
||||||
{ to: "/owner/people", icon: User, label: "People" },
|
{ to: "/owner/people", icon: User, label: "People" },
|
||||||
{ to: "/owner/documents", icon: FileText, label: "Documents" },
|
{ to: "/owner/documents", icon: FileText, label: "Documents" },
|
||||||
@@ -149,6 +150,7 @@ const Layout = () => {
|
|||||||
{ to: "/admin/dashboard", icon: LayoutDashboard, label: "Dashboard" },
|
{ to: "/admin/dashboard", icon: LayoutDashboard, label: "Dashboard" },
|
||||||
{ to: "/admin/schedule", icon: Calendar, label: "Schedule" },
|
{ to: "/admin/schedule", icon: Calendar, label: "Schedule" },
|
||||||
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
|
{ to: "/admin/leaderboard", icon: Trophy, label: "Leaderboard" },
|
||||||
|
{ to: "/admin/maps", icon: Map, label: "Territory Map" },
|
||||||
...commonItems
|
...commonItems
|
||||||
];
|
];
|
||||||
case 'CONTRACTOR':
|
case 'CONTRACTOR':
|
||||||
|
|||||||
@@ -68,14 +68,14 @@ export const StatCard = ({ label, value, suffix = '', icon: Icon, trend, trendLa
|
|||||||
const bgColor = colorClass.split(' ')[1];
|
const bgColor = colorClass.split(' ')[1];
|
||||||
|
|
||||||
return (
|
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 className="flex items-start justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">{label}</h3>
|
<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">
|
<div className="mt-2 flex items-baseline gap-1">
|
||||||
<span
|
<span
|
||||||
ref={counterRef}
|
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
|
0
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -138,9 +138,37 @@ const FinancialSummaryModal = ({ isOpen, onClose, role, data }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Mobile Card View */}
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-auto custom-scrollbar bg-white dark:bg-[#121214]">
|
<div className="flex-1 overflow-y-auto sm:hidden custom-scrollbar bg-white dark:bg-[#121214]">
|
||||||
<table className="w-full text-left border-collapse min-w-[600px]">
|
{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">
|
<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>
|
<tr>
|
||||||
{[
|
{[
|
||||||
@@ -152,7 +180,7 @@ const FinancialSummaryModal = ({ isOpen, onClose, role, data }) => {
|
|||||||
<th
|
<th
|
||||||
key={col.key}
|
key={col.key}
|
||||||
onClick={() => handleSort(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}
|
{col.label}
|
||||||
</th>
|
</th>
|
||||||
@@ -162,22 +190,22 @@ const FinancialSummaryModal = ({ isOpen, onClose, role, data }) => {
|
|||||||
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
{sortedData.length > 0 ? sortedData.map((item, idx) => (
|
{sortedData.length > 0 ? sortedData.map((item, idx) => (
|
||||||
<tr key={idx} className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
|
<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}
|
{item.date}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 sm:px-6 py-3 sm:py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="font-semibold text-sm sm:text-base text-zinc-900 dark:text-white">{item.description || item.project}</div>
|
<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>}
|
{item.project && <div className="text-xs text-zinc-500">{item.project}</div>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 sm:px-6 py-3 sm:py-4">
|
<td className="px-6 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'
|
<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-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'
|
: 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400'
|
||||||
}`}>
|
}`}>
|
||||||
{item.status}
|
{item.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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)}
|
{formatCurrency(item.amount)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ const TaskDetailsModal = ({ isOpen, onClose, task, onUpdate }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* 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 */}
|
{/* 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">
|
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Footer Actions */}
|
{/* 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' ? (
|
{task.status !== 'completed' ? (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,54 +1,78 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useMockStore } from '../../data/mockStore';
|
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';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const DocumentReviewQueue = () => {
|
const DocumentReviewQueue = () => {
|
||||||
const { documents } = useMockStore();
|
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 => {
|
const filteredDocs = documents.filter(doc => {
|
||||||
if (filter === 'all') return true;
|
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) => {
|
const handleApprove = (id) => {
|
||||||
toast.success(`Document #${id} Approved`);
|
toast.success(`Document #${id} Approved`);
|
||||||
// In a real app, this would update the store/backend
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReject = (id) => {
|
const handleReject = (id) => {
|
||||||
toast.error(`Document #${id} Rejected`);
|
toast.error(`Document #${id} Rejected — Reason: Missing signature or incorrect entity`);
|
||||||
// In a real app, this would update the store/backend
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusColor = (status) => {
|
const getStatusColor = (status) => {
|
||||||
switch (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 '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 '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': 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 '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';
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex flex-wrap gap-2 mb-6">
|
<div className="flex gap-2 mb-4 sm:mb-6 overflow-x-auto pb-2 no-scrollbar shrink-0 -mx-1 px-1">
|
||||||
{['pending', 'approved', 'rejected', 'all'].map(f => (
|
{filterTabs.map(f => (
|
||||||
<button
|
<button
|
||||||
key={f}
|
key={f.key}
|
||||||
onClick={() => setFilter(f)}
|
onClick={() => setFilter(f.key)}
|
||||||
className={`px-4 py-2 rounded-xl text-xs font-bold uppercase tracking-wider transition-all ${filter === f
|
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 transform scale-105'
|
? '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'
|
: '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>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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 ? (
|
{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="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">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredDocs.map(doc => (
|
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 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-4">
|
<div className="flex flex-col md:flex-row justify-between gap-3 sm:gap-4">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-3 sm: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">
|
<div className={`p-2 sm:p-3 rounded-xl shrink-0 ${
|
||||||
<FileText size={24} />
|
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>
|
<div>
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
<h4 className="font-bold text-zinc-900 dark:text-white text-base">{doc.title}</h4>
|
<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)}`}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-zinc-500 dark:text-zinc-400 space-y-1">
|
<div className="text-xs text-zinc-500 dark:text-zinc-400 space-y-1">
|
||||||
<p className="flex items-center gap-2">
|
<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>
|
<span className="w-1 h-1 rounded-full bg-zinc-300 dark:bg-zinc-600"></span>
|
||||||
ID: <span className="font-mono">{doc.id}</span>
|
ID: <span className="font-mono">{doc.id}</span>
|
||||||
</p>
|
</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 && (
|
{doc.expirationDate && (
|
||||||
<p className="flex items-center gap-1 text-amber-600 dark:text-amber-400 font-medium pt-1">
|
<p className={`flex items-center gap-1 font-medium pt-1 ${
|
||||||
<Clock size={12} /> Expires: {new Date(doc.expirationDate).toLocaleDateString()}
|
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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 flex-row md:flex-col justify-center items-end gap-2 shrink-0">
|
||||||
<div className="flex gap-2">
|
<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} />
|
<Eye size={18} />
|
||||||
</button>
|
</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} />
|
<Download size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{doc.status === 'pending' && (
|
{doc.status === 'pending_review' && (
|
||||||
<div className="flex gap-2 mt-2 w-full md:w-auto">
|
<div className="flex gap-2 mt-2 w-full md:w-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleReject(doc.id)}
|
onClick={() => handleReject(doc.id)}
|
||||||
@@ -114,6 +171,15 @@ const DocumentReviewQueue = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -3,8 +3,9 @@ import { createPortal } from 'react-dom';
|
|||||||
import { X, ArrowUpRight, ArrowDownRight, Filter, Download, Search, CheckCircle, Clock, AlertCircle } from 'lucide-react';
|
import { X, ArrowUpRight, ArrowDownRight, Filter, Download, Search, CheckCircle, Clock, AlertCircle } from 'lucide-react';
|
||||||
import { useMockStore } from '../../data/mockStore';
|
import { useMockStore } from '../../data/mockStore';
|
||||||
|
|
||||||
const FinancialDetailsModal = ({ isOpen, onClose, type }) => {
|
const FinancialDetailsModal = ({ isOpen, onClose, type, ownerId }) => {
|
||||||
const { projects, vendors } = useMockStore();
|
const { projects: allProjects, vendors } = useMockStore();
|
||||||
|
const projects = ownerId ? allProjects.filter(p => p.ownerId === ownerId) : allProjects;
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [sortConfig, setSortConfig] = useState({ key: 'amount', direction: 'desc' });
|
const [sortConfig, setSortConfig] = useState({ key: 'amount', direction: 'desc' });
|
||||||
|
|
||||||
@@ -152,9 +153,9 @@ const FinancialDetailsModal = ({ isOpen, onClose, type }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Header */}
|
{/* 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 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>
|
<div className="flex-1 min-w-0">
|
||||||
<h2 id="modal-title" className="text-2xl font-bold text-zinc-900 dark:text-white flex items-center gap-2">
|
<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 === 'revenue' && <ArrowUpRight className="text-emerald-500" />}
|
||||||
{type === 'ar' && <Clock className="text-blue-500" />}
|
{type === 'ar' && <Clock className="text-blue-500" />}
|
||||||
{type === 'payouts' && <AlertCircle className="text-amber-500" />}
|
{type === 'payouts' && <AlertCircle className="text-amber-500" />}
|
||||||
@@ -180,7 +181,7 @@ const FinancialDetailsModal = ({ isOpen, onClose, type }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
{/* 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">
|
<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} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
|
||||||
<input
|
<input
|
||||||
@@ -198,8 +199,34 @@ const FinancialDetailsModal = ({ isOpen, onClose, type }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Mobile Card View */}
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar bg-white dark:bg-[#121214]">
|
<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} · {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">
|
<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">
|
<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>
|
<tr>
|
||||||
@@ -258,7 +285,7 @@ const FinancialDetailsModal = ({ isOpen, onClose, type }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Summary */}
|
{/* 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>
|
<span className="text-zinc-500 dark:text-zinc-400">Showing {sortedItems.length} records</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-zinc-500 dark:text-zinc-400">Net Total:</span>
|
<span className="text-zinc-500 dark:text-zinc-400">Net Total:</span>
|
||||||
|
|||||||
@@ -3,18 +3,17 @@ import { useMockStore } from '../../data/mockStore';
|
|||||||
import { DollarSign, TrendingUp, TrendingDown, CreditCard } from 'lucide-react';
|
import { DollarSign, TrendingUp, TrendingDown, CreditCard } from 'lucide-react';
|
||||||
import { SpotlightCard } from '../SpotlightCard';
|
import { SpotlightCard } from '../SpotlightCard';
|
||||||
|
|
||||||
const FinancialKPICards = ({ onCardClick }) => {
|
const FinancialKPICards = ({ onCardClick, ownerId }) => {
|
||||||
const { projects, vendors } = useMockStore();
|
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(() => {
|
const metrics = useMemo(() => {
|
||||||
let totalRevenue = 0; // Derived from active project budgets (simplified for demo)
|
const ownerProjects = ownerId ? projects.filter(p => p.ownerId === ownerId) : projects;
|
||||||
let outstandingAR = 0; // Invoices sent but not paid
|
|
||||||
let pendingPayouts = 0; // Vendor invoices pending
|
|
||||||
let vendorBillsDue = 0;
|
|
||||||
|
|
||||||
projects.forEach(project => {
|
let totalRevenue = 0;
|
||||||
|
let pendingPayouts = 0;
|
||||||
|
|
||||||
|
ownerProjects.forEach(project => {
|
||||||
if (project.status === 'active' || project.status === 'completed') {
|
if (project.status === 'active' || project.status === 'completed') {
|
||||||
totalRevenue += project.budget;
|
totalRevenue += project.budget;
|
||||||
}
|
}
|
||||||
@@ -32,11 +31,11 @@ const FinancialKPICards = ({ onCardClick }) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
totalRevenue,
|
totalRevenue,
|
||||||
outstandingAR: totalRevenue * 0.3, // Mocking AR as 30% of revenue for demo
|
outstandingAR: totalRevenue * 0.3,
|
||||||
pendingPayouts,
|
pendingPayouts,
|
||||||
totalVendorSpend
|
totalVendorSpend
|
||||||
};
|
};
|
||||||
}, [projects, vendors]);
|
}, [projects, vendors, ownerId]);
|
||||||
|
|
||||||
const cards = [
|
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;
|
||||||
@@ -3,9 +3,11 @@ import { AlertCircle, Clock, FileWarning, Wallet, CheckCircle, AlertTriangle, Ch
|
|||||||
import { SpotlightCard } from '../SpotlightCard';
|
import { SpotlightCard } from '../SpotlightCard';
|
||||||
import { useMockStore } from '../../data/mockStore';
|
import { useMockStore } from '../../data/mockStore';
|
||||||
|
|
||||||
const UrgentItemsPanel = ({ onActionClick }) => {
|
const UrgentItemsPanel = ({ onActionClick, ownerId }) => {
|
||||||
const { documents, vendors, projects } = useMockStore();
|
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
|
// Logic to find urgent items
|
||||||
const expiringDocs = documents.filter(d => {
|
const expiringDocs = documents.filter(d => {
|
||||||
@@ -22,7 +24,7 @@ const UrgentItemsPanel = ({ onActionClick }) => {
|
|||||||
v.compliance.coi.status === 'expired'
|
v.compliance.coi.status === 'expired'
|
||||||
);
|
);
|
||||||
|
|
||||||
const pendingInvoices = projects.flatMap(p =>
|
const pendingInvoices = ownerProjects.flatMap(p =>
|
||||||
(p.invoices || []).filter(i => i.status === 'pending')
|
(p.invoices || []).filter(i => i.status === 'pending')
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+42
-12
@@ -139,9 +139,39 @@ const VendorFinancialSummaryModal = ({ isOpen, onClose, data }) => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Mobile Card View */}
|
||||||
<div className="flex-1 overflow-y-auto overflow-x-auto custom-scrollbar bg-white dark:bg-[#121214]">
|
<div className="flex-1 overflow-y-auto sm:hidden custom-scrollbar bg-white dark:bg-[#121214]">
|
||||||
<table className="w-full text-left border-collapse min-w-[600px]">
|
{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">
|
<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>
|
<tr>
|
||||||
{[
|
{[
|
||||||
@@ -155,7 +185,7 @@ const VendorFinancialSummaryModal = ({ isOpen, onClose, data }) => {
|
|||||||
<th
|
<th
|
||||||
key={col.key}
|
key={col.key}
|
||||||
onClick={() => handleSort(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}
|
{col.label}
|
||||||
</th>
|
</th>
|
||||||
@@ -165,21 +195,21 @@ const VendorFinancialSummaryModal = ({ isOpen, onClose, data }) => {
|
|||||||
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
<tbody className="divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
{sortedData.length > 0 ? sortedData.map((invoice, idx) => (
|
{sortedData.length > 0 ? sortedData.map((invoice, idx) => (
|
||||||
<tr key={idx} className="hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors">
|
<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}
|
{invoice.id}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 sm:px-6 py-3 sm:py-4">
|
<td className="px-6 py-4">
|
||||||
<div className="font-semibold text-sm sm:text-base text-zinc-900 dark:text-white">{invoice.project}</div>
|
<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>}
|
{invoice.description && <div className="text-xs text-zinc-500">{invoice.description}</div>}
|
||||||
</td>
|
</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}
|
{invoice.invoiceDate}
|
||||||
</td>
|
</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}
|
{invoice.dueDate}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 sm:px-6 py-3 sm:py-4">
|
<td className="px-6 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'
|
<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'
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400'
|
||||||
: invoice.status === 'pending'
|
: invoice.status === 'pending'
|
||||||
? 'bg-amber-100 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400'
|
? '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}
|
{invoice.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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)}
|
{formatCurrency(invoice.amount)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
+1436
-218
File diff suppressed because it is too large
Load Diff
@@ -371,6 +371,7 @@ const AdminSchedule = () => {
|
|||||||
</div>
|
</div>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ const LeaderboardPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ const Login = () => {
|
|||||||
</div>
|
</div>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -414,6 +414,7 @@ function Maps() {
|
|||||||
loadingAddr={loadingAddr}
|
loadingAddr={loadingAddr}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
/>
|
/>
|
||||||
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ const NotFound = () => {
|
|||||||
<div className="absolute bottom-8 text-[10px] uppercase font-bold tracking-widest text-zinc-300 dark:text-zinc-700">
|
<div className="absolute bottom-8 text-[10px] uppercase font-bold tracking-widest text-zinc-300 dark:text-zinc-700">
|
||||||
LynkedUpPro Infrastructure
|
LynkedUpPro Infrastructure
|
||||||
</div>
|
</div>
|
||||||
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,44 +4,51 @@ import { useAuth } from '../../context/AuthContext';
|
|||||||
import { useMockStore } from '../../data/mockStore';
|
import { useMockStore } from '../../data/mockStore';
|
||||||
import { StatCard } from '../../components/StatCard';
|
import { StatCard } from '../../components/StatCard';
|
||||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
import { Hammer, CheckSquare, Clock, AlertTriangle, Calendar, DollarSign } from 'lucide-react';
|
import { Hammer, CheckSquare, Clock, AlertTriangle, Calendar, DollarSign, PauseCircle } from 'lucide-react';
|
||||||
import ProjectDetailsModal from '../../components/contractor/ProjectDetailsModal';
|
import ProjectDetailsModal from '../../components/contractor/ProjectDetailsModal';
|
||||||
import FinancialSummaryModal from '../../components/contractor/FinancialSummaryModal';
|
import FinancialSummaryModal from '../../components/contractor/FinancialSummaryModal';
|
||||||
|
import TaskDetailsModal from '../../components/contractor/TaskDetailsModal';
|
||||||
|
|
||||||
const ContractorDashboard = () => {
|
const ContractorDashboard = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { projects } = useMockStore();
|
const { projects } = useMockStore();
|
||||||
|
|
||||||
// 1. Identify current contractor
|
// 1. Identify current contractor
|
||||||
// Filter projects where this contractor is the Lead
|
const myProjects = projects.filter(p => p.contractorId === user?.id || p.contractorId === 'con_001');
|
||||||
const myProjects = projects.filter(p => p.contractorId === user?.id || p.contractorId === 'con_001'); // Default to con_001 for mock
|
|
||||||
|
|
||||||
// 2. Aggregate Data
|
// 2. Aggregate Data
|
||||||
const activeProjects = myProjects.filter(p => p.status === 'active').length;
|
const activeProjects = myProjects.filter(p => p.status === 'active').length;
|
||||||
const upcomingProjects = myProjects.filter(p => p.status === 'scheduled').length;
|
const onHoldProjects = myProjects.filter(p => p.status === 'on_hold').length;
|
||||||
|
const completedProjects = myProjects.filter(p => p.status === 'completed').length;
|
||||||
|
|
||||||
// Calculate total budget managed
|
// Calculate total budget managed
|
||||||
const totalBudget = myProjects.reduce((sum, p) => sum + (p.budget || 0), 0);
|
const totalBudget = myProjects.reduce((sum, p) => sum + (p.budget || 0), 0);
|
||||||
const totalSpent = myProjects.reduce((sum, p) => sum + (p.spent || 0), 0);
|
const totalSpent = myProjects.reduce((sum, p) => sum + (p.spent || 0), 0);
|
||||||
const budgetUtilization = totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0;
|
const budgetUtilization = totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 100) : 0;
|
||||||
|
|
||||||
// Upcoming Milestones (next 7 days)
|
// Pending invoice amounts
|
||||||
const upcomingMilestones = myProjects.flatMap(p =>
|
const pendingInvoiceAmount = myProjects.reduce((sum, p) =>
|
||||||
p.milestones.filter(m => {
|
sum + (p.invoices || []).filter(i => i.status === 'pending').reduce((s, i) => s + i.amount, 0), 0
|
||||||
const dueDate = new Date(m.dueDate);
|
|
||||||
const today = new Date('2026-02-05'); // Fixed context date
|
|
||||||
const diffTime = Math.abs(dueDate - today);
|
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
|
||||||
return diffDays <= 7 && m.status !== 'completed';
|
|
||||||
}).map(m => ({ ...m, projectAddress: p.address }))
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Upcoming Milestones (next 14 days from context date)
|
||||||
|
const upcomingMilestones = myProjects.flatMap(p =>
|
||||||
|
(p.milestones || []).filter(m => {
|
||||||
|
const dueDate = new Date(m.dueDate);
|
||||||
|
const today = new Date('2026-02-18'); // Current context date
|
||||||
|
const diffDays = (dueDate - today) / (1000 * 60 * 60 * 24);
|
||||||
|
return diffDays >= -3 && diffDays <= 14 && m.status !== 'completed';
|
||||||
|
}).map(m => ({ ...m, projectAddress: p.address, projectId: p.id }))
|
||||||
|
).sort((a, b) => new Date(a.dueDate) - new Date(b.dueDate));
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
// Modal state
|
// Modal state
|
||||||
const [selectedProject, setSelectedProject] = useState(null);
|
const [selectedProject, setSelectedProject] = useState(null);
|
||||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||||
const [isFinancialModalOpen, setIsFinancialModalOpen] = useState(false);
|
const [isFinancialModalOpen, setIsFinancialModalOpen] = useState(false);
|
||||||
|
const [selectedTask, setSelectedTask] = useState(null);
|
||||||
|
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||||
|
|
||||||
// Financial data for modal
|
// Financial data for modal
|
||||||
const financialData = {
|
const financialData = {
|
||||||
@@ -49,7 +56,7 @@ const ContractorDashboard = () => {
|
|||||||
spent: totalSpent,
|
spent: totalSpent,
|
||||||
remaining: totalBudget - totalSpent,
|
remaining: totalBudget - totalSpent,
|
||||||
paid: totalSpent,
|
paid: totalSpent,
|
||||||
pending: 0,
|
pending: pendingInvoiceAmount,
|
||||||
items: myProjects.map(p => ({
|
items: myProjects.map(p => ({
|
||||||
date: p.startDate,
|
date: p.startDate,
|
||||||
description: p.address,
|
description: p.address,
|
||||||
@@ -64,6 +71,11 @@ const ContractorDashboard = () => {
|
|||||||
setIsProjectModalOpen(true);
|
setIsProjectModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTaskClick = (task) => {
|
||||||
|
setSelectedTask(task);
|
||||||
|
setIsTaskModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 md:p-10 max-w-7xl mx-auto space-y-8 animate-in fade-in duration-500">
|
<div className="p-6 md:p-10 max-w-7xl mx-auto space-y-8 animate-in fade-in duration-500">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -89,18 +101,24 @@ const ContractorDashboard = () => {
|
|||||||
|
|
||||||
{/* KPI Cards */}
|
{/* KPI Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<StatCard
|
<div onClick={() => navigate('/contractor/projects')} className="cursor-pointer">
|
||||||
label="Active Projects"
|
<StatCard
|
||||||
value={activeProjects}
|
label="Active Projects"
|
||||||
icon={Hammer}
|
value={activeProjects}
|
||||||
color="blue"
|
icon={Hammer}
|
||||||
/>
|
color="blue"
|
||||||
<StatCard
|
trendLabel={`${completedProjects} completed`}
|
||||||
label="Upcoming Starts"
|
/>
|
||||||
value={upcomingProjects}
|
</div>
|
||||||
icon={Calendar}
|
<div onClick={() => navigate('/contractor/projects')} className="cursor-pointer">
|
||||||
color="purple"
|
<StatCard
|
||||||
/>
|
label="On Hold"
|
||||||
|
value={onHoldProjects}
|
||||||
|
icon={PauseCircle}
|
||||||
|
color="purple"
|
||||||
|
trendLabel={`${myProjects.length} total`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer">
|
<div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Budget Utilization"
|
label="Budget Utilization"
|
||||||
@@ -110,12 +128,15 @@ const ContractorDashboard = () => {
|
|||||||
suffix="%"
|
suffix="%"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<StatCard
|
<div onClick={() => { if (upcomingMilestones.length > 0) handleTaskClick(upcomingMilestones[0]); }} className="cursor-pointer">
|
||||||
label="Pending Actions"
|
<StatCard
|
||||||
value={upcomingMilestones.length}
|
label="Pending Actions"
|
||||||
icon={AlertTriangle}
|
value={upcomingMilestones.length}
|
||||||
color="amber"
|
icon={AlertTriangle}
|
||||||
/>
|
color="amber"
|
||||||
|
trendLabel="due soon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
@@ -141,8 +162,14 @@ const ContractorDashboard = () => {
|
|||||||
<h4 className="font-bold text-zinc-900 dark:text-white">{proj.address}</h4>
|
<h4 className="font-bold text-zinc-900 dark:text-white">{proj.address}</h4>
|
||||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">{proj.projectType} • Started {new Date(proj.startDate).toLocaleDateString()}</p>
|
<p className="text-xs text-zinc-500 dark:text-zinc-400">{proj.projectType} • Started {new Date(proj.startDate).toLocaleDateString()}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="px-2 py-1 bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-xs font-bold rounded uppercase">
|
<span className={`px-2 py-1 text-xs font-bold rounded uppercase ${
|
||||||
On Track
|
proj.spent > proj.budget
|
||||||
|
? 'bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400'
|
||||||
|
: proj.completionPercentage >= 70
|
||||||
|
? 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400'
|
||||||
|
: 'bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400'
|
||||||
|
}`}>
|
||||||
|
{proj.spent > proj.budget ? 'Over Budget' : proj.completionPercentage >= 70 ? 'On Track' : 'In Progress'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -185,15 +212,37 @@ const ContractorDashboard = () => {
|
|||||||
{/* Right Panel: Tasks & Alerts */}
|
{/* Right Panel: Tasks & Alerts */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<SpotlightCard className="p-6">
|
<SpotlightCard className="p-6">
|
||||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Critical Tasks (Next 7 Days)</h2>
|
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Critical Tasks (Next 14 Days)</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{upcomingMilestones.length > 0 ? upcomingMilestones.map((ms, idx) => (
|
{upcomingMilestones.length > 0 ? upcomingMilestones.map((ms, idx) => (
|
||||||
<div key={idx} className="flex items-start gap-3 p-3 rounded-lg bg-red-50 dark:bg-red-500/5 hover:bg-red-100 dark:hover:bg-red-500/10 transition-colors cursor-pointer group">
|
<div
|
||||||
<div className="mt-1 w-2 h-2 rounded-full bg-red-500 shrink-0 group-hover:scale-125 transition-transform" />
|
key={idx}
|
||||||
|
onClick={() => handleTaskClick(ms)}
|
||||||
|
className={`flex items-start gap-3 p-3 rounded-lg transition-colors cursor-pointer group ${
|
||||||
|
new Date(ms.dueDate) < new Date('2026-02-18')
|
||||||
|
? 'bg-red-50 dark:bg-red-500/5 hover:bg-red-100 dark:hover:bg-red-500/10'
|
||||||
|
: ms.status === 'in_progress'
|
||||||
|
? 'bg-blue-50 dark:bg-blue-500/5 hover:bg-blue-100 dark:hover:bg-blue-500/10'
|
||||||
|
: 'bg-amber-50 dark:bg-amber-500/5 hover:bg-amber-100 dark:hover:bg-amber-500/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`mt-1 w-2 h-2 rounded-full shrink-0 group-hover:scale-125 transition-transform ${
|
||||||
|
new Date(ms.dueDate) < new Date('2026-02-18') ? 'bg-red-500' :
|
||||||
|
ms.status === 'in_progress' ? 'bg-blue-500' : 'bg-amber-500'
|
||||||
|
}`} />
|
||||||
<div>
|
<div>
|
||||||
<h5 className="text-sm font-bold text-zinc-900 dark:text-white">{ms.name}</h5>
|
<h5 className="text-sm font-bold text-zinc-900 dark:text-white">{ms.name}</h5>
|
||||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{ms.projectAddress}</p>
|
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{ms.projectAddress}</p>
|
||||||
<p className="text-xs font-mono font-medium text-red-500 mt-1">Due: {ms.dueDate}</p>
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<p className={`text-xs font-mono font-medium ${
|
||||||
|
new Date(ms.dueDate) < new Date('2026-02-18') ? 'text-red-500' : 'text-zinc-500'
|
||||||
|
}`}>Due: {ms.dueDate}</p>
|
||||||
|
<span className={`text-[10px] font-bold uppercase px-1.5 py-0.5 rounded ${
|
||||||
|
ms.status === 'in_progress'
|
||||||
|
? 'bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400'
|
||||||
|
: 'bg-amber-100 text-amber-600 dark:bg-amber-500/20 dark:text-amber-400'
|
||||||
|
}`}>{ms.status.replace('_', ' ')}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)) : (
|
)) : (
|
||||||
@@ -233,6 +282,12 @@ const ContractorDashboard = () => {
|
|||||||
role="CONTRACTOR"
|
role="CONTRACTOR"
|
||||||
data={financialData}
|
data={financialData}
|
||||||
/>
|
/>
|
||||||
|
<TaskDetailsModal
|
||||||
|
isOpen={isTaskModalOpen}
|
||||||
|
onClose={() => setIsTaskModalOpen(false)}
|
||||||
|
task={selectedTask}
|
||||||
|
/>
|
||||||
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ const ProjectList = () => {
|
|||||||
onClose={() => setIsProjectModalOpen(false)}
|
onClose={() => setIsProjectModalOpen(false)}
|
||||||
project={selectedProject}
|
project={selectedProject}
|
||||||
/>
|
/>
|
||||||
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,42 +1,64 @@
|
|||||||
import React from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
import { useMockStore } from '../../data/mockStore';
|
||||||
import DocumentReviewQueue from '../../components/documents/DocumentReviewQueue';
|
import DocumentReviewQueue from '../../components/documents/DocumentReviewQueue';
|
||||||
import { FileText, Shield, AlertTriangle, CheckCircle, Clock } from 'lucide-react';
|
import { FileText, Shield, AlertTriangle, CheckCircle, Clock, XCircle } from 'lucide-react';
|
||||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const DocumentManagement = () => {
|
const DocumentManagement = () => {
|
||||||
|
const { documents } = useMockStore();
|
||||||
|
|
||||||
|
// Live stats from actual data
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const pendingReview = documents.filter(d => d.status === 'pending_review').length;
|
||||||
|
const expired = documents.filter(d => d.status === 'expired').length;
|
||||||
|
const expiringSoon = documents.filter(d => {
|
||||||
|
if (!d.expirationDate || d.status === 'expired') return false;
|
||||||
|
const expDate = new Date(d.expirationDate);
|
||||||
|
const today = new Date('2026-02-18');
|
||||||
|
const diffDays = (expDate - today) / (1000 * 60 * 60 * 24);
|
||||||
|
return diffDays > 0 && diffDays <= 30;
|
||||||
|
}).length;
|
||||||
|
const approved = documents.filter(d => d.status === 'approved').length;
|
||||||
|
return { pendingReview, expired, expiringSoon, approved, total: documents.length };
|
||||||
|
}, [documents]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 overflow-hidden relative">
|
<div className="h-screen flex flex-col bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 overflow-hidden lg:overflow-hidden overflow-y-auto relative">
|
||||||
{/* Ambient Background Glows */}
|
{/* Ambient Background Glows */}
|
||||||
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||||
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-purple-500/5 dark:bg-purple-900/10 rounded-full blur-[120px]" />
|
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-purple-500/5 dark:bg-purple-900/10 rounded-full blur-[120px]" />
|
||||||
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-blue-500/5 dark:bg-blue-900/10 rounded-full blur-[120px]" />
|
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-blue-500/5 dark:bg-blue-900/10 rounded-full blur-[120px]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 flex-1 flex flex-col min-h-0 max-w-7xl mx-auto w-full p-4 md:p-6 lg:p-8">
|
<div className="relative z-10 flex-1 flex flex-col lg:min-h-0 max-w-7xl mx-auto w-full p-4 md:p-6 lg:p-8">
|
||||||
<header className="mb-6 flex flex-col md:flex-row justify-between items-start md:items-end shrink-0">
|
<header className="mb-6 flex flex-col md:flex-row justify-between items-start md:items-end shrink-0">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 tracking-tight">Document Control</h1>
|
<h1 className="text-3xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 tracking-tight">Document Control</h1>
|
||||||
<p className="text-zinc-500 dark:text-zinc-400 mt-1">Review, approve, and manage critical business documents.</p>
|
<p className="text-zinc-500 dark:text-zinc-400 mt-1">Review, approve, and manage critical business documents.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 md:mt-0 flex space-x-3">
|
<div className="mt-4 md:mt-0 flex flex-wrap gap-3">
|
||||||
<SpotlightCard className="cursor-pointer hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors" rounded="rounded-xl">
|
<SpotlightCard className="cursor-pointer hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors" rounded="rounded-xl">
|
||||||
<div className="px-4 py-2 flex items-center gap-2">
|
<div className="px-4 py-2 flex items-center gap-2">
|
||||||
<Shield size={16} className="text-emerald-500" />
|
<Shield size={16} className="text-emerald-500" />
|
||||||
<span className="text-sm font-bold text-zinc-700 dark:text-zinc-300">Audit Log</span>
|
<span className="text-sm font-bold text-zinc-700 dark:text-zinc-300">Audit Log</span>
|
||||||
</div>
|
</div>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
<button className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-sm font-bold transition-colors shadow-lg shadow-blue-600/20">
|
<button
|
||||||
|
onClick={() => toast.info('Upload modal coming soon')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl text-sm font-bold transition-colors shadow-lg shadow-blue-600/20"
|
||||||
|
>
|
||||||
<FileText size={16} />
|
<FileText size={16} />
|
||||||
<span>Upload New</span>
|
<span>Upload New</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0">
|
<div className="flex-1 flex flex-col lg:flex-row gap-6 lg:min-h-0">
|
||||||
{/* Main Content: Review Queue */}
|
{/* Main Content: Review Queue */}
|
||||||
<div className="w-full lg:w-2/3 flex flex-col min-h-0">
|
<div className="w-full lg:w-2/3 flex flex-col min-h-0">
|
||||||
<SpotlightCard className="h-full flex flex-col overflow-hidden">
|
<SpotlightCard className="lg:h-full flex flex-col lg:overflow-hidden">
|
||||||
<div className="p-6 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5 flex items-center gap-2">
|
<div className="p-4 sm:p-6 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5 flex items-center gap-2 shrink-0">
|
||||||
<div className="p-2 bg-amber-100 dark:bg-amber-500/10 rounded-lg text-amber-600 dark:text-amber-400">
|
<div className="p-2 bg-amber-100 dark:bg-amber-500/10 rounded-lg text-amber-600 dark:text-amber-400">
|
||||||
<AlertTriangle size={20} />
|
<AlertTriangle size={20} />
|
||||||
</div>
|
</div>
|
||||||
@@ -45,7 +67,7 @@ const DocumentManagement = () => {
|
|||||||
<p className="text-xs text-zinc-500 dark:text-zinc-400">Documents requiring your attention</p>
|
<p className="text-xs text-zinc-500 dark:text-zinc-400">Documents requiring your attention</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-hidden p-6">
|
<div className="flex-1 lg:overflow-hidden p-4 sm:p-6">
|
||||||
<DocumentReviewQueue />
|
<DocumentReviewQueue />
|
||||||
</div>
|
</div>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
@@ -53,28 +75,44 @@ const DocumentManagement = () => {
|
|||||||
|
|
||||||
{/* Sidebar: Compliance Overview */}
|
{/* Sidebar: Compliance Overview */}
|
||||||
<div className="w-full lg:w-1/3 flex flex-col gap-6">
|
<div className="w-full lg:w-1/3 flex flex-col gap-6">
|
||||||
<SpotlightCard className="p-6">
|
<SpotlightCard className="p-4 sm:p-6">
|
||||||
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider mb-4 flex items-center gap-2">
|
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||||
<Clock size={16} className="text-blue-500" />
|
<Clock size={16} className="text-blue-500" />
|
||||||
Quick Stats
|
Quick Stats
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex justify-between items-center py-3 border-b border-zinc-100 dark:border-white/5">
|
<div className="flex justify-between items-center py-3 border-b border-zinc-100 dark:border-white/5">
|
||||||
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Pending Approval</span>
|
<span className="text-zinc-500 dark:text-zinc-400 text-sm flex items-center gap-2">
|
||||||
<span className="text-xl font-bold text-amber-500">3</span>
|
<Clock size={14} className="text-amber-500" /> Pending Review
|
||||||
|
</span>
|
||||||
|
<span className="text-xl font-bold text-amber-500">{stats.pendingReview}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center py-3 border-b border-zinc-100 dark:border-white/5">
|
<div className="flex justify-between items-center py-3 border-b border-zinc-100 dark:border-white/5">
|
||||||
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Expiring Soon (30d)</span>
|
<span className="text-zinc-500 dark:text-zinc-400 text-sm flex items-center gap-2">
|
||||||
<span className="text-xl font-bold text-red-500">1</span>
|
<XCircle size={14} className="text-red-500" /> Expired
|
||||||
|
</span>
|
||||||
|
<span className="text-xl font-bold text-red-500">{stats.expired}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between items-center py-3 border-b border-zinc-100 dark:border-white/5">
|
<div className="flex justify-between items-center py-3 border-b border-zinc-100 dark:border-white/5">
|
||||||
|
<span className="text-zinc-500 dark:text-zinc-400 text-sm flex items-center gap-2">
|
||||||
|
<AlertTriangle size={14} className="text-orange-500" /> Expiring Soon (30d)
|
||||||
|
</span>
|
||||||
|
<span className="text-xl font-bold text-orange-500">{stats.expiringSoon}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-3 border-b border-zinc-100 dark:border-white/5">
|
||||||
|
<span className="text-zinc-500 dark:text-zinc-400 text-sm flex items-center gap-2">
|
||||||
|
<CheckCircle size={14} className="text-emerald-500" /> Approved
|
||||||
|
</span>
|
||||||
|
<span className="text-xl font-bold text-emerald-500">{stats.approved}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-3">
|
||||||
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Total Documents</span>
|
<span className="text-zinc-500 dark:text-zinc-400 text-sm">Total Documents</span>
|
||||||
<span className="text-xl font-bold text-zinc-900 dark:text-white">124</span>
|
<span className="text-xl font-bold text-zinc-900 dark:text-white">{stats.total}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
|
|
||||||
<SpotlightCard className="p-6 bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/10 dark:to-indigo-900/10 border-blue-200 dark:border-blue-500/20">
|
<SpotlightCard className="p-4 sm:p-6 bg-gradient-to-br from-blue-50 to-indigo-50 dark:from-blue-900/10 dark:to-indigo-900/10 border-blue-200 dark:border-blue-500/20">
|
||||||
<h3 className="text-sm font-bold text-blue-700 dark:text-blue-300 mb-2 flex items-center gap-2">
|
<h3 className="text-sm font-bold text-blue-700 dark:text-blue-300 mb-2 flex items-center gap-2">
|
||||||
<CheckCircle size={16} />
|
<CheckCircle size={16} />
|
||||||
Compliance Tip
|
Compliance Tip
|
||||||
@@ -86,6 +124,7 @@ const DocumentManagement = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,40 +1,131 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
|
import { useMockStore } from '../../data/mockStore';
|
||||||
import FinancialKPICards from '../../components/owner/FinancialKPICards';
|
import FinancialKPICards from '../../components/owner/FinancialKPICards';
|
||||||
import UrgentItemsPanel from '../../components/owner/UrgentItemsPanel';
|
import UrgentItemsPanel from '../../components/owner/UrgentItemsPanel';
|
||||||
import FinancialDetailsModal from '../../components/owner/FinancialDetailsModal';
|
import FinancialDetailsModal from '../../components/owner/FinancialDetailsModal';
|
||||||
import ActionCenterModal from '../../components/owner/ActionCenterModal';
|
import ActionCenterModal from '../../components/owner/ActionCenterModal';
|
||||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend
|
||||||
|
} from 'recharts';
|
||||||
|
import {
|
||||||
|
Briefcase, TrendingUp, AlertTriangle, Shield, ChevronRight, ArrowUpRight
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
const OwnerSnapshot = () => {
|
const OwnerSnapshot = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { owners, projects } = useMockStore();
|
||||||
|
|
||||||
|
// Find the current owner profile
|
||||||
|
const owner = useMemo(() =>
|
||||||
|
owners?.find(o => o.id === user?.id) || null
|
||||||
|
, [owners, user]);
|
||||||
|
|
||||||
|
// Filter projects for this owner
|
||||||
|
const ownerProjects = useMemo(() =>
|
||||||
|
projects.filter(p => p.ownerId === user?.id)
|
||||||
|
, [projects, user]);
|
||||||
|
|
||||||
// Modal States
|
// Modal States
|
||||||
const [financialModal, setFinancialModal] = useState({ isOpen: false, type: 'revenue' });
|
const [financialModal, setFinancialModal] = useState({ isOpen: false, type: 'revenue' });
|
||||||
const [actionModal, setActionModal] = useState({ isOpen: false, filter: 'all' });
|
const [actionModal, setActionModal] = useState({ isOpen: false, filter: 'all' });
|
||||||
|
|
||||||
// Handlers
|
|
||||||
const handleFinancialClick = (type) => {
|
const handleFinancialClick = (type) => {
|
||||||
setFinancialModal({ isOpen: true, type });
|
setFinancialModal({ isOpen: true, type });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleActionClick = (id) => {
|
const handleActionClick = (id) => {
|
||||||
// Map IDs to filter types
|
const filterMap = { docs: 'docs', vendors: 'vendors', invoices: 'invoices', all: 'all' };
|
||||||
const filterMap = {
|
|
||||||
'docs': 'docs',
|
|
||||||
'vendors': 'vendors',
|
|
||||||
'invoices': 'invoices',
|
|
||||||
'all': 'all'
|
|
||||||
};
|
|
||||||
setActionModal({ isOpen: true, filter: filterMap[id] || 'all' });
|
setActionModal({ isOpen: true, filter: filterMap[id] || 'all' });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get time of day for greeting
|
// Greeting
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
const greeting = hour < 12 ? 'Good Morning' : hour < 18 ? 'Good Afternoon' : 'Good Evening';
|
const greeting = hour < 12 ? 'Good Morning' : hour < 18 ? 'Good Afternoon' : 'Good Evening';
|
||||||
|
|
||||||
|
// --- Derived Stats ---
|
||||||
|
const projectStats = useMemo(() => {
|
||||||
|
const active = ownerProjects.filter(p => p.status === 'active').length;
|
||||||
|
const completed = ownerProjects.filter(p => p.status === 'completed').length;
|
||||||
|
const delayed = ownerProjects.filter(p => p.status === 'delayed').length;
|
||||||
|
const onHold = ownerProjects.filter(p => p.status === 'on_hold').length;
|
||||||
|
const disputed = ownerProjects.filter(p => p.status === 'disputed').length;
|
||||||
|
const totalBudget = ownerProjects.reduce((sum, p) => sum + (p.approvedBudget || p.budget || 0), 0);
|
||||||
|
const totalSpent = ownerProjects.reduce((sum, p) => sum + (p.actualCost || p.spent || 0), 0);
|
||||||
|
const avgHealth = ownerProjects.length > 0
|
||||||
|
? Math.round(ownerProjects.reduce((sum, p) => sum + (p.healthScore || 0), 0) / ownerProjects.length)
|
||||||
|
: 0;
|
||||||
|
return { active, completed, delayed, onHold, disputed, totalBudget, totalSpent, avgHealth, total: ownerProjects.length };
|
||||||
|
}, [ownerProjects]);
|
||||||
|
|
||||||
|
// --- Chart Data ---
|
||||||
|
const statusChartData = useMemo(() => [
|
||||||
|
{ name: 'Active', value: projectStats.active, color: '#22c55e' },
|
||||||
|
{ name: 'Completed', value: projectStats.completed, color: '#3b82f6' },
|
||||||
|
{ name: 'Delayed', value: projectStats.delayed, color: '#f59e0b' },
|
||||||
|
{ name: 'On Hold', value: projectStats.onHold, color: '#a855f7' },
|
||||||
|
{ name: 'Disputed', value: projectStats.disputed, color: '#ef4444' },
|
||||||
|
].filter(d => d.value > 0), [projectStats]);
|
||||||
|
|
||||||
|
const budgetChartData = useMemo(() =>
|
||||||
|
ownerProjects.slice(0, 6).map(p => ({
|
||||||
|
name: p.projectType.length > 14 ? p.projectType.slice(0, 14) + '...' : p.projectType,
|
||||||
|
budget: (p.approvedBudget || p.budget || 0) / 1000,
|
||||||
|
spent: (p.actualCost || p.spent || 0) / 1000,
|
||||||
|
}))
|
||||||
|
, [ownerProjects]);
|
||||||
|
|
||||||
|
const monthlySpendData = owner?.dashboardSummary?.monthlySpend || [];
|
||||||
|
|
||||||
|
// Activity feed from owner's projects
|
||||||
|
const activityFeed = useMemo(() => {
|
||||||
|
const items = [];
|
||||||
|
ownerProjects.forEach(p => {
|
||||||
|
if (p.activityTimeline) {
|
||||||
|
p.activityTimeline.forEach(a => items.push({ ...a, projectType: p.projectType, projectId: p.id }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Sort by date desc, take top 5
|
||||||
|
items.sort((a, b) => new Date(b.date) - new Date(a.date));
|
||||||
|
return items.slice(0, 5);
|
||||||
|
}, [ownerProjects]);
|
||||||
|
|
||||||
|
const formatCurrency = (val) => {
|
||||||
|
if (val >= 1000000) return `$${(val / 1000000).toFixed(1)}M`;
|
||||||
|
if (val >= 1000) return `$${(val / 1000).toFixed(0)}k`;
|
||||||
|
return `$${val}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRiskColor = (score) => {
|
||||||
|
if (score <= 30) return 'text-emerald-500 bg-emerald-100 dark:bg-emerald-500/10';
|
||||||
|
if (score <= 60) return 'text-amber-500 bg-amber-100 dark:bg-amber-500/10';
|
||||||
|
return 'text-red-500 bg-red-100 dark:bg-red-500/10';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getHealthColor = (score) => {
|
||||||
|
if (score >= 70) return 'text-emerald-500';
|
||||||
|
if (score >= 40) return 'text-amber-500';
|
||||||
|
return 'text-red-500';
|
||||||
|
};
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }) => {
|
||||||
|
if (active && payload?.length) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-xl px-4 py-3 shadow-xl text-sm">
|
||||||
|
<p className="font-bold text-zinc-900 dark:text-white mb-1">{label}</p>
|
||||||
|
{payload.map((p, i) => (
|
||||||
|
<p key={i} className="text-zinc-600 dark:text-zinc-400">
|
||||||
|
<span className="font-semibold" style={{ color: p.color }}>{p.name}:</span> ${p.value}k
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white relative pb-20 transition-colors duration-300">
|
<div className="min-h-full bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white relative pb-20 transition-colors duration-300">
|
||||||
@@ -44,44 +135,136 @@ const OwnerSnapshot = () => {
|
|||||||
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-blue-500/5 dark:bg-blue-900/10 rounded-full blur-[120px]" />
|
<div className="absolute bottom-[-10%] right-[-10%] w-[50%] h-[50%] bg-blue-500/5 dark:bg-blue-900/10 rounded-full blur-[120px]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-10 p-8 max-w-7xl mx-auto space-y-8">
|
<div className="relative z-10 p-4 sm:p-8 max-w-7xl mx-auto space-y-8">
|
||||||
|
|
||||||
{/* Header Section */}
|
{/* Header Section */}
|
||||||
<header className="flex flex-col md:flex-row justify-between items-start md:items-center border-b border-zinc-200 dark:border-white/5 pb-6">
|
<header className="flex flex-col md:flex-row justify-between items-start md:items-center border-b border-zinc-200 dark:border-white/5 pb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 tracking-tight">
|
<h1 className="text-3xl sm:text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-r from-zinc-900 to-zinc-600 dark:from-white dark:to-white/60 tracking-tight">
|
||||||
{greeting}, {user?.name?.split(' ')[0] || 'Owner'}
|
{greeting}, {user?.name?.split(' ')[0] || 'Owner'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-zinc-500 dark:text-zinc-400 mt-2 font-light">Here's your business snapshot for today.</p>
|
<p className="text-zinc-500 dark:text-zinc-400 mt-2 font-light">
|
||||||
|
{owner ? `${owner.companyName} — ${owner.portfolioSize} projects in portfolio` : "Here's your business snapshot for today."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 md:mt-0 flex space-x-3">
|
<div className="mt-4 md:mt-0 flex flex-wrap gap-3">
|
||||||
<button className="px-4 py-2 bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 rounded-xl text-sm font-bold text-zinc-700 dark:text-zinc-300 transition-colors border border-zinc-200 dark:border-white/5 shadow-sm">
|
<button className="px-4 py-2 bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 rounded-xl text-sm font-bold text-zinc-700 dark:text-zinc-300 transition-colors border border-zinc-200 dark:border-white/5 shadow-sm">
|
||||||
Download Report
|
Download Report
|
||||||
</button>
|
</button>
|
||||||
<button className="px-4 py-2 bg-amber-500 hover:bg-amber-600 text-black font-bold rounded-xl text-sm transition-colors shadow-lg shadow-amber-500/20">
|
<button
|
||||||
Quick Action
|
onClick={() => navigate('/owner/projects')}
|
||||||
|
className="px-4 py-2 bg-amber-500 hover:bg-amber-600 text-black font-bold rounded-xl text-sm transition-colors shadow-lg shadow-amber-500/20 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
View Projects <ArrowUpRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{/* Quick Stats Row */}
|
||||||
|
<section className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: 'Total Projects', value: projectStats.total, icon: Briefcase, color: 'text-blue-500', onClick: () => navigate('/owner/projects') },
|
||||||
|
{ label: 'Active', value: projectStats.active, icon: TrendingUp, color: 'text-emerald-500', onClick: () => navigate('/owner/projects') },
|
||||||
|
{ label: 'Avg Health', value: `${projectStats.avgHealth}%`, icon: Shield, color: getHealthColor(projectStats.avgHealth), onClick: () => navigate('/owner/projects') },
|
||||||
|
{ label: 'Risk Score', value: owner?.riskScore ?? '—', icon: AlertTriangle, color: owner ? getRiskColor(owner.riskScore).split(' ')[0] : 'text-zinc-500', onClick: () => handleActionClick('all') },
|
||||||
|
].map((stat, i) => (
|
||||||
|
<SpotlightCard key={i} className="p-4 cursor-pointer" onClick={stat.onClick}>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-xl bg-zinc-100 dark:bg-zinc-800 ${stat.color}`}>
|
||||||
|
<stat.icon size={18} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-extrabold text-zinc-900 dark:text-white">{stat.value}</div>
|
||||||
|
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500">{stat.label}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Financial KPIs */}
|
{/* Financial KPIs */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className="text-xl font-bold text-zinc-900 dark:text-white/90 mb-4 flex items-center">
|
<h2 className="text-xl font-bold text-zinc-900 dark:text-white/90 mb-4 flex items-center">
|
||||||
<span className="w-2 h-8 bg-emerald-500 rounded-full mr-3"></span>
|
<span className="w-2 h-8 bg-emerald-500 rounded-full mr-3"></span>
|
||||||
Financial Overview
|
Financial Overview
|
||||||
</h2>
|
</h2>
|
||||||
<FinancialKPICards onCardClick={handleFinancialClick} />
|
<FinancialKPICards onCardClick={handleFinancialClick} ownerId={user?.id} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Charts Row */}
|
||||||
|
<section className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Budget vs Actual */}
|
||||||
|
<SpotlightCard className="lg:col-span-2 p-6">
|
||||||
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white mb-1">Budget vs Actual Spend</h3>
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-4">Per project (in $k)</p>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={budgetChartData} barGap={4}>
|
||||||
|
<XAxis dataKey="name" tick={{ fontSize: 11, fill: '#a1a1aa' }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: '#a1a1aa' }} axisLine={false} tickLine={false} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Bar dataKey="budget" name="Budget" fill="#3b82f6" radius={[6, 6, 0, 0]} />
|
||||||
|
<Bar dataKey="spent" name="Spent" fill="#f59e0b" radius={[6, 6, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
|
||||||
|
{/* Project Status Distribution */}
|
||||||
|
<SpotlightCard className="p-6">
|
||||||
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white mb-1">Project Status</h3>
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-4">Distribution</p>
|
||||||
|
<div className="h-64 flex items-center justify-center">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={statusChartData}
|
||||||
|
cx="50%" cy="50%"
|
||||||
|
innerRadius={50} outerRadius={80}
|
||||||
|
paddingAngle={4}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{statusChartData.map((entry, i) => (
|
||||||
|
<Cell key={i} fill={entry.color} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip />
|
||||||
|
<Legend
|
||||||
|
formatter={(value) => <span className="text-xs text-zinc-600 dark:text-zinc-400">{value}</span>}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Monthly Spend Trend */}
|
||||||
|
{monthlySpendData.length > 0 && (
|
||||||
|
<SpotlightCard className="p-6">
|
||||||
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white mb-1">Monthly Spend Trend</h3>
|
||||||
|
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-4">Last 12 months (in $k)</p>
|
||||||
|
<div className="h-56">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={monthlySpendData}>
|
||||||
|
<XAxis dataKey="month" tick={{ fontSize: 11, fill: '#a1a1aa' }} axisLine={false} tickLine={false} />
|
||||||
|
<YAxis tick={{ fontSize: 11, fill: '#a1a1aa' }} axisLine={false} tickLine={false} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Bar dataKey="amount" name="Spend" fill="#8b5cf6" radius={[6, 6, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main Content Grid */}
|
{/* Main Content Grid */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||||
|
|
||||||
{/* Urgent Items Panel (Takes up 2 columns on large screens) */}
|
{/* Urgent Items Panel */}
|
||||||
<section className="lg:col-span-2">
|
<section className="lg:col-span-2">
|
||||||
<UrgentItemsPanel onActionClick={handleActionClick} />
|
<UrgentItemsPanel onActionClick={handleActionClick} ownerId={user?.id} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Activity Feed / Notifications Placeholder (Right Column) */}
|
{/* Activity Feed */}
|
||||||
<SpotlightCard className="h-full">
|
<SpotlightCard className="h-full">
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="flex items-center mb-6">
|
<div className="flex items-center mb-6">
|
||||||
@@ -89,28 +272,47 @@ const OwnerSnapshot = () => {
|
|||||||
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Recent Activity</h3>
|
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Recent Activity</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[1, 2, 3].map((i) => (
|
{activityFeed.length > 0 ? activityFeed.map((item, i) => (
|
||||||
<div key={i} className="flex items-start space-x-3 p-3 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors">
|
<div key={i} className="flex items-start space-x-3 p-3 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors">
|
||||||
<div className="w-2 h-2 mt-2 rounded-full bg-blue-400 shrink-0"></div>
|
<div className={`w-2 h-2 mt-2 rounded-full shrink-0 ${
|
||||||
|
item.action?.toLowerCase().includes('payment') ? 'bg-emerald-400' :
|
||||||
|
item.action?.toLowerCase().includes('issue') || item.action?.toLowerCase().includes('dispute') ? 'bg-red-400' :
|
||||||
|
'bg-blue-400'
|
||||||
|
}`}></div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-zinc-600 dark:text-zinc-300">New invoice submitted by <span className="text-zinc-900 dark:text-white font-bold">Texas Builders LLC</span></p>
|
<p className="text-sm text-zinc-600 dark:text-zinc-300">
|
||||||
<span className="text-[10px] text-zinc-400 uppercase font-bold tracking-wider">2 hours ago</span>
|
<span className="text-zinc-900 dark:text-white font-bold">{item.action}</span>
|
||||||
|
{item.details && <span className="text-zinc-500"> — {item.details}</span>}
|
||||||
|
</p>
|
||||||
|
<span className="text-[10px] text-zinc-400 uppercase font-bold tracking-wider">
|
||||||
|
{item.date} · {item.projectType}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)) : (
|
||||||
<div className="flex items-start space-x-3 p-3 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5 hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors">
|
// Fallback when no activityTimeline data
|
||||||
<div className="w-2 h-2 mt-2 rounded-full bg-emerald-400 shrink-0"></div>
|
<>
|
||||||
<div>
|
{[
|
||||||
<p className="text-sm text-zinc-600 dark:text-zinc-300">Payment received for <span className="text-zinc-900 dark:text-white font-bold">Project P-2602</span></p>
|
{ msg: `${projectStats.active} active projects in progress`, time: 'Current', color: 'bg-blue-400' },
|
||||||
<span className="text-[10px] text-zinc-400 uppercase font-bold tracking-wider">5 hours ago</span>
|
{ msg: `${formatCurrency(projectStats.totalSpent)} spent of ${formatCurrency(projectStats.totalBudget)} total budget`, time: 'Overview', color: 'bg-emerald-400' },
|
||||||
</div>
|
{ msg: `${projectStats.completed} projects completed successfully`, time: 'All time', color: 'bg-purple-400' },
|
||||||
</div>
|
].map((item, i) => (
|
||||||
|
<div key={i} className="flex items-start space-x-3 p-3 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5">
|
||||||
|
<div className={`w-2 h-2 mt-2 rounded-full ${item.color} shrink-0`}></div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-zinc-600 dark:text-zinc-300">{item.msg}</p>
|
||||||
|
<span className="text-[10px] text-zinc-400 uppercase font-bold tracking-wider">{item.time}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/owner/vendors')}
|
onClick={() => navigate('/owner/projects')}
|
||||||
className="w-full mt-6 py-2 text-xs font-bold uppercase tracking-wider text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white border border-zinc-200 dark:border-white/10 hover:bg-zinc-100 dark:hover:bg-white/5 rounded-xl transition-all"
|
className="w-full mt-6 py-2 text-xs font-bold uppercase tracking-wider text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white border border-zinc-200 dark:border-white/10 hover:bg-zinc-100 dark:hover:bg-white/5 rounded-xl transition-all flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
View All Activity
|
View All Projects <ChevronRight size={14} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
@@ -123,15 +325,16 @@ const OwnerSnapshot = () => {
|
|||||||
isOpen={financialModal.isOpen}
|
isOpen={financialModal.isOpen}
|
||||||
onClose={() => setFinancialModal({ ...financialModal, isOpen: false })}
|
onClose={() => setFinancialModal({ ...financialModal, isOpen: false })}
|
||||||
type={financialModal.type}
|
type={financialModal.type}
|
||||||
|
ownerId={user?.id}
|
||||||
/>
|
/>
|
||||||
<ActionCenterModal
|
<ActionCenterModal
|
||||||
isOpen={actionModal.isOpen}
|
isOpen={actionModal.isOpen}
|
||||||
onClose={() => setActionModal({ ...actionModal, isOpen: false })}
|
onClose={() => setActionModal({ ...actionModal, isOpen: false })}
|
||||||
defaultFilter={actionModal.filter}
|
defaultFilter={actionModal.filter}
|
||||||
// Force re-render on filter change if needed, generally okay as props update
|
|
||||||
key={actionModal.filter}
|
key={actionModal.filter}
|
||||||
/>
|
/>
|
||||||
</div >
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import { useMockStore } from '../../data/mockStore';
|
import { useMockStore } from '../../data/mockStore';
|
||||||
import { Search, Filter, Shield, User, Briefcase, Zap } from 'lucide-react';
|
import { Search, Filter, Shield, User, Briefcase, Zap, ArrowLeft } from 'lucide-react';
|
||||||
import MaskedData from '../../components/MaskedData';
|
import MaskedData from '../../components/MaskedData';
|
||||||
import ComplianceChecklist from '../../components/people/ComplianceChecklist';
|
import ComplianceChecklist from '../../components/people/ComplianceChecklist';
|
||||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
@@ -10,6 +10,11 @@ const PeopleDirectory = () => {
|
|||||||
const [filter, setFilter] = useState('all'); // all, employee, contractor, subcontractor
|
const [filter, setFilter] = useState('all'); // all, employee, contractor, subcontractor
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedPerson, setSelectedPerson] = useState(null);
|
const [selectedPerson, setSelectedPerson] = useState(null);
|
||||||
|
const detailRef = useRef(null);
|
||||||
|
|
||||||
|
const handleSelectPerson = (person) => {
|
||||||
|
setSelectedPerson(person);
|
||||||
|
};
|
||||||
|
|
||||||
// Combine and normalize data for display
|
// Combine and normalize data for display
|
||||||
const allPeople = [
|
const allPeople = [
|
||||||
@@ -30,7 +35,7 @@ const PeopleDirectory = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 overflow-hidden relative">
|
<div className="h-screen flex flex-col bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 overflow-hidden lg:overflow-hidden overflow-y-auto relative">
|
||||||
{/* Ambient Background Glows */}
|
{/* Ambient Background Glows */}
|
||||||
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||||
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-purple-500/5 dark:bg-purple-900/10 rounded-full blur-[120px]" />
|
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-purple-500/5 dark:bg-purple-900/10 rounded-full blur-[120px]" />
|
||||||
@@ -45,7 +50,7 @@ const PeopleDirectory = () => {
|
|||||||
|
|
||||||
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0">
|
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0">
|
||||||
{/* Left Sidebar: List & Search */}
|
{/* Left Sidebar: List & Search */}
|
||||||
<SpotlightCard className="w-full lg:w-1/3 flex flex-col overflow-hidden">
|
<SpotlightCard className={`w-full lg:w-1/3 flex flex-col overflow-hidden ${selectedPerson ? 'hidden lg:flex' : 'flex'}`}>
|
||||||
<div className="p-4 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5 space-y-4">
|
<div className="p-4 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5 space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
|
||||||
@@ -93,7 +98,7 @@ const PeopleDirectory = () => {
|
|||||||
{filteredPeople.map((person) => (
|
{filteredPeople.map((person) => (
|
||||||
<div
|
<div
|
||||||
key={person.id}
|
key={person.id}
|
||||||
onClick={() => setSelectedPerson(person)}
|
onClick={() => handleSelectPerson(person)}
|
||||||
className={`p-3 rounded-xl border transition-all cursor-pointer group ${selectedPerson?.id === person.id
|
className={`p-3 rounded-xl border transition-all cursor-pointer group ${selectedPerson?.id === person.id
|
||||||
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/20 shadow-sm'
|
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/20 shadow-sm'
|
||||||
: 'bg-transparent border-transparent hover:bg-zinc-100 dark:hover:bg-white/5'
|
: 'bg-transparent border-transparent hover:bg-zinc-100 dark:hover:bg-white/5'
|
||||||
@@ -119,15 +124,23 @@ const PeopleDirectory = () => {
|
|||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
|
|
||||||
{/* Right Panel: Details */}
|
{/* Right Panel: Details */}
|
||||||
<div className="w-full lg:w-2/3 flex flex-col min-h-0">
|
<div className={`w-full lg:w-2/3 flex flex-col min-h-0 ${!selectedPerson ? 'hidden lg:flex' : 'flex'}`} ref={detailRef}>
|
||||||
{selectedPerson ? (
|
{selectedPerson ? (
|
||||||
<SpotlightCard className="h-full flex flex-col overflow-hidden">
|
<SpotlightCard className="h-full flex flex-col overflow-hidden">
|
||||||
|
{/* Mobile Back Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedPerson(null)}
|
||||||
|
className="lg:hidden flex items-center gap-2 px-4 py-3 text-sm font-bold text-blue-600 dark:text-blue-400 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Back to People
|
||||||
|
</button>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-8 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5">
|
<div className="p-4 sm:p-8 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center space-x-3 mb-2">
|
<div className="flex items-center space-x-3 mb-2">
|
||||||
<h2 className="text-3xl font-bold text-zinc-900 dark:text-white">{selectedPerson.name}</h2>
|
<h2 className="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-white">{selectedPerson.name}</h2>
|
||||||
<span className={`px-2.5 py-0.5 rounded-md text-[10px] font-bold border uppercase tracking-wider ${selectedPerson.status === 'active'
|
<span className={`px-2.5 py-0.5 rounded-md text-[10px] font-bold border uppercase tracking-wider ${selectedPerson.status === 'active'
|
||||||
? 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20'
|
? 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20'
|
||||||
: 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20'
|
: 'bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20'
|
||||||
@@ -146,10 +159,10 @@ const PeopleDirectory = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-8">
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 sm:p-8">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 sm:gap-8">
|
||||||
{/* Sensitive Data Section */}
|
{/* Sensitive Data Section */}
|
||||||
<div className="space-y-8">
|
<div className="space-y-6 sm:space-y-8">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider border-b border-zinc-200 dark:border-white/10 pb-2 flex items-center gap-2">
|
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider border-b border-zinc-200 dark:border-white/10 pb-2 flex items-center gap-2">
|
||||||
<Shield size={16} className="text-blue-500" />
|
<Shield size={16} className="text-blue-500" />
|
||||||
@@ -227,6 +240,7 @@ const PeopleDirectory = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { useMockStore } from '../../data/mockStore';
|
import { useMockStore } from '../../data/mockStore';
|
||||||
import { Search, Filter, Briefcase, DollarSign, FileText, AlertTriangle, CheckCircle } from 'lucide-react';
|
import { Search, Filter, Briefcase, DollarSign, FileText, AlertTriangle, CheckCircle, ArrowLeft } from 'lucide-react';
|
||||||
import MaskedData from '../../components/MaskedData';
|
import MaskedData from '../../components/MaskedData';
|
||||||
import ComplianceChecklist from '../../components/people/ComplianceChecklist';
|
import ComplianceChecklist from '../../components/people/ComplianceChecklist';
|
||||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
@@ -10,6 +10,12 @@ const VendorDirectory = () => {
|
|||||||
const [filterType, setFilterType] = useState('all'); // all, roofing, electrical, plumbing, hvac
|
const [filterType, setFilterType] = useState('all'); // all, roofing, electrical, plumbing, hvac
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedVendor, setSelectedVendor] = useState(null);
|
const [selectedVendor, setSelectedVendor] = useState(null);
|
||||||
|
const detailRef = useRef(null);
|
||||||
|
|
||||||
|
// On mobile, scroll to detail when vendor selected
|
||||||
|
const handleSelectVendor = (vendor) => {
|
||||||
|
setSelectedVendor(vendor);
|
||||||
|
};
|
||||||
|
|
||||||
const filteredVendors = vendors.filter(vendor => {
|
const filteredVendors = vendors.filter(vendor => {
|
||||||
const matchesSearch = vendor.vendorName.toLowerCase().includes(search.toLowerCase()) ||
|
const matchesSearch = vendor.vendorName.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
@@ -24,7 +30,7 @@ const VendorDirectory = () => {
|
|||||||
const totalSpend = filteredVendors.reduce((acc, v) => acc + (v.spend?.totalSpend || 0), 0);
|
const totalSpend = filteredVendors.reduce((acc, v) => acc + (v.spend?.totalSpend || 0), 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 overflow-hidden relative">
|
<div className="h-screen flex flex-col bg-zinc-50 dark:bg-[#09090b] text-zinc-900 dark:text-white transition-colors duration-300 overflow-hidden lg:overflow-hidden overflow-y-auto relative">
|
||||||
{/* Ambient Background Glows */}
|
{/* Ambient Background Glows */}
|
||||||
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
<div className="fixed top-0 left-0 w-full h-full overflow-hidden pointer-events-none z-0">
|
||||||
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-purple-500/5 dark:bg-purple-900/10 rounded-full blur-[120px]" />
|
<div className="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-purple-500/5 dark:bg-purple-900/10 rounded-full blur-[120px]" />
|
||||||
@@ -46,7 +52,7 @@ const VendorDirectory = () => {
|
|||||||
|
|
||||||
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0">
|
<div className="flex-1 flex flex-col lg:flex-row gap-6 min-h-0">
|
||||||
{/* Left Sidebar: Vendor List */}
|
{/* Left Sidebar: Vendor List */}
|
||||||
<SpotlightCard className="w-full lg:w-1/3 flex flex-col overflow-hidden">
|
<SpotlightCard className={`w-full lg:w-1/3 flex flex-col overflow-hidden ${selectedVendor ? 'hidden lg:flex' : 'flex'}`}>
|
||||||
<div className="p-4 border-b border-zinc-200 dark:border-white/5 space-y-4 bg-zinc-50/50 dark:bg-white/5">
|
<div className="p-4 border-b border-zinc-200 dark:border-white/5 space-y-4 bg-zinc-50/50 dark:bg-white/5">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-400" size={16} />
|
||||||
@@ -79,7 +85,7 @@ const VendorDirectory = () => {
|
|||||||
{filteredVendors.map((vendor) => (
|
{filteredVendors.map((vendor) => (
|
||||||
<div
|
<div
|
||||||
key={vendor.id}
|
key={vendor.id}
|
||||||
onClick={() => setSelectedVendor(vendor)}
|
onClick={() => handleSelectVendor(vendor)}
|
||||||
className={`p-3 rounded-xl border transition-all cursor-pointer group ${selectedVendor?.id === vendor.id
|
className={`p-3 rounded-xl border transition-all cursor-pointer group ${selectedVendor?.id === vendor.id
|
||||||
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/20 shadow-sm'
|
? 'bg-blue-50 dark:bg-blue-500/10 border-blue-200 dark:border-blue-500/20 shadow-sm'
|
||||||
: 'bg-transparent border-transparent hover:bg-zinc-100 dark:hover:bg-white/5'
|
: 'bg-transparent border-transparent hover:bg-zinc-100 dark:hover:bg-white/5'
|
||||||
@@ -109,15 +115,23 @@ const VendorDirectory = () => {
|
|||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
|
|
||||||
{/* Right Panel: Vendor Details */}
|
{/* Right Panel: Vendor Details */}
|
||||||
<div className="w-full lg:w-2/3 flex flex-col min-h-0">
|
<div className={`w-full lg:w-2/3 flex flex-col min-h-0 ${!selectedVendor ? 'hidden lg:flex' : 'flex'}`} ref={detailRef}>
|
||||||
{selectedVendor ? (
|
{selectedVendor ? (
|
||||||
<SpotlightCard className="h-full flex flex-col overflow-hidden">
|
<SpotlightCard className="h-full flex flex-col overflow-hidden">
|
||||||
|
{/* Mobile Back Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedVendor(null)}
|
||||||
|
className="lg:hidden flex items-center gap-2 px-4 py-3 text-sm font-bold text-blue-600 dark:text-blue-400 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Back to Vendors
|
||||||
|
</button>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-8 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5">
|
<div className="p-4 sm:p-8 border-b border-zinc-200 dark:border-white/5 bg-zinc-50/50 dark:bg-white/5">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center space-x-3 mb-2">
|
<div className="flex items-center space-x-3 mb-2">
|
||||||
<h2 className="text-3xl font-bold text-zinc-900 dark:text-white">{selectedVendor.vendorName}</h2>
|
<h2 className="text-2xl sm:text-3xl font-bold text-zinc-900 dark:text-white">{selectedVendor.vendorName}</h2>
|
||||||
<span className={`px-2.5 py-0.5 rounded-md text-[10px] font-bold border uppercase tracking-wider ${selectedVendor.status === 'active'
|
<span className={`px-2.5 py-0.5 rounded-md text-[10px] font-bold border uppercase tracking-wider ${selectedVendor.status === 'active'
|
||||||
? 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20'
|
? 'bg-emerald-100 text-emerald-700 border-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-400 dark:border-emerald-500/20'
|
||||||
: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-500/10 dark:text-red-400 dark:border-red-500/20'
|
: 'bg-red-100 text-red-700 border-red-200 dark:bg-red-500/10 dark:text-red-400 dark:border-red-500/20'
|
||||||
@@ -136,10 +150,10 @@ const VendorDirectory = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-8">
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-4 sm:p-8">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 sm:gap-8">
|
||||||
{/* Financials & Compliance */}
|
{/* Financials & Compliance */}
|
||||||
<div className="space-y-8">
|
<div className="space-y-6 sm:space-y-8">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider border-b border-zinc-200 dark:border-white/10 pb-2 flex items-center gap-2">
|
<h3 className="text-sm font-bold text-zinc-900 dark:text-white uppercase tracking-wider border-b border-zinc-200 dark:border-white/10 pb-2 flex items-center gap-2">
|
||||||
<DollarSign size={16} className="text-emerald-500" />
|
<DollarSign size={16} className="text-emerald-500" />
|
||||||
@@ -211,6 +225,7 @@ const VendorDirectory = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,47 +1,235 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { useMockStore } from '../../data/mockStore';
|
import { useMockStore } from '../../data/mockStore';
|
||||||
import { StatCard } from '../../components/StatCard';
|
import { StatCard } from '../../components/StatCard';
|
||||||
import { SpotlightCard } from '../../components/SpotlightCard';
|
import { SpotlightCard } from '../../components/SpotlightCard';
|
||||||
import { CheckSquare, Clock, Wallet, MapPin, Camera } from 'lucide-react';
|
import { CheckSquare, Clock, Wallet, MapPin, Camera, X, Calendar, DollarSign, ChevronRight } from 'lucide-react';
|
||||||
import TaskDetailsModal from '../../components/contractor/TaskDetailsModal';
|
import TaskDetailsModal from '../../components/contractor/TaskDetailsModal';
|
||||||
import FinancialSummaryModal from '../../components/contractor/FinancialSummaryModal';
|
import FinancialSummaryModal from '../../components/contractor/FinancialSummaryModal';
|
||||||
|
|
||||||
|
// --- Reusable list modal for filtered tasks / clock-in / payment history ---
|
||||||
|
const SubDetailModal = ({ isOpen, onClose, title, type, tasks, invoices, clockIns, onTaskClick }) => {
|
||||||
|
const [selectedItem, setSelectedItem] = useState(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handleEsc = (e) => { if (e.key === 'Escape') { if (selectedItem) setSelectedItem(null); else onClose(); } };
|
||||||
|
if (isOpen) window.addEventListener('keydown', handleEsc);
|
||||||
|
return () => window.removeEventListener('keydown', handleEsc);
|
||||||
|
}, [isOpen, onClose, selectedItem]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const formatCurrency = (amt) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amt);
|
||||||
|
|
||||||
|
const getStatusColor = (s) => {
|
||||||
|
if (s === 'completed' || s === 'paid') return 'text-emerald-600 bg-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10';
|
||||||
|
if (s === 'in_progress') return 'text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/10';
|
||||||
|
if (s === 'pending') return 'text-amber-600 bg-amber-100 dark:text-amber-400 dark:bg-amber-500/10';
|
||||||
|
return 'text-zinc-500 bg-zinc-100 dark:text-zinc-400 dark:bg-white/5';
|
||||||
|
};
|
||||||
|
|
||||||
|
// If a specific task is selected, show detail view
|
||||||
|
if (selectedItem && (type === 'tasks_in_progress' || type === 'pending_tasks' || type === 'jobs_completed')) {
|
||||||
|
const task = selectedItem;
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" role="dialog" aria-modal="true">
|
||||||
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setSelectedItem(null)} />
|
||||||
|
<div className="relative w-full max-w-lg bg-white dark:bg-[#121214] rounded-2xl shadow-2xl border border-zinc-200 dark:border-white/10 overflow-hidden animate-in zoom-in-95 duration-200">
|
||||||
|
<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">
|
||||||
|
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">Task Detail</h2>
|
||||||
|
<button onClick={() => setSelectedItem(null)} className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:text-red-500 transition-colors"><X size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">{task.name}</h3>
|
||||||
|
<span className={`mt-2 inline-block px-3 py-1 rounded-full text-xs font-bold uppercase ${getStatusColor(task.status)}`}>{task.status.replace('_', ' ')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
{[['Project', task.projectAddress], ['Due Date', task.dueDate], ['Assigned To', task.assignedTo], ['Project Status', task.projectStatus]].map(([l, v], 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">{l}</div>
|
||||||
|
<div className="font-semibold text-zinc-900 dark:text-white">{v}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 flex justify-end">
|
||||||
|
<button onClick={() => setSelectedItem(null)} 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If a specific invoice is selected in payment history
|
||||||
|
if (selectedItem && type === 'payment_history') {
|
||||||
|
const inv = selectedItem;
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4" role="dialog" aria-modal="true">
|
||||||
|
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={() => setSelectedItem(null)} />
|
||||||
|
<div className="relative w-full max-w-lg bg-white dark:bg-[#121214] rounded-2xl shadow-2xl border border-zinc-200 dark:border-white/10 overflow-hidden animate-in zoom-in-95 duration-200">
|
||||||
|
<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">
|
||||||
|
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">Payment Detail</h2>
|
||||||
|
<button onClick={() => setSelectedItem(null)} className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:text-red-500 transition-colors"><X size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="text-3xl font-extrabold text-zinc-900 dark:text-white">{formatCurrency(inv.amount)}</div>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-bold uppercase ${getStatusColor(inv.status)}`}>{inv.status}</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||||
|
{[['Invoice ID', inv.id], ['Due Date', inv.dueDate], ['Date Paid', inv.datePaid || 'Not yet'], ['Submitted By', inv.submittedBy]].map(([l, v], 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">{l}</div>
|
||||||
|
<div className="font-semibold text-zinc-900 dark:text-white">{v}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 py-4 border-t border-zinc-200 dark:border-white/10 bg-zinc-50 dark:bg-white/5 flex justify-end">
|
||||||
|
<button onClick={() => setSelectedItem(null)} 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main list modal
|
||||||
|
const renderContent = () => {
|
||||||
|
if (type === 'tasks_in_progress' || type === 'pending_tasks' || type === 'jobs_completed') {
|
||||||
|
const items = tasks || [];
|
||||||
|
return items.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((task, i) => (
|
||||||
|
<div key={i} onClick={() => setSelectedItem(task)} className="flex items-center justify-between p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5 hover:border-blue-500/30 transition-all cursor-pointer group">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${getStatusColor(task.status)}`}><CheckSquare size={16} /></div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-zinc-900 dark:text-white group-hover:text-blue-500 transition-colors">{task.name}</h4>
|
||||||
|
<p className="text-xs text-zinc-500 mt-0.5">{task.projectAddress}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right flex items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-mono text-zinc-500">Due: {task.dueDate}</p>
|
||||||
|
<span className={`text-[10px] font-bold uppercase ${getStatusColor(task.status)} px-2 py-0.5 rounded-full`}>{task.status.replace('_', ' ')}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={16} className="text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : <p className="text-center text-zinc-500 py-8">No tasks found.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'clock_in') {
|
||||||
|
const items = clockIns || [];
|
||||||
|
return items.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((entry, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 rounded-lg text-blue-600 bg-blue-100 dark:text-blue-400 dark:bg-blue-500/10"><Clock size={16} /></div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-zinc-900 dark:text-white">{entry.date}</h4>
|
||||||
|
<p className="text-xs text-zinc-500 mt-0.5">{entry.project}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-mono font-bold text-zinc-900 dark:text-white">{entry.clockIn} — {entry.clockOut || 'Active'}</p>
|
||||||
|
<p className="text-xs text-zinc-500">{entry.hours} hrs</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : <p className="text-center text-zinc-500 py-8">No clock-in records.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'payment_history') {
|
||||||
|
const items = invoices || [];
|
||||||
|
return items.length > 0 ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{items.map((inv, i) => (
|
||||||
|
<div key={i} onClick={() => setSelectedItem(inv)} className="flex items-center justify-between p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/5 hover:border-emerald-500/30 transition-all cursor-pointer group">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${getStatusColor(inv.status)}`}><DollarSign size={16} /></div>
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-zinc-900 dark:text-white group-hover:text-emerald-500 transition-colors">Invoice #{inv.id}</h4>
|
||||||
|
<p className="text-xs text-zinc-500 mt-0.5">Due: {inv.dueDate}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right flex items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-mono font-bold text-zinc-900 dark:text-white">{formatCurrency(inv.amount)}</p>
|
||||||
|
<span className={`text-[10px] font-bold uppercase ${getStatusColor(inv.status)} px-2 py-0.5 rounded-full`}>{inv.status}</span>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={16} className="text-zinc-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : <p className="text-center text-zinc-500 py-8">No payment records.</p>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 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-2xl h-[85dvh] sm:h-auto sm:max-h-[80vh] bg-white dark:bg-[#121214] rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-in slide-in-from-bottom-10 duration-200 border border-zinc-200 dark:border-white/10">
|
||||||
|
<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">
|
||||||
|
<h2 className="text-xl font-bold text-zinc-900 dark:text-white">{title}</h2>
|
||||||
|
<button onClick={onClose} className="p-2 rounded-lg bg-zinc-100 dark:bg-white/10 text-zinc-500 hover:text-red-500 transition-colors"><X size={18} /></button>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||||
|
{renderContent()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const SubContractorDashboard = () => {
|
const SubContractorDashboard = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { projects } = useMockStore();
|
const { projects } = useMockStore();
|
||||||
|
|
||||||
// 1. Identify current subcontractor (e.g. 'sub_001')
|
|
||||||
const subId = user?.id || 'sub_001';
|
const subId = user?.id || 'sub_001';
|
||||||
|
|
||||||
// 2. Aggregate Tasks (Milestones assigned to me)
|
// Aggregate Tasks
|
||||||
const myTasks = projects.flatMap(p =>
|
const myTasks = projects.flatMap(p =>
|
||||||
p.milestones.filter(m => m.assignedTo === subId)
|
p.milestones.filter(m => m.assignedTo === subId)
|
||||||
.map(m => ({
|
.map(m => ({ ...m, projectAddress: p.address, projectId: p.id, projectStatus: p.status }))
|
||||||
...m,
|
|
||||||
projectAddress: p.address,
|
|
||||||
projectId: p.id,
|
|
||||||
projectStatus: p.status
|
|
||||||
}))
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const activeTasks = myTasks.filter(t => t.status === 'in_progress').length;
|
const inProgressTasks = myTasks.filter(t => t.status === 'in_progress');
|
||||||
const pendingTasks = myTasks.filter(t => t.status === 'pending').length;
|
const pendingTasks = myTasks.filter(t => t.status === 'pending');
|
||||||
const completedTasks = myTasks.filter(t => t.status === 'completed').length;
|
const completedTasks = myTasks.filter(t => t.status === 'completed');
|
||||||
|
|
||||||
// 3. Financials (Invoices submitted by me)
|
// Financials
|
||||||
const myInvoices = projects.flatMap(p =>
|
const myInvoices = projects.flatMap(p =>
|
||||||
p.invoices.filter(i => i.submittedBy === subId)
|
(p.invoices || []).filter(i => i.submittedBy === subId)
|
||||||
);
|
);
|
||||||
const pendingPay = myInvoices.filter(i => i.status === 'pending').reduce((sum, i) => sum + i.amount, 0);
|
const pendingPay = myInvoices.filter(i => i.status === 'pending').reduce((sum, i) => sum + i.amount, 0);
|
||||||
const totalEarnings = myInvoices.filter(i => i.status === 'paid').reduce((sum, i) => sum + i.amount, 0);
|
const totalEarnings = myInvoices.filter(i => i.status === 'paid').reduce((sum, i) => sum + i.amount, 0);
|
||||||
|
|
||||||
// Modal state
|
// Mock clock-in data
|
||||||
|
const clockInData = useMemo(() => [
|
||||||
|
{ date: '2026-02-18', project: '2604 Dunwick Dr — Roof Replacement', clockIn: '7:00 AM', clockOut: null, hours: 'Active' },
|
||||||
|
{ date: '2026-02-17', project: '6613 Phoenix Pl — HVAC Upgrade', clockIn: '7:15 AM', clockOut: '4:30 PM', hours: '9.25' },
|
||||||
|
{ date: '2026-02-16', project: '2604 Dunwick Dr — Roof Replacement', clockIn: '6:45 AM', clockOut: '3:45 PM', hours: '9.0' },
|
||||||
|
{ date: '2026-02-15', project: '3913 Arizona Pl — Electrical Panel', clockIn: '7:00 AM', clockOut: '4:00 PM', hours: '9.0' },
|
||||||
|
{ date: '2026-02-14', project: '2612 Dunwick Dr — Interior Renovation', clockIn: '7:30 AM', clockOut: '5:00 PM', hours: '9.5' },
|
||||||
|
], []);
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
const [detailModal, setDetailModal] = useState({ isOpen: false, type: null, title: '' });
|
||||||
const [selectedTask, setSelectedTask] = useState(null);
|
const [selectedTask, setSelectedTask] = useState(null);
|
||||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||||
const [isFinancialModalOpen, setIsFinancialModalOpen] = useState(false);
|
const [isFinancialModalOpen, setIsFinancialModalOpen] = useState(false);
|
||||||
|
|
||||||
// Financial data for modal
|
|
||||||
const financialData = {
|
const financialData = {
|
||||||
total: totalEarnings + pendingPay,
|
total: totalEarnings + pendingPay,
|
||||||
paid: totalEarnings,
|
paid: totalEarnings,
|
||||||
@@ -64,7 +252,10 @@ const SubContractorDashboard = () => {
|
|||||||
|
|
||||||
const handleTaskUpdate = (taskId, actionType) => {
|
const handleTaskUpdate = (taskId, actionType) => {
|
||||||
console.log(`Task ${taskId} updated with action: ${actionType}`);
|
console.log(`Task ${taskId} updated with action: ${actionType}`);
|
||||||
// In a real app, this would update the backend
|
};
|
||||||
|
|
||||||
|
const openDetailModal = (type, title) => {
|
||||||
|
setDetailModal({ isOpen: true, type, title });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -73,17 +264,20 @@ const SubContractorDashboard = () => {
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">Field Task Center</h1>
|
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">Field Task Center</h1>
|
||||||
<p className="text-zinc-500 dark:text-zinc-400 mt-1">
|
<p className="text-zinc-500 dark:text-zinc-400 mt-1">
|
||||||
You have <span className="text-blue-500 font-bold">{activeTasks} active tasks</span> and <span className="text-emerald-500 font-bold">${pendingPay.toLocaleString()}</span> in pending payouts.
|
You have <span className="text-blue-500 font-bold">{inProgressTasks.length} active tasks</span> and <span className="text-emerald-500 font-bold">${pendingPay.toLocaleString()}</span> in pending payouts.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions (Mobile First) */}
|
{/* Quick Actions */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<button className="flex flex-col items-center justify-center p-4 rounded-xl bg-blue-600 text-white shadow-lg shadow-blue-500/30 hover:bg-blue-500 transition-all active:scale-95">
|
<button className="flex flex-col items-center justify-center p-4 rounded-xl bg-blue-600 text-white shadow-lg shadow-blue-500/30 hover:bg-blue-500 transition-all active:scale-95">
|
||||||
<Camera size={24} className="mb-2" />
|
<Camera size={24} className="mb-2" />
|
||||||
<span className="text-sm font-bold">Upload Photo</span>
|
<span className="text-sm font-bold">Upload Photo</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="flex flex-col items-center justify-center p-4 rounded-xl bg-zinc-100 dark:bg-white/10 text-zinc-900 dark:text-white hover:bg-zinc-200 dark:hover:bg-white/20 transition-all active:scale-95">
|
<button
|
||||||
|
onClick={() => openDetailModal('clock_in', 'Clock-In History')}
|
||||||
|
className="flex flex-col items-center justify-center p-4 rounded-xl bg-zinc-100 dark:bg-white/10 text-zinc-900 dark:text-white hover:bg-zinc-200 dark:hover:bg-white/20 transition-all active:scale-95"
|
||||||
|
>
|
||||||
<Clock size={24} className="mb-2" />
|
<Clock size={24} className="mb-2" />
|
||||||
<span className="text-sm font-bold">Clock In</span>
|
<span className="text-sm font-bold">Clock In</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -99,32 +293,18 @@ const SubContractorDashboard = () => {
|
|||||||
|
|
||||||
{/* KPI Cards */}
|
{/* KPI Cards */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<StatCard
|
<div onClick={() => openDetailModal('tasks_in_progress', 'Tasks In Progress')} className="cursor-pointer">
|
||||||
label="Tasks In Progress"
|
<StatCard label="Tasks In Progress" value={inProgressTasks.length} icon={Clock} color="blue" />
|
||||||
value={activeTasks}
|
</div>
|
||||||
icon={Clock}
|
<div onClick={() => openDetailModal('pending_tasks', 'Pending Tasks')} className="cursor-pointer">
|
||||||
color="blue"
|
<StatCard label="Pending Tasks" value={pendingTasks.length} icon={CheckSquare} color="amber" />
|
||||||
/>
|
</div>
|
||||||
<StatCard
|
<div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer">
|
||||||
label="Pending Tasks"
|
<StatCard label="Pending Payouts" value={`$${pendingPay.toLocaleString()}`} icon={Wallet} color="emerald" />
|
||||||
value={pendingTasks}
|
</div>
|
||||||
icon={CheckSquare}
|
<div onClick={() => openDetailModal('jobs_completed', 'Jobs Completed')} className="cursor-pointer">
|
||||||
color="amber"
|
<StatCard label="Jobs Completed" value={completedTasks.length} icon={CheckSquare} color="purple" />
|
||||||
/>
|
|
||||||
<div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer">
|
|
||||||
<StatCard
|
|
||||||
label="Pending Payouts"
|
|
||||||
value={`$${pendingPay.toLocaleString()}`}
|
|
||||||
icon={Wallet}
|
|
||||||
color="emerald"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<StatCard
|
|
||||||
label="Jobs Completed"
|
|
||||||
value={completedTasks}
|
|
||||||
icon={CheckSquare}
|
|
||||||
color="purple"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Task List */}
|
{/* Main Task List */}
|
||||||
@@ -134,13 +314,10 @@ const SubContractorDashboard = () => {
|
|||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">My Assignments</h2>
|
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">My Assignments</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{myTasks.length > 0 ? myTasks.map((task, idx) => (
|
{myTasks.length > 0 ? myTasks.map((task, idx) => (
|
||||||
<div key={idx} className="group p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5 hover:border-blue-500/30 transition-all cursor-pointer" onClick={() => handleTaskClick(task)}>
|
<div key={idx} className="group p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5 hover:border-blue-500/30 transition-all cursor-pointer" onClick={() => handleTaskClick(task)}>
|
||||||
<div className="flex flex-col md:flex-row justify-between md:items-center gap-4">
|
<div className="flex flex-col md:flex-row justify-between md:items-center gap-4">
|
||||||
|
|
||||||
{/* Left: Task Info */}
|
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className={`p-3 rounded-lg ${task.status === 'completed' ? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400' :
|
<div className={`p-3 rounded-lg ${task.status === 'completed' ? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-500/20 dark:text-emerald-400' :
|
||||||
task.status === 'in_progress' ? 'bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400' :
|
task.status === 'in_progress' ? 'bg-blue-100 text-blue-600 dark:bg-blue-500/20 dark:text-blue-400' :
|
||||||
@@ -156,26 +333,18 @@ const SubContractorDashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: Status & Action */}
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-xs font-mono font-medium text-zinc-500 mb-1">Due: {task.dueDate}</p>
|
<p className="text-xs font-mono font-medium text-zinc-500 mb-1">Due: {task.dueDate}</p>
|
||||||
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${task.status === 'completed' ? 'bg-emerald-100 text-emerald-600' :
|
<span className={`px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider ${task.status === 'completed' ? 'bg-emerald-100 text-emerald-600' :
|
||||||
task.status === 'in_progress' ? 'bg-blue-100 text-blue-600' :
|
task.status === 'in_progress' ? 'bg-blue-100 text-blue-600' : 'bg-amber-100 text-amber-600'
|
||||||
'bg-amber-100 text-amber-600'
|
|
||||||
}`}>
|
}`}>
|
||||||
{task.status.replace('_', ' ')}
|
{task.status.replace('_', ' ')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{task.status !== 'completed' && (
|
{task.status !== 'completed' && (
|
||||||
<button
|
<button className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold transition-colors"
|
||||||
className="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold transition-colors"
|
onClick={(e) => { e.stopPropagation(); handleTaskClick(task); }}>
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleTaskClick(task);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Update
|
Update
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -183,15 +352,13 @@ const SubContractorDashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)) : (
|
)) : (
|
||||||
<div className="text-center py-10 text-zinc-500">
|
<div className="text-center py-10 text-zinc-500"><p>No tasks assigned.</p></div>
|
||||||
<p>No tasks assigned.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Earnings Widget */}
|
{/* Earnings Widget */}
|
||||||
<div>
|
<div>
|
||||||
<SpotlightCard className="p-6">
|
<SpotlightCard className="p-6">
|
||||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Earnings Tracker</h2>
|
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Earnings Tracker</h2>
|
||||||
@@ -200,7 +367,6 @@ const SubContractorDashboard = () => {
|
|||||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-1">Total Earned (YTD)</p>
|
<p className="text-sm text-zinc-500 dark:text-zinc-400 mb-1">Total Earned (YTD)</p>
|
||||||
<h3 className="text-4xl font-mono font-bold text-emerald-500">${totalEarnings.toLocaleString()}</h3>
|
<h3 className="text-4xl font-mono font-bold text-emerald-500">${totalEarnings.toLocaleString()}</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-8 space-y-4">
|
<div className="mt-8 space-y-4">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-zinc-500">Paid Invoices</span>
|
<span className="text-zinc-500">Paid Invoices</span>
|
||||||
@@ -211,8 +377,10 @@ const SubContractorDashboard = () => {
|
|||||||
<span className="font-bold text-amber-500">{myInvoices.filter(i => i.status === 'pending').length}</span>
|
<span className="font-bold text-amber-500">{myInvoices.filter(i => i.status === 'pending').length}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
<button className="w-full mt-8 py-3 rounded-xl bg-zinc-100 dark:bg-white/10 hover:bg-zinc-200 dark:hover:bg-white/20 text-zinc-900 dark:text-white font-bold text-sm transition-colors">
|
onClick={() => openDetailModal('payment_history', 'Payment History')}
|
||||||
|
className="w-full mt-8 py-3 rounded-xl bg-zinc-100 dark:bg-white/10 hover:bg-zinc-200 dark:hover:bg-white/20 text-zinc-900 dark:text-white font-bold text-sm transition-colors"
|
||||||
|
>
|
||||||
View Payment History
|
View Payment History
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,18 +389,23 @@ const SubContractorDashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
<TaskDetailsModal
|
<TaskDetailsModal isOpen={isTaskModalOpen} onClose={() => setIsTaskModalOpen(false)} task={selectedTask} onUpdate={handleTaskUpdate} />
|
||||||
isOpen={isTaskModalOpen}
|
<FinancialSummaryModal isOpen={isFinancialModalOpen} onClose={() => setIsFinancialModalOpen(false)} role="SUBCONTRACTOR" data={financialData} />
|
||||||
onClose={() => setIsTaskModalOpen(false)}
|
<SubDetailModal
|
||||||
task={selectedTask}
|
isOpen={detailModal.isOpen}
|
||||||
onUpdate={handleTaskUpdate}
|
onClose={() => setDetailModal({ isOpen: false, type: null, title: '' })}
|
||||||
/>
|
title={detailModal.title}
|
||||||
<FinancialSummaryModal
|
type={detailModal.type}
|
||||||
isOpen={isFinancialModalOpen}
|
tasks={
|
||||||
onClose={() => setIsFinancialModalOpen(false)}
|
detailModal.type === 'tasks_in_progress' ? inProgressTasks :
|
||||||
role="SUBCONTRACTOR"
|
detailModal.type === 'pending_tasks' ? pendingTasks :
|
||||||
data={financialData}
|
detailModal.type === 'jobs_completed' ? completedTasks : []
|
||||||
|
}
|
||||||
|
invoices={myInvoices}
|
||||||
|
clockIns={clockInData}
|
||||||
|
onTaskClick={handleTaskClick}
|
||||||
/>
|
/>
|
||||||
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+96
-73
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAuth } from '../../context/AuthContext';
|
import { useAuth } from '../../context/AuthContext';
|
||||||
import { useMockStore } from '../../data/mockStore';
|
import { useMockStore } from '../../data/mockStore';
|
||||||
@@ -23,40 +23,48 @@ const VendorDashboard = () => {
|
|||||||
const [isPerformanceModalOpen, setIsPerformanceModalOpen] = useState(false);
|
const [isPerformanceModalOpen, setIsPerformanceModalOpen] = useState(false);
|
||||||
|
|
||||||
// 1. Identify current vendor
|
// 1. Identify current vendor
|
||||||
// For mock purposes, if logged in as 'abc_supply', we map to 'v3'
|
|
||||||
// In real app, user.vendorId would track this.
|
|
||||||
const vendorId = user.id === 'ven_001' ? 'v3' : 'v1';
|
const vendorId = user.id === 'ven_001' ? 'v3' : 'v1';
|
||||||
const vendorData = vendors.find(v => v.id === vendorId) || vendors[0];
|
const vendorData = vendors.find(v => v.id === vendorId) || vendors[0];
|
||||||
|
|
||||||
// 2. Aggregate Data
|
// 2. Aggregate Data from ACTUAL vendor invoices (not vendorData.spend which is summary)
|
||||||
// Find projects where this vendor is assigned to milestones or has invoices
|
const vendorInvoicesList = useMemo(() =>
|
||||||
// A. Milestones
|
vendorInvoices.filter(inv => inv.vendorId === vendorId)
|
||||||
|
, [vendorInvoices, vendorId]);
|
||||||
|
|
||||||
|
const pendingInvoiceAmount = useMemo(() =>
|
||||||
|
vendorInvoicesList.filter(inv => inv.status === 'pending').reduce((sum, inv) => sum + inv.amount, 0)
|
||||||
|
, [vendorInvoicesList]);
|
||||||
|
|
||||||
|
const pendingInvoiceCount = useMemo(() =>
|
||||||
|
vendorInvoicesList.filter(inv => inv.status === 'pending').length
|
||||||
|
, [vendorInvoicesList]);
|
||||||
|
|
||||||
|
const paidInvoiceAmount = useMemo(() =>
|
||||||
|
vendorInvoicesList.filter(inv => inv.status === 'paid').reduce((sum, inv) => sum + inv.amount, 0)
|
||||||
|
, [vendorInvoicesList]);
|
||||||
|
|
||||||
|
const totalEarningsYTD = paidInvoiceAmount; // Only count actually paid invoices
|
||||||
|
|
||||||
|
// Orders
|
||||||
|
const vendorOrders = useMemo(() => orders.filter(o => o.vendorId === vendorId), [orders, vendorId]);
|
||||||
|
const activeOrderCount = vendorOrders.filter(o => o.status !== 'delivered').length;
|
||||||
|
|
||||||
|
// Milestones
|
||||||
const activeDeliveries = projects.flatMap(p => p.milestones)
|
const activeDeliveries = projects.flatMap(p => p.milestones)
|
||||||
.filter(m => m.assignedTo === vendorId && m.status === 'pending').length;
|
.filter(m => m.assignedTo === vendorId && m.status === 'pending').length;
|
||||||
|
|
||||||
const completedDeliveries = projects.flatMap(p => p.milestones)
|
// Compliance
|
||||||
.filter(m => m.assignedTo === vendorId && m.status === 'completed').length;
|
|
||||||
|
|
||||||
// B. Financials
|
|
||||||
const pendingInvoices = vendorData.spend?.pendingInvoices || 0;
|
|
||||||
const totalEarnings = vendorData.spend?.totalSpend || 0;
|
|
||||||
|
|
||||||
// C. Compliance
|
|
||||||
const complianceStatus = vendorData.compliance?.coi?.status === 'compliant' ? 'Verified' : 'Action Required';
|
const complianceStatus = vendorData.compliance?.coi?.status === 'compliant' ? 'Verified' : 'Action Required';
|
||||||
|
|
||||||
// D. Financial Data for Modal
|
// Financial Data for Modal — DISTINCT: Pending vs Paid
|
||||||
const vendorOrders = orders.filter(o => o.vendorId === vendorId);
|
|
||||||
const vendorInvoicesList = vendorInvoices.filter(inv => inv.vendorId === vendorId);
|
|
||||||
const paidInvoices = vendorInvoicesList.filter(inv => inv.status === 'paid').reduce((sum, inv) => sum + inv.amount, 0);
|
|
||||||
const pendingPayments = vendorInvoicesList.filter(inv => inv.status === 'pending').reduce((sum, inv) => sum + inv.amount, 0);
|
|
||||||
const financialData = {
|
const financialData = {
|
||||||
totalEarnings: vendorData.spend?.ytdSpend || 0,
|
totalEarnings: totalEarningsYTD,
|
||||||
paidInvoices,
|
paidInvoices: paidInvoiceAmount,
|
||||||
pendingPayments,
|
pendingPayments: pendingInvoiceAmount,
|
||||||
invoices: vendorInvoicesList
|
invoices: vendorInvoicesList
|
||||||
};
|
};
|
||||||
|
|
||||||
// E. Click Handlers
|
// Click Handlers
|
||||||
const handleOrderClick = (order) => {
|
const handleOrderClick = (order) => {
|
||||||
setSelectedOrder(order);
|
setSelectedOrder(order);
|
||||||
setIsOrderModalOpen(true);
|
setIsOrderModalOpen(true);
|
||||||
@@ -92,23 +100,24 @@ const VendorDashboard = () => {
|
|||||||
<div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer">
|
<div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Pending Invoices"
|
label="Pending Invoices"
|
||||||
value={`$${pendingInvoices.toLocaleString()}`}
|
value={`$${pendingInvoiceAmount.toLocaleString()}`}
|
||||||
icon={DollarSign}
|
icon={DollarSign}
|
||||||
color="amber"
|
color="amber"
|
||||||
|
trendLabel={`${pendingInvoiceCount} pending`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div onClick={() => navigate('/vendor/orders')} className="cursor-pointer">
|
||||||
|
<StatCard
|
||||||
|
label="Active Orders"
|
||||||
|
value={activeOrderCount}
|
||||||
|
icon={ClipboardCheck}
|
||||||
|
color="blue"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<StatCard
|
|
||||||
label="Active Orders"
|
|
||||||
value={activeDeliveries}
|
|
||||||
icon={ClipboardCheck}
|
|
||||||
color="blue"
|
|
||||||
onClick={() => navigate('/vendor/orders')}
|
|
||||||
className="cursor-pointer hover:border-blue-500/50 transition-colors"
|
|
||||||
/>
|
|
||||||
<div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer">
|
<div onClick={() => setIsFinancialModalOpen(true)} className="cursor-pointer">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Total Earnings (YTD)"
|
label="Total Earnings (YTD)"
|
||||||
value={`$${vendorData.spend?.ytdSpend?.toLocaleString() || '0'}`}
|
value={`$${totalEarningsYTD.toLocaleString()}`}
|
||||||
icon={TrendingUp}
|
icon={TrendingUp}
|
||||||
color="emerald"
|
color="emerald"
|
||||||
trend={12}
|
trend={12}
|
||||||
@@ -125,13 +134,43 @@ const VendorDashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Earnings Summary Card */}
|
||||||
|
<SpotlightCard className="p-4 sm:p-6">
|
||||||
|
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Earnings Summary</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4">
|
||||||
|
<div className="p-3 sm:p-4 rounded-xl bg-emerald-50 dark:bg-emerald-500/5 border border-emerald-200 dark:border-emerald-500/10">
|
||||||
|
<div className="text-[10px] uppercase font-bold tracking-wider text-emerald-600 dark:text-emerald-400 mb-1">Paid (YTD)</div>
|
||||||
|
<div className="text-lg sm:text-2xl font-bold text-emerald-700 dark:text-emerald-300">${paidInvoiceAmount.toLocaleString()}</div>
|
||||||
|
<div className="text-xs text-emerald-600/70 mt-1">{vendorInvoicesList.filter(i => i.status === 'paid').length} invoices</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 sm: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-1">Pending</div>
|
||||||
|
<div className="text-lg sm:text-2xl font-bold text-amber-700 dark:text-amber-300">${pendingInvoiceAmount.toLocaleString()}</div>
|
||||||
|
<div className="text-xs text-amber-600/70 mt-1">{pendingInvoiceCount} invoices</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 sm:p-4 rounded-xl bg-blue-50 dark:bg-blue-500/5 border border-blue-200 dark:border-blue-500/10">
|
||||||
|
<div className="text-[10px] uppercase font-bold tracking-wider text-blue-600 dark:text-blue-400 mb-1">Active Orders</div>
|
||||||
|
<div className="text-lg sm:text-2xl font-bold text-blue-700 dark:text-blue-300">{activeOrderCount}</div>
|
||||||
|
<div className="text-xs text-blue-600/70 mt-1">{vendorOrders.filter(o => o.status === 'shipped').length} shipped, {vendorOrders.filter(o => o.status === 'pending').length} pending</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 sm:p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-200 dark:border-white/10">
|
||||||
|
<div className="text-[10px] uppercase font-bold tracking-wider text-zinc-500 mb-1">Total Volume</div>
|
||||||
|
<div className="text-lg sm:text-2xl font-bold text-zinc-900 dark:text-white">${(paidInvoiceAmount + pendingInvoiceAmount).toLocaleString()}</div>
|
||||||
|
<div className="text-xs text-zinc-500 mt-1">{vendorInvoicesList.length} total invoices</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SpotlightCard>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
{/* Active Projects / Deliveries */}
|
{/* Active Orders */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="lg:col-span-2 space-y-6">
|
||||||
<SpotlightCard className="p-6 h-full">
|
<SpotlightCard className="p-6 h-full">
|
||||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Active Orders & Deliveries</h2>
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">Active Orders & Deliveries</h2>
|
||||||
|
<button onClick={() => navigate('/vendor/orders')} className="text-sm text-blue-500 hover:text-blue-400 font-medium">View All</button>
|
||||||
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{vendorOrders.map(order => (
|
{vendorOrders.map(order => (
|
||||||
<div
|
<div
|
||||||
@@ -139,22 +178,28 @@ const VendorDashboard = () => {
|
|||||||
onClick={() => handleOrderClick(order)}
|
onClick={() => handleOrderClick(order)}
|
||||||
className="flex items-center justify-between p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5 hover:border-blue-500/30 transition-colors cursor-pointer group">
|
className="flex items-center justify-between p-4 rounded-xl bg-zinc-50 dark:bg-white/5 border border-zinc-100 dark:border-white/5 hover:border-blue-500/30 transition-colors cursor-pointer group">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="p-3 rounded-lg bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400">
|
<div className={`p-3 rounded-lg ${
|
||||||
|
order.status === 'delivered' ? 'bg-emerald-100 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400' :
|
||||||
|
order.status === 'shipped' ? 'bg-blue-100 dark:bg-blue-500/20 text-blue-600 dark:text-blue-400' :
|
||||||
|
order.status === 'confirmed' ? 'bg-purple-100 dark:bg-purple-500/20 text-purple-600 dark:text-purple-400' :
|
||||||
|
'bg-amber-100 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400'
|
||||||
|
}`}>
|
||||||
<FileText size={20} />
|
<FileText size={20} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold text-zinc-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">{order.id}</h4>
|
<h4 className="font-semibold text-zinc-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors">{order.id}</h4>
|
||||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
|
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">{order.projectAddress}</p>
|
||||||
{order.projectAddress}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm font-mono font-medium text-zinc-700 dark:text-zinc-300">
|
<p className="text-sm font-mono font-medium text-zinc-700 dark:text-zinc-300">
|
||||||
Due: {order.dueDate}
|
${order.total.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
<span className={`text-xs font-bold uppercase ${order.status === 'delivered' ? 'text-emerald-500' :
|
<span className={`text-xs font-bold uppercase ${
|
||||||
order.status === 'shipped' ? 'text-blue-500' : 'text-amber-500'
|
order.status === 'delivered' ? 'text-emerald-500' :
|
||||||
|
order.status === 'shipped' ? 'text-blue-500' :
|
||||||
|
order.status === 'confirmed' ? 'text-purple-500' :
|
||||||
|
'text-amber-500'
|
||||||
}`}>
|
}`}>
|
||||||
{order.status}
|
{order.status}
|
||||||
</span>
|
</span>
|
||||||
@@ -176,7 +221,6 @@ const VendorDashboard = () => {
|
|||||||
<SpotlightCard className="p-6">
|
<SpotlightCard className="p-6">
|
||||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Action Center</h2>
|
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Action Center</h2>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Invoices */}
|
|
||||||
<div className="p-4 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-100 dark:border-amber-500/20">
|
<div className="p-4 rounded-xl bg-amber-50 dark:bg-amber-500/10 border border-amber-100 dark:border-amber-500/20">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<AlertCircle size={18} className="text-amber-600 dark:text-amber-400 mt-0.5" />
|
<AlertCircle size={18} className="text-amber-600 dark:text-amber-400 mt-0.5" />
|
||||||
@@ -185,14 +229,10 @@ const VendorDashboard = () => {
|
|||||||
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
|
<p className="text-xs text-amber-700 dark:text-amber-300 mt-1">
|
||||||
Invoice #INV-2024-001 was flagged for missing PO number.
|
Invoice #INV-2024-001 was flagged for missing PO number.
|
||||||
</p>
|
</p>
|
||||||
<button className="mt-2 text-xs font-bold text-amber-600 dark:text-amber-400 hover:underline">
|
<button className="mt-2 text-xs font-bold text-amber-600 dark:text-amber-400 hover:underline">View Details</button>
|
||||||
View Details
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Compliance */}
|
|
||||||
<div className="p-4 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20">
|
<div className="p-4 rounded-xl bg-blue-50 dark:bg-blue-500/10 border border-blue-100 dark:border-blue-500/20">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Calendar size={18} className="text-blue-600 dark:text-blue-400 mt-0.5" />
|
<Calendar size={18} className="text-blue-600 dark:text-blue-400 mt-0.5" />
|
||||||
@@ -201,9 +241,7 @@ const VendorDashboard = () => {
|
|||||||
<p className="text-xs text-blue-700 dark:text-blue-300 mt-1">
|
<p className="text-xs text-blue-700 dark:text-blue-300 mt-1">
|
||||||
Your General Liability policy expires in 45 days.
|
Your General Liability policy expires in 45 days.
|
||||||
</p>
|
</p>
|
||||||
<button className="mt-2 text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline">
|
<button className="mt-2 text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline">Upload Renewal</button>
|
||||||
Upload Renewal
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,7 +253,7 @@ const VendorDashboard = () => {
|
|||||||
<ul className="space-y-3 text-sm">
|
<ul className="space-y-3 text-sm">
|
||||||
<li className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
<li className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
||||||
<span className="text-zinc-500">On-Time Delivery Rate</span>
|
<span className="text-zinc-500">On-Time Delivery Rate</span>
|
||||||
<span className="font-bold text-emerald-500">{vendorData.performance?.onTimeRate * 100}%</span>
|
<span className="font-bold text-emerald-500">{(vendorData.performance?.onTimeRate || 0) * 100}%</span>
|
||||||
</li>
|
</li>
|
||||||
<li className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
<li className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
||||||
<span className="text-zinc-500">Average Payment Window</span>
|
<span className="text-zinc-500">Average Payment Window</span>
|
||||||
@@ -223,7 +261,7 @@ const VendorDashboard = () => {
|
|||||||
</li>
|
</li>
|
||||||
<li className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
<li className="flex justify-between items-center py-2 border-b border-zinc-100 dark:border-white/5">
|
||||||
<span className="text-zinc-500">Active Contracts</span>
|
<span className="text-zinc-500">Active Contracts</span>
|
||||||
<span className="font-bold text-zinc-900 dark:text-white">3</span>
|
<span className="font-bold text-zinc-900 dark:text-white">{vendorOrders.length}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</SpotlightCard>
|
</SpotlightCard>
|
||||||
@@ -231,26 +269,11 @@ const VendorDashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
<OrderDetailsModal
|
<OrderDetailsModal isOpen={isOrderModalOpen} onClose={() => setIsOrderModalOpen(false)} order={selectedOrder} />
|
||||||
isOpen={isOrderModalOpen}
|
<VendorFinancialSummaryModal isOpen={isFinancialModalOpen} onClose={() => setIsFinancialModalOpen(false)} data={financialData} />
|
||||||
onClose={() => setIsOrderModalOpen(false)}
|
<ComplianceDetailsModal isOpen={isComplianceModalOpen} onClose={() => setIsComplianceModalOpen(false)} vendorData={vendorData} />
|
||||||
order={selectedOrder}
|
<PerformanceMetricsModal isOpen={isPerformanceModalOpen} onClose={() => setIsPerformanceModalOpen(false)} vendorData={vendorData} />
|
||||||
/>
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
<VendorFinancialSummaryModal
|
|
||||||
isOpen={isFinancialModalOpen}
|
|
||||||
onClose={() => setIsFinancialModalOpen(false)}
|
|
||||||
data={financialData}
|
|
||||||
/>
|
|
||||||
<ComplianceDetailsModal
|
|
||||||
isOpen={isComplianceModalOpen}
|
|
||||||
onClose={() => setIsComplianceModalOpen(false)}
|
|
||||||
vendorData={vendorData}
|
|
||||||
/>
|
|
||||||
<PerformanceMetricsModal
|
|
||||||
isOpen={isPerformanceModalOpen}
|
|
||||||
onClose={() => setIsPerformanceModalOpen(false)}
|
|
||||||
vendorData={vendorData}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+45
-2
@@ -71,7 +71,49 @@ const VendorOrders = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SpotlightCard className="overflow-hidden">
|
<SpotlightCard className="overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
{/* Mobile Card View */}
|
||||||
|
<div className="md:hidden divide-y divide-zinc-100 dark:divide-white/5">
|
||||||
|
{filteredOrders.length > 0 ? filteredOrders.map(order => (
|
||||||
|
<div
|
||||||
|
key={order.id}
|
||||||
|
onClick={() => handleOrderClick(order)}
|
||||||
|
className="p-4 active:bg-zinc-50 dark:active:bg-white/5 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start mb-2">
|
||||||
|
<div className="flex-1 min-w-0 mr-3">
|
||||||
|
<h4 className="font-medium text-sm text-zinc-900 dark:text-white truncate">
|
||||||
|
{order.projectAddress || order.project}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-zinc-500 font-mono mt-0.5">{order.id}</p>
|
||||||
|
</div>
|
||||||
|
<ChevronRight size={16} className="text-zinc-400 shrink-0 mt-1" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide ${getStatusColor(order.status)}`}>
|
||||||
|
{order.status === 'delivered' && <CheckCircle size={10} />}
|
||||||
|
{order.status === 'shipped' && <Truck size={10} />}
|
||||||
|
{(order.status === 'pending' || order.status === 'confirmed') && <Clock size={10} />}
|
||||||
|
{order.status}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-zinc-500 flex items-center gap-1">
|
||||||
|
<Package size={12} /> {order.items ? `${order.items.length} items` : order.item}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center text-xs">
|
||||||
|
<span className="text-zinc-500">Due: {order.dueDate || 'N/A'}</span>
|
||||||
|
<span className="font-mono font-semibold text-zinc-900 dark:text-white">${(order.total || order.amount).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="p-12 text-center text-zinc-500">
|
||||||
|
<Package size={40} className="mx-auto mb-3 opacity-20" />
|
||||||
|
<p>No orders found matching this filter.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Table View */}
|
||||||
|
<div className="hidden md:block overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-200 dark:border-white/10 text-xs font-bold uppercase tracking-wider text-zinc-500 bg-zinc-50/50 dark:bg-white/5">
|
<tr className="border-b border-zinc-200 dark:border-white/10 text-xs font-bold uppercase tracking-wider text-zinc-500 bg-zinc-50/50 dark:bg-white/5">
|
||||||
@@ -128,7 +170,7 @@ const VendorOrders = () => {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{filteredOrders.length === 0 && (
|
{filteredOrders.length === 0 && (
|
||||||
<div className="p-12 text-center text-zinc-500">
|
<div className="hidden md:block p-12 text-center text-zinc-500">
|
||||||
<Package size={48} className="mx-auto mb-4 opacity-20" />
|
<Package size={48} className="mx-auto mb-4 opacity-20" />
|
||||||
<p>No orders found matching this filter.</p>
|
<p>No orders found matching this filter.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,6 +183,7 @@ const VendorOrders = () => {
|
|||||||
onClose={() => setIsOrderModalOpen(false)}
|
onClose={() => setIsOrderModalOpen(false)}
|
||||||
order={selectedOrder}
|
order={selectedOrder}
|
||||||
/>
|
/>
|
||||||
|
<span className="attribution-ghost">igotsar.matyas | LynkedUpPro - Turns out roofing CRMs don't build themselves. Who knew?</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user