diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..5e2e6111d5fb0dd166e25db214a3b898bc99e4a6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +.git +.env +.env.local +*.log +output +migrated_prompt_history diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..0eb19ac08b5e172b93edbb0dee28f5325a5f3ee7 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Optional. Leave empty to use the in-memory local database. +# MONGODB_URI=mongodb://localhost:27017/buildtrack_db + +# Optional. Set a strong value for production login tokens. +# JWT_SECRET=replace-with-a-long-random-secret + +# Hugging Face Spaces sets PORT automatically. Local default is 7860. +# PORT=7860 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..a547bf36d8d11a4f89c59c144f24795749086dd1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/App.tsx b/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..06a7fbd73e5bf770ff2a826d0fbef26a8ebc4bed --- /dev/null +++ b/App.tsx @@ -0,0 +1,725 @@ + +import React, { useState, useEffect } from 'react'; +import { NotificationProvider } from './contexts/NotificationContext'; +import Layout from './components/Layout'; +import Dashboard from './components/Dashboard'; +import MasterControl from './components/MasterControl'; +import SiteExecution from './components/SiteExecution'; +import FinancialControl from './components/FinancialControl'; +import LiabilityTracker from './components/LiabilityTracker'; +import DocumentManager from './components/DocumentManager'; +import ProjectList from './components/ProjectList'; +import Auth from './components/Auth'; +import TaskManager from './components/TaskManager'; +import MemberManager from './components/MemberManager'; +import GanttChart from './components/GanttChart'; +import FinancialAnalytics from './components/FinancialAnalytics'; +import Procurement from './components/Procurement'; +import SubcontractorPortal from './components/SubcontractorPortal'; +import QCSafety from './components/QCSafety'; +import Reporting from './components/Reporting'; +import PhotoLogs from './components/PhotoLogs'; +import EquipmentManager from './components/EquipmentManager'; +import AttendanceManager from './components/AttendanceManager'; +import ChangeOrderManager from './components/ChangeOrderManager'; +import SustainabilityTracker from './components/SustainabilityTracker'; +import BimViewer from './components/BimViewer'; +import ClientPortal from './components/ClientPortal'; +import VendorAnalytics from './components/VendorAnalytics'; +import LocalAssistant from './components/LocalAssistant'; +import { CommentSection } from './components/Collaboration'; +import { MOCK_PROJECTS } from './constants'; +import { ProjectState, ProjectDocument, DPR, UserRole, BOQItem, AiSuggestion, Material, Bill, ExtractedDPR, User, Task } from './types'; +import { parseBOQDocument, analyzeDocumentContent, processWhatsAppMessage } from './services/localAnalysisService'; +import { MessageSquare, Send, Loader2, Smartphone, AlertCircle, LayoutDashboard, PlusCircle } from 'lucide-react'; +import { useLocalCollection } from './hooks/useLocalCollection'; + +const stripUndefined = (obj: any): any => { + if (Array.isArray(obj)) return obj.map(stripUndefined); + if (typeof obj === 'object' && obj !== null) { + const res: any = {}; + for (const key in obj) { + if (obj[key] !== undefined) res[key] = stripUndefined(obj[key]); + } + return res; + } + return obj; +}; + +const App: React.FC = () => { + const [user, setUser] = useState(null); + const [isAuthReady, setIsAuthReady] = useState(false); + const [activeTab, setActiveTab] = useState('dashboard'); + + const authRefreshKey = user?.uid || localStorage.getItem('auth_token') || 'guest'; + const { data: projectsData, add: addProject, update: updateProjectStorage } = useLocalCollection('projects', authRefreshKey); + + const [activeProjectId, setActiveProjectId] = useState(null); + const [activeProjectRole, setActiveProjectRole] = useState(null); + const [activeProjectTasks, setActiveProjectTasks] = useState([]); + const [activeProjectMembers, setActiveProjectMembers] = useState([]); + const [isSimulatingWhatsApp, setIsSimulatingWhatsApp] = useState(false); + const [whatsappMessage, setWhatsappMessage] = useState(''); + const [selectedDocId, setSelectedDocId] = useState(null); + + // Connection Test omitted since Firebase is removed + // Auth Listener replaced with short initialization + useEffect(() => { + setIsAuthReady(true); + }, []); + + // Sync Projects + const projects = projectsData; + useEffect(() => { + if (projects.length === 0 && user) { + MOCK_PROJECTS.forEach(p => { + addProject({ ...p, ownerUid: user.uid } as any); + }); + } + }, [user, projects.length, addProject]); + + // Project Role Listener + useEffect(() => { + if (user && activeProjectId) { + // Local mode: give them the role they logged in with for everything + setActiveProjectRole(user.role); + } + }, [user, activeProjectId]); + + useEffect(() => { + if (user) { + setActiveProjectMembers([user]); + } + }, [user]); + + useEffect(() => { + if (!activeProjectId && projects.length > 0) { + setActiveProjectId(projects[0].id); + } + }, [activeProjectId, projects]); + + // Tasks local fetch + useEffect(() => { + const fetchTasks = async () => { + try { + const token = localStorage.getItem('auth_token'); + const res = await fetch(`/api/collections/tasks_${activeProjectId}`, { + headers: token ? { 'Authorization': `Bearer ${token}` } : {} + }); + if (!res.ok) throw new Error("Tasks fetch failed"); + const data = await res.json(); + setActiveProjectTasks(data || []); + } catch (e) { + console.error("Failed to fetch tasks", e); + } + }; + if (activeProjectId) { + fetchTasks(); + } + }, [activeProjectId]); + + const activeProject = projects.find(p => p.id === activeProjectId); + + const handleLogout = async () => { + localStorage.removeItem('local_user_uid'); + localStorage.removeItem('auth_token'); + setUser(null); + setActiveProjectId(null); + }; + + const handleCreateProject = async (newProject: Partial) => { + if (!user) return; + const id = `P${Date.now()}`; + const project: ProjectState = { + ...newProject as ProjectState, + id, + ownerUid: user.uid, + memberUids: [user.uid], + aiSuggestions: [], + materials: [], + subContractors: [], + documents: [], + dprs: [], + boq: [], + bills: [], + liabilities: [], + milestones: [], + purchaseOrders: [], + qualityChecks: [], + safetyChecks: [], + photoLogs: [], + equipment: [], + attendance: [], + changeOrders: [], + vendors: [], + weatherForecast: [], + bimModels: [] + }; + + addProject(stripUndefined(project)); + setActiveProjectId(id); + }; + + const handleUpdateProject = async (projectId: string, updater: (proj: ProjectState) => ProjectState) => { + const project = projects.find(p => p.id === projectId); + if (!project) return; + + const updated = updater(project); + updateProjectStorage(projectId, stripUndefined(updated)); + }; + + const handleAddDocument = async (newDoc: ProjectDocument) => { + if (!activeProjectId || !activeProject) return; + + // 1. Add Document immediately + handleUpdateProject(activeProjectId, (project) => ({ + ...project, + documents: [newDoc, ...project.documents] + })); + + // 2. Trigger Auto-Analysis based on Doc Type + try { + let mimeType = 'application/pdf'; + if (newDoc.type === 'JPG' || newDoc.type === 'PNG') mimeType = 'image/jpeg'; + + const suggestions = await analyzeDocumentContent(newDoc.name, newDoc.category, activeProject.boq, newDoc.content, mimeType); + + if (suggestions && suggestions.length > 0) { + handleUpdateProject(activeProjectId, (project) => ({ + ...project, + documents: project.documents.map(d => d.id === newDoc.id ? { ...d, isAnalyzed: true } : d), + aiSuggestions: [...suggestions.map(s => ({ ...s, docId: newDoc.id })), ...project.aiSuggestions] + })); + } + } catch (e) { + console.error("Auto-analysis failed", e); + } + }; + + const handleAnalyzeDocument = (docId: string, suggestions: AiSuggestion[]) => { + if (!activeProjectId) return; + handleUpdateProject(activeProjectId, (project) => ({ + ...project, + documents: project.documents.map(d => d.id === docId ? { ...d, isAnalyzed: true } : d), + aiSuggestions: [...suggestions, ...project.aiSuggestions] + })); + setActiveTab('dashboard'); // Switch to dashboard to see results + }; + + const handleImportBOQItems = (items: BOQItem[]) => { + if (!activeProjectId) return; + handleUpdateProject(activeProjectId, (project) => ({ + ...project, + boq: [...project.boq, ...items] // Append new items. In real app, this might merge or replace. + })); + }; + + const handleApplySuggestion = async (suggestionId: string) => { + if (!activeProjectId || !activeProject) return; + const suggestion = activeProject.aiSuggestions.find(s => s.id === suggestionId); + if (!suggestion) return; + + if (suggestion.type === 'BOQ_IMPORT') { + const relatedDoc = activeProject.documents.find(d => d.id === suggestion.docId); + if (relatedDoc) { + let mimeType = 'application/pdf'; + if (relatedDoc.type === 'JPG' || relatedDoc.type === 'PNG') mimeType = 'image/jpeg'; + const items = await parseBOQDocument(relatedDoc.name, relatedDoc.content, mimeType); + handleImportBOQItems(items); + } + handleUpdateProject(activeProjectId, (project) => ({ + ...project, + aiSuggestions: project.aiSuggestions.map(s => s.id === suggestionId ? { ...s, status: 'APPLIED' as const } : s) + })); + return; + } + + if (suggestion.type === 'DPR_ENTRY' && suggestion.value) { + const dprData = suggestion.value as ExtractedDPR; + // Resolve IDs + let subId = undefined; + if (dprData.subContractorName) { + subId = activeProject.subContractors?.find(s => + s.name.toLowerCase().includes(dprData.subContractorName!.toLowerCase()) + )?.id; + } + + let materialsUsed = []; + if (dprData.materials) { + materialsUsed = dprData.materials.map(m => { + const mat = activeProject.materials.find(ex => ex.name.toLowerCase().includes(m.name.toLowerCase())); + return mat ? { materialId: mat.id, qty: m.qty } : null; + }).filter(Boolean) as any; + } + + const newDPR: DPR = { + id: `DPR-AI-${Date.now()}`, + date: dprData.date || new Date().toISOString().split('T')[0], + activity: dprData.activity || 'Reported Activity', + location: dprData.location || 'Site', + laborCount: dprData.laborCount || 0, + remarks: dprData.remarks || '', + linkedBoqId: dprData.linkedBoqId, + workDoneQty: dprData.workDoneQty, + subContractorId: subId, + materialsUsed: materialsUsed + }; + handleAddDPR(newDPR); + + handleUpdateProject(activeProjectId, (project) => ({ + ...project, + aiSuggestions: project.aiSuggestions.map(s => s.id === suggestionId ? { ...s, status: 'APPLIED' as const } : s) + })); + return; + } + + handleUpdateProject(activeProjectId, (project) => { + let updatedProject = { ...project }; + + // Update data based on suggestion type + if (suggestion.type === 'QUANTITY_UPDATE' && suggestion.linkedId && suggestion.value) { + updatedProject.boq = project.boq.map(b => b.id === suggestion.linkedId ? { ...b, executedQty: b.executedQty + suggestion.value } : b); + } else if (suggestion.type === 'BILL_DETECTION' && suggestion.value) { + const billVal = suggestion.value as any; // could be object or number + const amount = typeof billVal === 'object' ? billVal.amount : billVal; + + const newBill = { + id: `BILL-AI-${Date.now()}`, + type: 'VENDOR_INVOICE' as const, + entityName: suggestion.title.split('from ')[1] || 'Unknown Vendor', + amount: Number(amount), + date: new Date().toISOString().split('T')[0], + status: 'PENDING' as const + }; + updatedProject.bills = [newBill, ...project.bills]; + } + + updatedProject.aiSuggestions = project.aiSuggestions.map(s => s.id === suggestionId ? { ...s, status: 'APPLIED' as const } : s); + return updatedProject; + }); + }; + + const handleDismissSuggestion = (suggestionId: string) => { + if (!activeProjectId) return; + handleUpdateProject(activeProjectId, (project) => ({ + ...project, + aiSuggestions: project.aiSuggestions.map(s => s.id === suggestionId ? { ...s, status: 'DISMISSED' as const } : s) + })); + }; + + const handleAddDPR = (newDPR: DPR) => { + if (!activeProjectId) return; + handleUpdateProject(activeProjectId, (project) => { + const updatedDPRs = [newDPR, ...project.dprs]; + let updatedBOQ = project.boq; + let updatedSubContractors = project.subContractors; + let updatedLiabilities = project.liabilities; + + // 1. Update BOQ Executed Qty + if (newDPR.linkedBoqId && newDPR.workDoneQty) { + updatedBOQ = project.boq.map(item => { + if (item.id === newDPR.linkedBoqId) { + return { ...item, executedQty: item.executedQty + (newDPR.workDoneQty || 0) }; + } + return item; + }); + + // 2. Automated Sub-contractor Progress Tracking + if (newDPR.subContractorId && newDPR.workDoneQty) { + const sub = project.subContractors.find(s => s.id === newDPR.subContractorId); + if (sub) { + // Find agreed rate for this BOQ item + const rateInfo = sub.agreedRates.find(r => r.boqId === newDPR.linkedBoqId); + const rate = rateInfo ? rateInfo.rate : 0; + const workValue = newDPR.workDoneQty * rate; + + if (workValue > 0) { + // Update SC stats + updatedSubContractors = project.subContractors.map(s => { + if (s.id === sub.id) { + return { + ...s, + totalWorkValue: s.totalWorkValue + workValue, + currentLiability: s.currentLiability + workValue + }; + } + return s; + }); + + // Create Liability Entry automatically + const newLiability = { + id: `L-AUTO-${Date.now()}`, + description: `Unbilled Work: ${sub.name} (${newDPR.date})`, + type: 'UNBILLED_WORK' as const, + amount: workValue, + dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0] // Net 30 default + }; + updatedLiabilities = [newLiability, ...project.liabilities]; + } + } + } + } + + // 3. Update Material Stock + let updatedMaterials = project.materials; + if (newDPR.materialsUsed && newDPR.materialsUsed.length > 0) { + updatedMaterials = project.materials.map(mat => { + const used = newDPR.materialsUsed?.find(u => u.materialId === mat.id); + if (used) { + return { + ...mat, + totalConsumed: mat.totalConsumed + used.qty, + currentStock: mat.currentStock - used.qty + }; + } + return mat; + }); + } + + return { + ...project, + dprs: updatedDPRs, + boq: updatedBOQ, + materials: updatedMaterials, + subContractors: updatedSubContractors, + liabilities: updatedLiabilities + }; + }); + }; + + const handleReceiveMaterial = (materialId: string, receivedQty: number, newRate?: number) => { + if (!activeProjectId) return; + handleUpdateProject(activeProjectId, (project) => ({ + ...project, + materials: project.materials.map(mat => { + if (mat.id === materialId) { + const newTotalReceived = mat.totalReceived + receivedQty; + const newStock = mat.currentStock + receivedQty; + // Weighted Average Rate Calculation + const oldVal = mat.currentStock * mat.averageRate; + const newVal = receivedQty * (newRate || mat.averageRate); + const newAvgRate = (oldVal + newVal) / newStock; + + return { + ...mat, + totalReceived: newTotalReceived, + currentStock: newStock, + averageRate: newRate ? newAvgRate : mat.averageRate + }; + } + return mat; + }) + })); + }; + + const handleAddBill = (newBill: Bill) => { + if (!activeProjectId) return; + handleUpdateProject(activeProjectId, (project) => ({ + ...project, + bills: [newBill, ...project.bills] + })); + }; + + const handleBillItemizedUpdate = (items: { boqId: string; amount: number }[]) => { + if (!activeProjectId) return; + handleUpdateProject(activeProjectId, (project) => ({ + ...project, + boq: project.boq.map(b => { + const update = items.find(i => i.boqId === b.id); + if (update) { + return { ...b, billedAmount: (b.billedAmount || 0) + update.amount }; + } + return b; + }) + })); + }; + + const handleUpdatePDRemarks = (entityType: 'MATERIAL' | 'BILL' | 'DPR' | 'SUBCONTRACTOR', entityId: string, remarks: string) => { + if (!activeProjectId) return; + handleUpdateProject(activeProjectId, (project) => { + if (entityType === 'MATERIAL') { + return { ...project, materials: project.materials.map(m => m.id === entityId ? { ...m, pdRemarks: remarks } : m) }; + } + if (entityType === 'BILL') { + return { ...project, bills: project.bills.map(b => b.id === entityId ? { ...b, pdRemarks: remarks } : b) }; + } + if (entityType === 'SUBCONTRACTOR') { + return { ...project, subContractors: project.subContractors.map(s => s.id === entityId ? { ...s, pdRemarks: remarks } : s) }; + } + return project; + }); + }; + + const handleAddBOQItem = (newItem: BOQItem) => { + if (!activeProjectId) return; + handleUpdateProject(activeProjectId, (project) => ({ + ...project, + boq: [...project.boq, newItem] + })); + }; + + const handleUpdateBOQItem = (itemId: string, updatedItem: Partial) => { + if (!activeProjectId) return; + handleUpdateProject(activeProjectId, (project) => ({ + ...project, + boq: project.boq.map(item => item.id === itemId ? { ...item, ...updatedItem } : item) + })); + }; + + const handleSimulateWhatsApp = async () => { + if (!activeProjectId || !activeProject || !whatsappMessage.trim()) return; + + setIsSimulatingWhatsApp(true); + try { + const extracted = await processWhatsAppMessage(whatsappMessage, activeProject.boq); + if (extracted) { + // Create an AI Suggestion based on WhatsApp message + const newSuggestion: AiSuggestion = { + id: `WA-SUG-${Date.now()}`, + docId: 'WHATSAPP', + type: 'DPR_ENTRY', + title: 'WhatsApp Progress Update', + description: `Extracted from message: "${whatsappMessage.substring(0, 50)}..."`, + value: extracted, + status: 'PENDING' + }; + + handleUpdateProject(activeProjectId, (project) => ({ + ...project, + aiSuggestions: [newSuggestion, ...project.aiSuggestions] + })); + + setWhatsappMessage(''); + setActiveTab('dashboard'); + } + } catch (err) { + console.error("WhatsApp simulation failed", err); + } finally { + setIsSimulatingWhatsApp(false); + } + }; + + if (!isAuthReady) { + return ( +
+ +
+ ); + } + + if (!user) { + return ; + } + + if (!activeProject) { + return ( +
+ {}} // Disabled for real users + /> +
+ ); + } + + const renderContent = () => { + if (!activeProjectId || !activeProject) { + return ( +
+
+ +
+

No Project Selected

+

+ Please select a project from the sidebar to view its dashboard and manage construction activities. +

+ +
+ ); + } + + switch (activeTab) { + case 'dashboard': + return ( + handleUpdateProject(activeProjectId, updater)} + /> + ); + case 'master': + return ; + case 'site': + return ; + case 'finance': + return ; + case 'analytics': + return ( +
+ +
+

Sustainability & Waste Tracking

+ +
+
+ ); + case 'procurement': + return ( +
+ +
+

Vendor Performance

+ +
+
+ ); + case 'equipment': + return ; + case 'labor': + return ; + case 'subcontractors': + return ; + case 'qc-safety': + return ; + case 'gantt': + return ; + case 'bim': + return ; + case 'photos': + return ; + case 'reports': + return ; + case 'client': + return ; + case 'liability': + return ; + case 'documents': + return ( +
+
+ +
+ {selectedDocId && ( +
+ +
+ )} +
+ ); + case 'tasks': + return ( +
+ +
+ ); + case 'team': + return ( +
+ +
+ ); + default: + return handleUpdateProject(activeProjectId, updater)} />; + } + }; + + return ( + + setActiveProjectId(null)} + projectName={activeProject.name} + user={{ ...user, role: activeProjectRole || user.role }} + onLogout={handleLogout} + > +
+
+ {renderContent()} +
+ + {/* Collaboration Sidebar */} +
+ +
+
+ +

WhatsApp DPR Simulation

+
+

+ Paste a message from your site WhatsApp group to automatically extract progress data. +

+